// 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。 // // OB5(2026-04-26)起認證走 OIDC(OIDCConfig); // Phase 0.7(2026-05-01)security audit 移除了 api.Deps.StaticUserID handler fallback // (見 .autoflow/05-implementation/review/phase-0.7-security-audit.md C1)。 // 此處的 StaticUserID 欄位**僅供 dev seed(VISIONA_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_SECRET,presigned 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 id(confidential 或 public 皆可)。 // 對齊 VISIONA_OIDC_CLIENT_ID。 ClientID string // ClientSecret 為**選填**(A1, 2026-05-01): // - 有值 → confidential client mode(client_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 client(client_credentials grant)的 secret。 // 與 ServiceClientID 配對使用;同樣 A1 階段不啟用、Validate() 不檢查。 // **禁止 commit 進 repo**;對齊 VISIONA_OIDC_SERVICE_CLIENT_SECRET。 ServiceClientSecret string } // UserSessionConfig 控制 OIDC 登入後在 browser 端建立的 cookie session。 // // 注意:與既有 SessionConfig(tunnel 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 // CookieDomain:dev 留空(host-only cookie),prod 設 ".visiona.cloud"。 // 對齊 VISIONA_SESSION_COOKIE_DOMAIN。 CookieDomain string // CookieSecure 控制 Secure flag。dev=false(http),prod=true(https)。 // 對齊 VISIONA_SESSION_COOKIE_SECURE。 CookieSecure bool // AbsoluteTTL 是 session 的最長存活時間(從 Create 起算)。預設 168h(7 天)。 // 對齊 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_LEVEL:debug / 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 不會 wire(main.go 在 wire 階段跳過、log warn)。 // // **Phase 0.8b 變更**:服務間認證從 OAuth client_credentials 改為 pre-shared API key(ADR-015)。 // // **Phase 0.8b T5 完成**(見 conversion.md §3.2 / ADR-015 §5 §7):原暫留欄位 // TenantID / DelegatedTTLSeconds 已移除 — MC 認證鏈與 delegated download token 機制 // 都不存在了,兩個欄位連同對應 env(VISIONA_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 端不再直接呼叫 FAA,download / promote // 流程改走 converter.GetResult(ADR-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:9501(dev / stage) / https://converter.visiona.cloud(prod) // 對齊 VISIONA_CONVERTER_BASE_URL;留空 = 不啟用 Phase 0.8 轉檔功能。 ConverterBaseURL string // ConverterAPIKey 是 visionA → converter 服務間認證的 pre-shared API key(Phase 0.8b 新增)。 // 對齊 VISIONA_CONVERTER_API_KEY;以 `Authorization: Bearer ` 形式帶上。 // 雙方獨立產生(`openssl rand -hex 32`),visionA 端的值必須與 converter 端的 // `CONVERTER_API_KEY` env 對齊;不對齊 → 下游 401(visionA 端不重試,回 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 §10(stage 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 issuer,model 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(不帶結尾斜線)。 // stage:https://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 一致)。 // stage:732270c0-449c-489c-bfad-321e9bf89b3d // 對齊 VISIONA_FILE_ACCESS_TENANT_ID。 TenantID string // FAABaseURL 是 File Access Agent 對外 base URL(不帶結尾斜線),用來組回給 Client 的 // download_url(`{FAABaseURL}/files/{object_key}`)。 // stage:https://stage-9527.innovedus.com:5081 // 對齊 VISIONA_FILE_ACCESS_FAA_BASE_URL。 FAABaseURL string // DownloadTokenTTLSeconds 是簽 download token 時帶給 MC 的 expires_in_seconds。 // ADR-017 Q2 區間 60–300s,預設 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.1):visionA 端撤回 // 對 FAA 的直接呼叫,download / promote 改走 converter.GetResult;只剩 ConverterBaseURL // 與 ConverterAPIKey 兩個欄位需非空。任一缺 → 視為未啟用,main.go 不會 wire // conversion.Service(5 個 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.Secret(cookie 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。 // // 缺任何**必填**項 → 回 *MissingEnvError,main.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) }