jim800121chen c63886a194 feat(visionA-backend): Phase 0.9 模型庫存取 — FAA delegated download token(B1+B2)
對齊 ADR-017 v1.2:模型庫下載走 visionA 簽 MC delegated token → Client 直連 FAA。

B2 — MC download token client(internal/fileaccess):
- DownloadTokenIssuer: GetServiceToken(打 MC /oauth/token,client_credentials +
  scope files:download.delegate,含 token cache)+ IssueDownloadToken(打 MC Issue 簽 fdt_)
- secret / service token / fdt token 三層全程用 hashShort 遮罩不 log
- FileAccessConfig + VISIONA_FILE_ACCESS_* env + main.go wire(Enabled() 才接)

B1 — object_key 斷層:
- model.Model 加 FAAObjectKey(json:"-" 不揭露前端)
- PromoteToModels 寫入(用 promote response TargetObjectKey = models/{userID}/{jobID}.nef)
- 三方對映天然一致(visionA Issue / FAA path / MC validate)
- 第一階段框死只 Source=converted 類 model,上傳類 download 回 501

download endpoint:
- GET /api/models/:id/download(owner-only)→ {download_url, token, expires_at}
- 前端帶 Authorization: Bearer 直連 FAA(不經 visionA、不經 AWS)
- 401/403/404/501/502 分明,502 對外 mask 不洩漏 MC 內部狀態

測試: 13 + 8 unit test(mock MC + fake issuer,httptest 驗真 HTTP);go build/vet/test 全綠。
Reviewer: 0 Critical / 0 Major / 3 Minor / 4 Suggestion,通過。

技術債(正式上線前): 第一階段 PoC 共用 FAA service client,MC 規範禁止 client 混用
usage、secret 不共用,須 MC 配發 visionA 專屬 usage=file_api client。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 04:06:09 +08:00

380 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package config 定義 visionA-backend 的組態結構,對齊 TDD §2.10。
//
// 雛形遵循 12-Factor App所有可變設定皆透過環境變數注入不寫死在程式碼裡。
// `api-server` 與 `remote-proxy` 共享同一份 Config各自只消費自己需要的欄位。
package config
import "time"
// Config 是整個 visionA-backend 的環境設定。
//
// 所有欄位皆由 Load() 從環境變數讀取並套用預設值。
// 欄位命名對齊 TDD §2.10;新增欄位時請同步更新 `.env.example`(待 B6
type Config struct {
Server ServerConfig
Session SessionConfig
Auth AuthConfig
OIDC OIDCConfig
UserSession UserSessionConfig
Storage StorageConfig
Model ModelConfig
Tunnel TunnelConfig
Logger LoggerConfig
CORS CORSConfig
// Conversion 控制 Phase 0.8 轉檔功能整合converter / FAA / MC service token
// 對齊 .autoflow/04-architecture/conversion.md §5.3。
Conversion ConversionConfig
// FileAccess 控制 Phase 0.9 模型庫 model 直連 FAA 下載鏈ADR-017 (a))。
FileAccess FileAccessConfig
}
// ServerConfig 控制 HTTP listener 的位址與埠號。
//
// api-server 端使用 Port 提供 REST / WebSocket
// remote-proxy 端使用 TunnelPort面向 local agent與 InternalPort面向 api-server
//
// Port 預設為 3721 — 對齊 local-tool 的 base URL這樣 local-tool 前端切到雲端版時
// base URL 可以維持一致,降低前端的 dev 流程切換成本B4 決定)。
type ServerConfig struct {
Host string // VISIONA_HOST預設 "0.0.0.0"
Port int // VISIONA_API_PORT預設 3721對齊 local-tool
TunnelPort int // VISIONA_TUNNEL_PORT預設 3800
InternalPort int // VISIONA_PROXY_INTERNAL_PORT預設 3801
// RelayPublicURL 是 agent 連 tunnel 用的對外可達 URL通常是 wss://.../tunnel/connect
// 的 origin 部分wss://relay.visionA.cloud
// AB11 新增:/api/pairing/exchange 會把這個值回傳給 agent。
// 雛形預設為空 — handler 會 fallback 到 placeholder `wss://relay.visionA.cloud`。
RelayPublicURL string
// SeedDemoData 控制 api-server 啟動時是否塞入示範用 device + model + pairing token。
// 預設 false本機開發或 demo 時可設 VISIONA_SEED_DEMO_DATA=true 開啟,
// 方便前端不必跑完整 pairing 流程就能看到資料。
SeedDemoData bool
}
// SessionConfig 控制 SessionStore 的實作選擇與連線資訊。
//
// Backend:
// - "inmemory" — remote-proxy 端持有 yamux session 的唯一來源
// - "proxy-client" — api-server 端透過 internal HTTP 查詢 remote-proxy
type SessionConfig struct {
Backend string // VISIONA_SESSION_BACKEND預設 "inmemory"
ProxyInternalURL string // VISIONA_PROXY_INTERNAL_URL預設 "http://localhost:3801"
}
// AuthConfig 控制雛形專用的 user fallback 與 pairing token。
//
// OB52026-04-26起認證走 OIDCOIDCConfig
// Phase 0.72026-05-01security audit 移除了 api.Deps.StaticUserID handler fallback
// (見 .autoflow/05-implementation/review/phase-0.7-security-audit.md C1
// 此處的 StaticUserID 欄位**僅供 dev seedVISIONA_SEED_DEMO_DATA=true與 unit test
// fixture 讀取使用**,不再注入 api.Deps、不影響 stage/prod 認證行為。
type AuthConfig struct {
// StaticUserID — Deprecated for routing/auth use. 僅供 dev seed / unit test。
// 見 internal/api/api.go 的 Deps 註解stage/prod 留空無影響。
StaticUserID string // VISIONA_STATIC_USER_ID預設 "demo-user"dev seed only
PairingToken string // VISIONA_PAIRING_TOKEN格式必須為 vAc_ + 32 hex
SigningSecret string // VISIONA_STORAGE_SIGNING_SECRETpresigned URL HMAC secret
}
// OIDCConfig 控制 OpenID Connect 登入流程BFF 模式)。
//
// 對齊 oidc-tdd.md §13.1 + ADR-010 + ADR-011 + ADR-013。
// OB5 起 OIDC 是唯一認證路徑A1 起支援 public PKCE-only client。
type OIDCConfig struct {
// IssuerURL 是 OIDC IdP 的 issuer不帶結尾斜線例如
// dev: http://localhost:5050
// prod: https://members.innovedus.com
// 對齊 VISIONA_OIDC_ISSUER_URL。
IssuerURL string
// ClientID 是 visionA 在 IdP 註冊的 OAuth client idconfidential 或 public 皆可)。
// 對齊 VISIONA_OIDC_CLIENT_ID。
ClientID string
// ClientSecret 為**選填**A1, 2026-05-01
// - 有值 → confidential client modeclient_secret + PKCE 雙保險)
// - 留空 → PKCE-only public client mode純依靠 PKCE 防 code interception
// 兩種 mode 由 IdP 決定visionA-backend 都支援(見 ADR-013
// **禁止 commit 進 repo**;對齊 VISIONA_OIDC_CLIENT_SECRET。
ClientSecret string
// RedirectURL 是 visionA-backend 的 callback URL必須與 IdP 註冊值完全一致。
// dev: http://localhost:3721/api/auth/callback
// prod: https://api.visiona.cloud/api/auth/callback
// 對齊 VISIONA_OIDC_REDIRECT_URL。
RedirectURL string
// PostLoginURL 是 callback 完成後 302 回 frontend 的 base URL。
// dev: http://localhost:3000
// prod: https://app.visiona.cloud
// 對齊 VISIONA_FRONTEND_URL沿用 oidc-tdd.md §13.1 命名)。
PostLoginURL string
// ServiceClientID 是「visionA-backend 以服務身份呼叫 MC API」用的 client id
// 預留給未來 client_credentials grant flow例如查詢使用者組織、推送通知等
//
// **A1 階段不啟用**Validate() 不檢查、main.go 不 wire只先把 config 鉤子留好,
// 之後接時不必再改 OIDCConfig schema。對齊 VISIONA_OIDC_SERVICE_CLIENT_ID。
ServiceClientID string
// ServiceClientSecret 是 service clientclient_credentials grant的 secret。
// 與 ServiceClientID 配對使用;同樣 A1 階段不啟用、Validate() 不檢查。
// **禁止 commit 進 repo**;對齊 VISIONA_OIDC_SERVICE_CLIENT_SECRET。
ServiceClientSecret string
}
// UserSessionConfig 控制 OIDC 登入後在 browser 端建立的 cookie session。
//
// 注意:與既有 SessionConfigtunnel session 用)刻意分開,避免命名混淆。
// 對齊 oidc-tdd.md §5、§13.1。
type UserSessionConfig struct {
// Secret 是 cookie HMAC-SHA256 簽章金鑰;應為至少 32 byte 隨機字串。
// 對齊 VISIONA_SESSION_SECRET。
Secret string
// CookieName 預設 "visiona_session"。
// 對齊 VISIONA_SESSION_COOKIE_NAME。
CookieName string
// CookieDomaindev 留空host-only cookieprod 設 ".visiona.cloud"。
// 對齊 VISIONA_SESSION_COOKIE_DOMAIN。
CookieDomain string
// CookieSecure 控制 Secure flag。dev=falsehttpprod=truehttps
// 對齊 VISIONA_SESSION_COOKIE_SECURE。
CookieSecure bool
// AbsoluteTTL 是 session 的最長存活時間(從 Create 起算)。預設 168h7 天)。
// 對齊 VISIONA_SESSION_ABSOLUTE_TTL。
AbsoluteTTL time.Duration
// IdleTTL 是 session 的閒置存活時間(從 LastSeenAt 起算)。預設 24h。
// 對齊 VISIONA_SESSION_IDLE_TTL。
IdleTTL time.Duration
}
// StorageConfig 控制儲存層實作LocalFS / S3與路徑。
type StorageConfig struct {
Backend string // VISIONA_STORAGE_BACKEND預設 "localfs"
RootDir string // VISIONA_STORAGE_LOCALFS_ROOT預設 "./data/storage"
BaseURL string // VISIONA_STORAGE_LOCALFS_BASE_URL預設 "http://localhost:3721/storage"(對齊 api-server port
}
// ModelConfig 針對模型資源的驗證限制(大小等)。
type ModelConfig struct {
// MaxSizeMB 是允許上傳的單一模型檔案大小上限MB
// PRD §8.4 規範 Phase 0 為 100 MB可由 VISIONA_MODEL_MAX_SIZE_MB 覆寫。
MaxSizeMB int
}
// TunnelConfig 控制 tunnel 心跳與掉線判定閾值,對齊 tunnel.md §4.2。
type TunnelConfig struct {
// HeartbeatInterval 為 yamux KeepAliveInterval 值。預設 10s。
HeartbeatInterval time.Duration
// IdleTimeout 為判定對端失聯的時間。預設 30s= 3 次心跳未回)。
IdleTimeout time.Duration
}
// LoggerConfig 控制結構化 logger 的輸出等級。
type LoggerConfig struct {
Level string // VISIONA_LOG_LEVELdebug / info / warn / error預設 "info"
}
// ConversionConfig 控制 Phase 0.8 / 0.8b 轉檔功能整合。
//
// 對齊 docs/autoflow/04-architecture/conversion.md §3、
// `adr/adr-015-server-to-server-api-key.md`、`adr/adr-016-download-via-converter.md`。
//
// 啟用判定(由 Enabled() 給 main.go 用Phase 0.8b v0.6 起2 個必要欄位
// ConverterBaseURL / ConverterAPIKey**全部非空**才視為啟用;任一缺即視為未啟用,
// 5 個 /api/conversion/* endpoint 不會 wiremain.go 在 wire 階段跳過、log warn
//
// **Phase 0.8b 變更**:服務間認證從 OAuth client_credentials 改為 pre-shared API keyADR-015
//
// **Phase 0.8b T5 完成**(見 conversion.md §3.2 / ADR-015 §5 §7原暫留欄位
// TenantID / DelegatedTTLSeconds 已移除 — MC 認證鏈與 delegated download token 機制
// 都不存在了,兩個欄位連同對應 envVISIONA_OIDC_TENANT_ID /
// VISIONA_FAA_DELEGATED_TTL_SECONDS一併清除。
//
// **Phase 0.8b v0.6 T4 完成**(見 ADR-016 §2 / conversion.md v0.6.1 §3.1):原 FAA 相關
// 欄位 FAABaseURL / FAAAPIKey 已移除 — visionA 端不再直接呼叫 FAAdownload / promote
// 流程改走 converter.GetResultADR-016 撤回 v0.5 設計缺口),兩個欄位連同對應 env
// VISIONA_FAA_BASE_URL / VISIONA_FAA_API_KEY一併清除`Enabled()` 也簡化為只判
// converter 兩欄位。
type ConversionConfig struct {
// ConverterBaseURL 是 kneron_model_converter task-scheduler 服務的 base URL。
// 例http://192.168.0.130:9501dev / stage / https://converter.visiona.cloudprod
// 對齊 VISIONA_CONVERTER_BASE_URL留空 = 不啟用 Phase 0.8 轉檔功能。
ConverterBaseURL string
// ConverterAPIKey 是 visionA → converter 服務間認證的 pre-shared API keyPhase 0.8b 新增)。
// 對齊 VISIONA_CONVERTER_API_KEY以 `Authorization: Bearer <key>` 形式帶上。
// 雙方獨立產生(`openssl rand -hex 32`visionA 端的值必須與 converter 端的
// `CONVERTER_API_KEY` env 對齊;不對齊 → 下游 401visionA 端不重試,回 502 converter_auth_failed
// 對應 ADR-015 §3。
//
// 安全log 永遠不印此值全文(可印 `api_key_set=true/false` 或前 8 字元 prefix
// 部署用 AWS Secrets Manager / Vault嚴格分環境dev / stage / prod 各自獨立 key
ConverterAPIKey string
// MaxModelSizeMB 是 visionA-backend 端對上傳模型檔的大小上限MB
// 與 converter 端 limit 對齊converter 預設 500 MB
// 對齊 VISIONA_CONVERTER_MAX_MODEL_SIZE_MB預設 500。
MaxModelSizeMB int
}
// FileAccessConfig 控制「模型庫 model 直連 FAA 下載」鏈路ADR-017 (a))。
//
// 對齊 adr/adr-017-model-library-access.md §10stage e2e 實測藍本):
//
// visionA 用 service client 打 MC `/oauth/token`scope files:download.delegate
// → 打 MC `POST /file-access/download-tokens`Issue簽 opaque `fdt_` token
// → 回給 Client「FAA 下載 URL + fdt token」Client 帶 `Authorization: Bearer fdt_...`
// 直接 GET `{FAA}/files/{object_key}`。
//
// 啟用判定(由 Enabled() 給 main.go 用4 個必要欄位
// MCBaseURL / ServiceClientID / ServiceClientSecret / TenantID全部非空才視為啟用
// 任一缺即不 wire download token issuermodel download endpoint 回 501。
// FAABaseURL 是「組對外 download_url」用留空時 endpoint 也回 501無從組 URL
//
// ⚠️ 技術債ADR-017 §7 R1 / Q10第一階段 PoC 短期**共用 FAA 的 service client**
// `4242ba63...`stage 實測可拿 files:download.delegate token。MC 規範明訂「OAuth client
// 禁止混用 usage、secret 不共用」,故正式上線前須請 MC 配發 visionA 專屬 usage=file_api
// client 換掉此共用 client把 secret 邊界收回 visionA。詳見 .env.example 對應註解。
type FileAccessConfig struct {
// MCBaseURL 是 Member Center API base URL不帶結尾斜線
// stagehttps://stage-9527.innovedus.com:7850
// 對齊 VISIONA_FILE_ACCESS_MC_BASE_URL。
MCBaseURL string
// ServiceClientID 是打 MC `/oauth/token`client_credentials的 client id。
// ⚠️ 技術債:第一階段 PoC 共用 FAA service client`4242ba63...`)。
// 對齊 VISIONA_FILE_ACCESS_SERVICE_CLIENT_ID。
ServiceClientID string
// ServiceClientSecret 是 service client 的 secret。
// **禁止 commit 進 repo**;對齊 VISIONA_FILE_ACCESS_SERVICE_CLIENT_SECRET。
// 安全log 永遠不印此值全文。
ServiceClientSecret string
// TenantID 是簽 download token 時帶給 MC 的 tenant_id須與 FAA validate 的 tenant 一致)。
// stage732270c0-449c-489c-bfad-321e9bf89b3d
// 對齊 VISIONA_FILE_ACCESS_TENANT_ID。
TenantID string
// FAABaseURL 是 File Access Agent 對外 base URL不帶結尾斜線用來組回給 Client 的
// download_url`{FAABaseURL}/files/{object_key}`)。
// stagehttps://stage-9527.innovedus.com:5081
// 對齊 VISIONA_FILE_ACCESS_FAA_BASE_URL。
FAABaseURL string
// DownloadTokenTTLSeconds 是簽 download token 時帶給 MC 的 expires_in_seconds。
// ADR-017 Q2 區間 60300s預設 120s。對齊 VISIONA_FILE_ACCESS_DOWNLOAD_TOKEN_TTL_SECONDS。
DownloadTokenTTLSeconds int
}
// Enabled 回傳「模型庫 FAA 直連下載」是否啟用。
//
// 4 個必要欄位MCBaseURL / ServiceClientID / ServiceClientSecret / TenantID+ FAABaseURL
// 全部非空才視為啟用;任一缺 → main.go 不 wire download token issuer
// GET /api/models/:id/download 回 501。
func (c FileAccessConfig) Enabled() bool {
return c.MCBaseURL != "" &&
c.ServiceClientID != "" &&
c.ServiceClientSecret != "" &&
c.TenantID != "" &&
c.FAABaseURL != ""
}
// Enabled 回傳 Phase 0.8 / 0.8b conversion 是否啟用。
//
// **Phase 0.8b v0.6 T4 簡化**ADR-016 §2 / conversion.md v0.6.1 §3.1visionA 端撤回
// 對 FAA 的直接呼叫download / promote 改走 converter.GetResult只剩 ConverterBaseURL
// 與 ConverterAPIKey 兩個欄位需非空。任一缺 → 視為未啟用main.go 不會 wire
// conversion.Service5 個 endpoint 回 501 / 不註冊)。
func (c ConversionConfig) Enabled() bool {
return c.ConverterBaseURL != "" && c.ConverterAPIKey != ""
}
// CORSConfig 控制 api-server 對瀏覽器的 CORS 白名單。
//
// AllowedOrigins 為逗號分隔字串解析後的 slice
// 空時 api.Deps.validate() 會 fallback 到 http://localhost:3000前端 dev server
type CORSConfig struct {
AllowedOrigins []string // VISIONA_CORS_ALLOWED_ORIGINS逗號分隔
}
// Validate 在 Load() 之後檢查交叉依賴與必填欄位。
//
// OB5 起 OIDC 是唯一認證路徑,所有 OIDC 必填欄位永遠都要非空:
// - IssuerURL / ClientID / RedirectURL / PostLoginURL
// - UserSession.Secretcookie HMAC 簽章)
//
// ClientSecret 為**選填**A1, 2026-05-01
// - 有值 → confidential client mode標準 OAuth + PKCE 雙保險)
// - 留空 → PKCE-only public client mode依靠 PKCE 防 code interception
//
// 兩種 mode 由 IdP 決定visionA 都支援(見 ADR-013、oidc-tdd.md §13.1)。
//
// ServiceClientID / ServiceClientSecret 為 client_credentials grant 預留欄位,
// A1 階段不啟用、不檢查;之後若接服務間 API 呼叫再補 Validate。
//
// 缺任何**必填**項 → 回 *MissingEnvErrormain.go 啟動時 fatal log 退出。
// 維持單一 error 而非列表 — caller 只是 fail-fast 紀錄,不需要結構化處理。
func (c *Config) Validate() error {
missing := make([]string, 0, 5)
if c.OIDC.IssuerURL == "" {
missing = append(missing, "VISIONA_OIDC_ISSUER_URL")
}
if c.OIDC.ClientID == "" {
missing = append(missing, "VISIONA_OIDC_CLIENT_ID")
}
// ClientSecret 為選填public PKCE-only client 留空)— 不檢查。
if c.OIDC.RedirectURL == "" {
missing = append(missing, "VISIONA_OIDC_REDIRECT_URL")
}
if c.OIDC.PostLoginURL == "" {
missing = append(missing, "VISIONA_FRONTEND_URL")
}
if c.UserSession.Secret == "" {
missing = append(missing, "VISIONA_SESSION_SECRET")
}
if len(missing) > 0 {
return &MissingEnvError{Vars: missing}
}
return nil
}
// MissingEnvError 表示 OIDC 必填環境變數缺少OB5 起永遠檢查)。
type MissingEnvError struct {
Vars []string
}
func (e *MissingEnvError) Error() string {
return "config: OIDC enabled but required env vars are missing: " + joinStrings(e.Vars, ", ")
}
// joinStrings 是 strings.Join 的本地版本,避免單純為了 join 引入 strings package。
func joinStrings(parts []string, sep string) string {
switch len(parts) {
case 0:
return ""
case 1:
return parts[0]
}
n := len(sep) * (len(parts) - 1)
for _, p := range parts {
n += len(p)
}
out := make([]byte, 0, n)
out = append(out, parts[0]...)
for _, p := range parts[1:] {
out = append(out, sep...)
out = append(out, p...)
}
return string(out)
}