// Package api 實作 api-server 的 REST + WebSocket 入口。 // // 對齊 `.autoflow/04-architecture/api/api-spec.md`。 // // **B4 範圍**:Router / Middleware / 結構化錯誤回應骨架 + 少數 handler(/healthz、 // /api/system/health、/api/system/info、/api/pairing/token、/api/pairing/status)。 // // **B5 範圍**(本檔): // - Auth:login / logout / me(stub),register → 501 // - Pairing:list tokens / revoke token // - Devices:list / get(讀雲端 repo + 合併 tunnel 狀態),scan / connect / // disconnect / flash / inference.start/stop 走 proxy,unpair 軟刪 // - Models:list / get / init upload / finalize / delete // - System:/system/deps(走 proxy) // - Clusters:GET /clusters 回空陣列;其他 stub // - Storage:/storage/* 的 LocalFS 假 presigned URL 代理(GET/PUT) // - WebSocket:保留 501 stub(詳見 stubs.go;B7 TODO) package api import ( "log/slog" "github.com/gin-gonic/gin" "visiona-backend/internal/auth" "visiona-backend/internal/conversion" "visiona-backend/internal/converter" "visiona-backend/internal/device" "visiona-backend/internal/fileaccess" "visiona-backend/internal/model" "visiona-backend/internal/oidc" "visiona-backend/internal/session" "visiona-backend/internal/storage" "visiona-backend/internal/usersession" ) // Deps 匯整 api router 所需的所有依賴;由 cmd/api-server/main.go 在啟動時注入。 // // 之所以集中在一個 struct,是為了: // 1. 讓 NewRouter 簽章穩定(之後加新依賴只改 struct,不破壞既有 caller) // 2. integration test 容易組裝(只需建一個 Deps 物件) // 3. 各 handler 透過 closure 取得依賴,不需要 global state // // OB5(2026-04-26)起 OIDC 是唯一認證路徑:OIDCProvider + SessionManager 必填, // validate() 在 NewRouter 啟動時就會 panic 提前暴露 misconfiguration。 type Deps struct { Logger *slog.Logger // PairingStore 管理 Pairing Token 生命週期。為 nil 時 /api/pairing/* 會回 501。 PairingStore auth.PairingStore // ─── OIDC(OB5 起為必填) ─── // OIDCProvider 封裝 OIDC client(authorization URL 組裝、token exchange、id_token 驗證)。 OIDCProvider oidc.Provider // SessionManager 管理 cookie session(StartSession / GetSession / EndSession)。 SessionManager *usersession.Manager // OIDCPostLoginURL 是 callback 完成後 302 回 frontend 的 base URL。 // 例:http://localhost:3000(dev)/ https://app.visiona.cloud(prod)。 // 為空字串時 callback handler 會 fallback 到 same-origin "/"(不建議生產配置)。 OIDCPostLoginURL string SessionStore session.Store Forwarder *session.Forwarder DeviceRepo device.Repository ModelRepo model.Repository // DeviceUnpairer 將「軟刪 device + cascade 撤銷其 pairing/session token」包成單一原子 // (Postgres tx)或一致(in-memory 依序)操作(DB 接入塊 5.2,database.md §6)。 // 為 nil 時 unpair handler fallback 到「只軟刪 device、不 cascade」的舊行為 //(見 devices.go),確保最小骨架仍可啟動。main.go 依 dbPool 是否非 nil 擇一注入。 DeviceUnpairer DeviceUnpairer Storage storage.Store Converter converter.Client // Conversion 是 Phase 0.8 轉檔功能的 Service interface(5 個 endpoint 共用)。 // 為 nil 時 /api/conversion/* 5 個 endpoint 全回 501 NOT_IMPLEMENTED // (main.go 在 cfg.Conversion.Enabled() 為 false 時不 wire),對齊 api-conversion.md。 // // 設計選擇:用 conversion.Service interface 而非 concrete type — 方便 unit test 注入 stub。 Conversion conversion.Service // FileAccessIssuer 是 Phase 0.9「模型庫 model 直連 FAA 下載」的 download token 簽發者 // (ADR-017 (a))。為 nil 時 GET /api/models/:id/download 回 501 NOT_IMPLEMENTED // (main.go 在 cfg.FileAccess.Enabled() 為 false 時不 wire)。 // 用 interface 方便 unit test 注入 fake。 FileAccessIssuer fileaccess.DownloadTokenIssuer // FAABaseURL 是 File Access Agent 對外 base URL(不帶結尾斜線),用來組回給 Client // 的 download_url(`{FAABaseURL}/files/{object_key}`)。 // 由 cfg.FileAccess.FAABaseURL 注入;FileAccessIssuer 非 nil 時必非空(main.go 確保)。 FAABaseURL string // CORSAllowedOrigins 是允許的瀏覽器 Origin 白名單;空 slice 預設放行 // http://localhost:3000(前端 dev server)。 CORSAllowedOrigins []string // Phase 0.7 security fix C1 (見 .autoflow/05-implementation/review/phase-0.7-security-audit.md) // StaticUserID 欄位已移除:multi-tenant 環境下 fallback 到固定 user 是 latent multi-user // 隔離破口(OWASP A01 + A04)。改 OIDC 後 AuthMiddleware 會擋下未登入請求, // handler 拿不到 UserContext 一律 500(safer than silent fallback)。 // dev seed / unit test 仍可獨立讀 cfg.Auth.StaticUserID env,不再注入 Deps。 // MaxUploadSizeMB 是模型上傳大小上限(MB);0 代表不限(測試友善)。 // 對齊 feature-model-management.md:Phase 0 預設 100 MB(由 config.Model.MaxSizeMB 注入)。 MaxUploadSizeMB int // SessionTokenStore 保存 Pairing → Session 交換後發出的 Session Token。 // Phase 0 雛形用 in-memory 實作(由 main.go 注入);Phase 1 改為 DB-backed。 // 為 nil 時 /api/pairing/exchange 會回 501 NOT_IMPLEMENTED。 SessionTokenStore auth.SessionTokenStore // RelayPublicURL 是 agent 連 tunnel 用的 WSS URL(對外可訪問)。 // 由 `POST /api/pairing/exchange` 回給 agent;若為空會回預設 `wss://relay.visionA.cloud`(雛形 placeholder)。 // 對齊 build-deploy.md 的 VISIONA_RELAY_PUBLIC_URL 環境變數。 RelayPublicURL string // HealthDBPool / HealthRedis 是 /healthz 要 ping 的依賴(DB 接入塊 5.4)。 // 由 main.go 注入 db.Pool / db.RedisClient(皆有 Ping(ctx))。為 nil 代表該依賴未啟用、 // /healthz 略過不檢查(in-memory 模式維持「process 活著就 ok」)。 // 任一非 nil 依賴 ping 失敗 → /healthz 回 503,讓 load balancer 拉出此實例。 HealthDBPool HealthPinger HealthRedis HealthPinger } // validate 確認必要欄位都有;在 NewRouter 啟動時呼叫,避免 nil pointer panic 推到 runtime。 // // 嚴格欄位(缺則 panic — fail fast,避免半設定狀態跑進生產): // - OIDCProvider — OB5 起 OIDC 是唯一認證路徑 // - SessionManager — OIDC cookie session 必須 // // 寬鬆欄位(缺有預設):Logger / CORSAllowedOrigins // // 其他欄位(PairingStore / SessionStore 等)若為 nil 不擋 — 個別 handler 會回 501, // 允許「最小骨架」啟動跑 /healthz。 // // Phase 0.7 security fix C1:移除 StaticUserID 預設 "demo-user" 的 fallback。 func (d *Deps) validate() { if d.Logger == nil { d.Logger = slog.Default() } if len(d.CORSAllowedOrigins) == 0 { d.CORSAllowedOrigins = []string{"http://localhost:3000"} } if d.OIDCProvider == nil { panic("api.NewRouter: Deps.OIDCProvider is required (OB5: OIDC is the only auth path)") } if d.SessionManager == nil { panic("api.NewRouter: Deps.SessionManager is required (OB5: OIDC cookie session is mandatory)") } } // NewRouter 建立 Gin engine 並註冊所有路由與中介層。 // // 為何回 *gin.Engine 而非 http.Handler:cmd/api-server/main.go 需要 access // engine.Run(也可拿 .Handler() 給標準 http.Server 用,所以這個選擇沒讓 // caller 失去彈性)。 func NewRouter(deps Deps) *gin.Engine { deps.validate() // gin 的 ReleaseMode 由 caller 視環境設定(cmd/api-server/main.go); // 這裡不主動設,避免測試環境被汙染。 r := gin.New() // 註冊全域 middleware(順序很重要:Recovery 第一,Logger 接著,CORS 之後) r.Use(RecoveryMiddleware(deps.Logger)) r.Use(RequestIDMiddleware()) r.Use(LoggerMiddleware(deps.Logger)) r.Use(CORSMiddleware(deps.CORSAllowedOrigins)) r.Use(ErrorMiddleware()) // 統一把 c.Errors 轉成 JSON // /healthz 不需要 auth — K8s liveness/readiness 用。 // 塊 5.4:啟用的依賴(Postgres / Redis)都 ping,任一失敗回 503(fail-fast)。 r.GET("/healthz", HealthzHandler(HealthDeps{ DBPool: deps.HealthDBPool, Redis: deps.HealthRedis, Logger: deps.Logger, })) // /storage/* 不走 AuthMiddleware(改用 HMAC 簽章)— 對齊 api-spec.md §10 registerStorageRoutes(r, deps) // /api/pairing/exchange 刻意不走 AuthMiddleware: // agent 尚未有 session token 時就得用 Pairing Token 換 Session Token, // Pairing Token 本身就是這個 endpoint 的憑證。詳見 security.md §1.2。 registerPairingPublicRoutes(r, deps) // /ws/* 雛形全部 501;B7 補齊 WebSocket proxy registerWebSocketStubs(r) // OIDC public routes(不走 AuthMiddleware): // - GET /api/auth/login — 起始登入流程(user 還沒登入) // - GET /api/auth/callback — OIDC IdP 302 回來 // 必須註冊在 AuthMiddleware 群組之外,否則使用者沒登入根本進不去。 registerOIDCPublicRoutes(r, deps) // /api 群組:所有路由都走 OIDC AuthMiddleware(cookie session → UserContext) apiGroup := r.Group("/api") apiGroup.Use(AuthMiddleware(deps)) // B4 核心 registerSystemRoutes(apiGroup, deps) registerPairingRoutes(apiGroup, deps) // B5 新增:實際 handler registerAuthRoutes(apiGroup, deps) registerDeviceRoutes(apiGroup, deps) registerModelRoutes(apiGroup, deps) registerClusterRoutes(apiGroup, deps) // Phase 0.8:Conversion(轉檔)— 5 個 endpoint // 對齊 .autoflow/04-architecture/api/api-conversion.md registerConversionRoutes(apiGroup, deps) // Stubs(只註冊「還沒有實際 handler」的那些 endpoint) registerStubRoutes(apiGroup, deps) return r }