jim800121chen cabbdde495 feat(visionA-backend): DB 接入後續 — OIDC/pairing FK 收尾 + B4 metadata + nginx healthz + 補測試
DB 接入塊 0-5 上主幹後的收尾工作,讓 DB-on 模式可真人使用 + 補齊功能與測試。

OIDC / pairing FK 修復(接 DB 上線必要):
- 新建 internal/user package(User + Store + InMemory + Postgres);OIDC callback
  驗證 id_token 成功後 fail-closed upsert users(sub 直接當 users.id,MC sub 為 UUID)
- pairing exchange 雲端自建 device(不動 local-tool)+ 同 tx 綁 session token;
  自建 device 空 serial 寫 NULL(避免撞 partial unique)
- device.SaveTx / session.CreateTx 新增 tx-aware 版本

B4 model metadata:
- 轉檔 result 的 analysis_info(input_shape/classes/framework)串進 model:
  converter_client → flow → adapter → model.Model → PG → ModelResponse DTO
- input_shape 優先用陣列、後備四維組 NCHW、缺一不亂組;全 optional 防禦性
- 前端詳細頁顯示(另 repo);轉檔端串接交接檔 b4-converter-handoff.md

nginx healthz(部署層):
- 新增 /healthz/deep 轉發 backend(ping PG+Redis、down 回 503)給 LB
- 修掉 default_server return 444 短路 bug(docker healthcheck 長期 unhealthy 真因)

storage error 統一映射(不洩漏 storage 後端細節)。

測試:補 internal/api(storage/errors handler)、cmd/api-server(seed/adapter)、
internal/db(redis)、relay/session 弱處,含 testcontainers integration。
DB 接入相關 package 真環境覆蓋達 88-94%。全程 Reviewer 審查 + 130 真 PG/Redis dbtest 綠。

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

551 lines
20 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 api
import (
"context"
"errors"
"net/http"
"time"
"github.com/gin-gonic/gin"
"visiona-backend/internal/auth"
"visiona-backend/internal/session"
)
// pairingTokenTTL 是新發 pairing token 的存活時間。
//
// 對齊 security.md §1.3 的「短期一次性 token」設計15 分鐘足夠完成
// 「使用者啟動 local-tool → 點配對按鈕 → token 進來」的流程。
const pairingTokenTTL = 15 * time.Minute
// registerPairingRoutes 註冊 /api/pairing/* 的 routes。
//
// MVP 全集合B4 + B5
// - POST /api/pairing/token → 建立 + 回傳 pairing token
// - GET /api/pairing/status → 回傳當前 user 的 tunnel 連線狀態
// - GET /api/pairing/tokens → 列當前 user 的所有 tokenB5
// - DELETE /api/pairing/tokens/:token → 撤銷指定 tokenB5
func registerPairingRoutes(g *gin.RouterGroup, deps Deps) {
g.POST("/pairing/token", pairingCreateTokenHandler(deps))
g.GET("/pairing/status", pairingStatusHandler(deps))
g.GET("/pairing/tokens", pairingListTokensHandler(deps))
g.DELETE("/pairing/tokens/:token", pairingRevokeTokenHandler(deps))
}
// PairingTokenResponse 是 POST /api/pairing/token 的 data payload。
//
// 對齊 api-spec.md §2
//
// { "token": "vAc_...", "expires_at": "..." }
type PairingTokenResponse struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
}
// pairingCreateTokenHandler 建立一個新的 pairing token。
//
// 流程:
// 1. 從 UserContext 取出 userIDOIDC sub由 AuthMiddleware 注入)
// 2. PairingStore.Create 產生一個合法格式的 vAc_ token
// 3. 回傳 token plaintext 給前端「只此一次」顯示
//
// 失敗回應:
// - PairingStore 未注入 → 501 NOT_IMPLEMENTED
// - Create 內部錯誤 → 500 INTERNAL_ERROR
//
// 安全提醒plaintext 只回給合法登入的使用者,不寫進 server log。
func pairingCreateTokenHandler(deps Deps) gin.HandlerFunc {
return func(c *gin.Context) {
if deps.PairingStore == nil {
WriteNotImplemented(c, "pairing store not configured")
return
}
// Phase 0.7 security fix M1 (見 .autoflow/05-implementation/review/phase-0.7-security-audit.md)
// 移除 inline demo-user fallback強制要求 AuthMiddleware 已注入合法 UserContext。
uc, ok := UserContextFrom(c)
if !ok || uc.UserID == "" {
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
"missing user context (auth middleware misconfigured?)", nil)
return
}
userID := uc.UserID
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
plaintext, info, err := deps.PairingStore.Create(ctx, userID, pairingTokenTTL)
if err != nil {
logOrDefault(deps.Logger).Error("pairing: create token failed",
"error", err,
"user_id", userID,
"request_id", RequestIDFrom(c))
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
"failed to create pairing token", nil)
return
}
// 取 ExpiresAt — 雛形必定非 nilpairingTokenTTL > 0保險檢查
var expires time.Time
if info != nil && info.ExpiresAt != nil {
expires = *info.ExpiresAt
}
// 故意不 log plaintext只 log token prefix 與 expires
logOrDefault(deps.Logger).Info("pairing: token created",
"user_id", userID,
"token_prefix", tokenPrefix(plaintext),
"expires_at", expires,
"request_id", RequestIDFrom(c))
WriteSuccess(c, http.StatusOK, PairingTokenResponse{
Token: plaintext,
ExpiresAt: expires,
})
}
}
// PairingStatusResponse 是 GET /api/pairing/status 的 data payload。
//
// 對齊 api-spec.md §2
//
// {
// "connected": true,
// "connected_at": "...",
// "last_seen_at": "...",
// "device_id": "dev-xxx",
// "agent_version": "..."
// }
type PairingStatusResponse struct {
Connected bool `json:"connected"`
ConnectedAt *time.Time `json:"connected_at,omitempty"`
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
DeviceID string `json:"device_id,omitempty"`
AgentVersion string `json:"agent_version,omitempty"`
}
// pairingStatusHandler 回報當前 user 的 tunnel 連線狀態。
//
// 雛形實作:直接 List 所有 sessions單 user 場景),找第一個。
// 多 user 階段B5會改成「按 user_id 過濾」並考慮多 device 場景。
func pairingStatusHandler(deps Deps) gin.HandlerFunc {
return func(c *gin.Context) {
resp := PairingStatusResponse{Connected: false}
if deps.SessionStore == nil {
WriteSuccess(c, http.StatusOK, resp)
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
summaries, err := deps.SessionStore.List(ctx)
if err != nil {
// 跟 system/health 一樣list 失敗不致命,回 connected=false
logOrDefault(deps.Logger).Warn("pairing/status: list sessions failed",
"error", err,
"request_id", RequestIDFrom(c))
WriteSuccess(c, http.StatusOK, resp)
return
}
// Phase 0.7 security audit M2寬鬆比對暫保留待人工介入修復。
// 詳細理由見 proxy.go pickActiveSessionToken 註解relay 端 LocalHandle.Summary
// 不帶 UserIDstrict equality 會讓所有 e2e proxy 鏈路全斷。
// 此處仍要求 UserContext 非空C1 加固),但 s.UserID 暫接受空字串。
uc, ok := UserContextFrom(c)
var picked *session.Summary
if ok && uc != nil && uc.UserID != "" {
for _, s := range summaries {
// 寬鬆比對:暫接受 s.UserID == "" 直到 relay 端 backfill UserID。
if s.UserID == "" || s.UserID == uc.UserID {
picked = s
break
}
}
}
if picked == nil {
WriteSuccess(c, http.StatusOK, resp)
return
}
resp.Connected = true
ca := picked.ConnectedAt
ls := picked.LastHeartbeat
resp.ConnectedAt = &ca
resp.LastSeenAt = &ls
resp.DeviceID = picked.DeviceID
// AgentVersion 雛形未從 tunnel 讀回B5 會在 tunnel handshake 時收集
WriteSuccess(c, http.StatusOK, resp)
}
}
// tokenPrefix 截前 8 字元用於 log避免完整 token 進日誌。
//
// 在 pairing.go 中重複實作(不複用 relay package 的同名函式)以避免跨層循環依賴;
// 行為與 relay.tokenPrefix 一致。
func tokenPrefix(t string) string {
if len(t) <= 8 {
return t
}
return t[:8]
}
// ==========================================================================
// B5 新增List / Revoke tokens
// ==========================================================================
// PairingTokenListItem 是 GET /api/pairing/tokens 回應中的單筆 token。
//
// **注意**:不回 Plaintext — 那只能在建立時給一次。
type PairingTokenListItem struct {
TokenPrefix string `json:"token_prefix"` // 前 12 字元,例:`vAc_7f3c8e2a`
Kind string `json:"kind"`
DeviceID string `json:"device_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
UsedAt *time.Time `json:"used_at,omitempty"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
}
// pairingListTokensHandler 實作 GET /api/pairing/tokens。
//
// 回當前 user 的所有 token含已使用 / 撤銷 / 過期),供 UI 顯示。
// **絕對不回 Plaintext** — 前端已在建立時保留那份 plaintext只此一次
func pairingListTokensHandler(deps Deps) gin.HandlerFunc {
return func(c *gin.Context) {
if deps.PairingStore == nil {
WriteNotImplemented(c, "pairing store not configured")
return
}
// Phase 0.7 security fix M1 (見 .autoflow/05-implementation/review/phase-0.7-security-audit.md)
uc, ok := UserContextFrom(c)
if !ok || uc.UserID == "" {
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
"missing user context (auth middleware misconfigured?)", nil)
return
}
userID := uc.UserID
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
defer cancel()
tokens, err := deps.PairingStore.List(ctx, userID)
if err != nil {
// 塊 5.4DB 錯誤經 errors.go 映射PG down → 503其餘 → 500收掉塊 3 M1 raw error 洩漏。
WriteDBError(c, deps.Logger, "list pairing tokens", err)
return
}
out := make([]PairingTokenListItem, 0, len(tokens))
for _, t := range tokens {
item := PairingTokenListItem{
TokenPrefix: tokenPrefix12(t.Plaintext),
Kind: string(t.Kind),
DeviceID: t.DeviceID,
CreatedAt: t.CreatedAt,
ExpiresAt: t.ExpiresAt,
UsedAt: t.UsedAt,
RevokedAt: t.RevokedAt,
}
out = append(out, item)
}
WriteSuccess(c, http.StatusOK, out)
}
}
// pairingRevokeTokenHandler 實作 DELETE /api/pairing/tokens/:token。
//
// 雛形:直接收 plaintext token 作為 path parampath 可被日誌記錄 — 雛形容忍;
// Phase 1 會改為回「token id」而非 plaintext路徑不洩漏原文
//
// Revoke 成功回 204 No Content不存在回 404 + NOT_FOUND。
func pairingRevokeTokenHandler(deps Deps) gin.HandlerFunc {
return func(c *gin.Context) {
if deps.PairingStore == nil {
WriteNotImplemented(c, "pairing store not configured")
return
}
token := c.Param("token")
if token == "" {
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed,
"token param required", nil)
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
if err := deps.PairingStore.Revoke(ctx, token); err != nil {
if errors.Is(err, auth.ErrInvalidToken) {
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "token not found", nil)
return
}
// 塊 5.4DB 錯誤經 errors.go 映射PG down → 503其餘 → 500收掉塊 3 M1 raw error 洩漏。
WriteDBError(c, deps.Logger, "revoke pairing token", err)
return
}
logOrDefault(deps.Logger).Info("pairing: token revoked",
"token_prefix", tokenPrefix(token),
"request_id", RequestIDFrom(c))
c.Status(http.StatusNoContent)
}
}
// tokenPrefix12 截前 12 字元(`vAc_` + 8 hex用於 list 顯示。
func tokenPrefix12(t string) string {
if len(t) <= 12 {
return t
}
return t[:12]
}
// ==========================================================================
// AB11 新增POST /api/pairing/exchangepublic — 不走 AuthMiddleware
// ==========================================================================
//
// 行為對齊 visiona-agent-tdd.md §4.3 + security.md §1.2
// 1. agent 送 Pairing Token 過來
// 2. 雲端驗證(存在 / 未過期 / 未使用 / 未撤銷)
// 3. 產生 Session TokenvAs_ + 64 hex90 天 TTL
// 4. 把 Pairing Token 標為 used一次性無法再交換
// 5. 回 { session_token, account, relay_url, expires_at }
//
// 雛形取捨:
// - remote-proxy 端目前只做 token 格式驗證relay/server.go isAcceptableToken
// **不會**實際查 SessionTokenStore。這是對齊 TDD「選項 A」的雛形設計
// Phase 1 要新增 remote-proxy → api-server 的 `/internal/session-token/:token` 驗證。
// - Rate limit / token rotation / 真實 DB 都留給 Phase 1。
// defaultRelayPublicURL 是 relay_url 的雛形 placeholder當 Deps.RelayPublicURL
// 未設定時用此值(讓 agent 至少能收到一個格式正確的 URL實機請透過
// VISIONA_RELAY_PUBLIC_URL 覆寫)。
const defaultRelayPublicURL = "wss://relay.visionA.cloud"
// registerPairingPublicRoutes 註冊**不需要 auth**的 pairing endpoints。
//
// 目前只有 /api/pairing/exchange — agent 拿 Pairing Token 換 Session Token 時
// 本身還沒有登入身份,故不能套 AuthMiddleware。
// 呼叫方NewRouter() 在 engine 層級直接註冊(不加 apiGroup
func registerPairingPublicRoutes(r gin.IRouter, deps Deps) {
r.POST("/api/pairing/exchange", pairingExchangeHandler(deps))
}
// PairingExchangeRequest 是 POST /api/pairing/exchange 的 request body。
type PairingExchangeRequest struct {
PairingToken string `json:"pairing_token" binding:"required"`
}
// PairingExchangeResponse 是 POST /api/pairing/exchange 成功時的 data payload。
//
// 欄位對齊 visiona-agent-tdd.md §7.1
//
// {
// "session_token": "vAs_...",
// "account": "demo@visionA.local",
// "relay_url": "wss://relay.visionA.cloud",
// "expires_at": "2026-07-21T00:00:00Z"
// }
type PairingExchangeResponse struct {
SessionToken string `json:"session_token"`
Account string `json:"account"`
RelayURL string `json:"relay_url"`
ExpiresAt time.Time `json:"expires_at"`
}
// Pairing exchange 專用錯誤碼(對齊 TDD §7.1 四種 case
//
// 注意:這些 code 走 ErrorBody.Error.Code 欄位,不是 envelope top-level code。
// 刻意與 security.md §1.2 的語意一致;前端 agent 可以 switch 對應 UI 文案。
const (
ErrCodeInvalidPairingToken = "INVALID_PAIRING_TOKEN"
ErrCodePairingTokenExpired = "PAIRING_TOKEN_EXPIRED"
ErrCodePairingTokenUsed = "PAIRING_TOKEN_USED"
ErrCodePairingTokenRevoked = "PAIRING_TOKEN_REVOKED"
)
// pairingExchangeHandler 實作 Pairing → Session Token 交換。
//
// 失敗回應:
// - 400 VALIDATION_FAILED — body 缺 pairing_token
// - 401 INVALID_PAIRING_TOKEN — 格式錯 / 不存在
// - 401 PAIRING_TOKEN_EXPIRED — 過期
// - 401 PAIRING_TOKEN_USED — 已交換過
// - 401 PAIRING_TOKEN_REVOKED — 已撤銷
// - 500 INTERNAL_ERROR — Session Token 產生失敗
// - 501 NOT_IMPLEMENTED — store 未注入
//
// 安全提醒:回應內**絕對不包含** Pairing Token 原文log 只印 prefix。
func pairingExchangeHandler(deps Deps) gin.HandlerFunc {
return func(c *gin.Context) {
// 雛形store 任一缺失 → 501避免 nil pointer
if deps.PairingStore == nil || deps.SessionTokenStore == nil {
WriteNotImplemented(c, "pairing exchange store not configured")
return
}
// Parse body
var req PairingExchangeRequest
if err := c.ShouldBindJSON(&req); err != nil {
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed,
"pairing_token is required", nil)
return
}
// 格式驗證 — 不合格直接 401 INVALID_PAIRING_TOKEN避免把格式錯誤漏進 store
if !auth.IsValidPairingToken(req.PairingToken) {
logOrDefault(deps.Logger).Warn("pairing exchange: invalid token format",
"token_prefix", tokenPrefix(req.PairingToken),
"request_id", RequestIDFrom(c))
WriteError(c, http.StatusUnauthorized, ErrCodeInvalidPairingToken,
"pairing token format invalid", nil)
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
// Validate — store 會判斷存在 / 未過期 / 未使用 / 未撤銷
info, err := deps.PairingStore.Validate(ctx, req.PairingToken)
if err != nil {
code, msg := mapPairingExchangeError(err)
logOrDefault(deps.Logger).Warn("pairing exchange: validate failed",
"code", code,
"token_prefix", tokenPrefix(req.PairingToken),
"request_id", RequestIDFrom(c))
WriteError(c, http.StatusUnauthorized, code, msg, nil)
return
}
// 自建 device + 建綁該 device 的 session tokenDB-on FK 收尾,問題 #2
//
// session_tokens.device_id 是 NOT NULL FK → devices(id),但雛形 pairing 流程從不建 device。
// 改由 PairingExchanger 在 exchange 時自建一筆 deviceowner = info.UserID該 user 已透過
// OIDC callback provision 進 users 表),並建綁該 device 的 session token——Postgres 後端用
// 單一交易把兩步包成原子(建 device 失敗或建 token 失敗都整筆 rollback
//
// info.TokenHash 作為 parent_token_hash稽核鏈session token ← pairing token
//
// PairingExchanger 為 nilDB-off 雛形最小骨架,未注入)→ fallback 到舊行為:直接用
// info.DeviceID可能為空建 session token不自建 device。in-memory store 不檢查 FK
// 空 deviceID 可接受;此分支只在 main.go 沒注入 exchanger 時走到。
var (
plaintext string
sessionInfo *auth.SessionToken
deviceID string
)
if deps.PairingExchanger != nil {
res, exErr := deps.PairingExchanger.Provision(ctx, info.UserID, info.TokenHash, auth.SessionTokenTTL)
if exErr != nil {
logOrDefault(deps.Logger).Error("pairing exchange: provision device+session failed",
"error", exErr,
"user_id", info.UserID,
"request_id", RequestIDFrom(c))
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
"failed to create session token", nil)
return
}
plaintext = res.SessionPlaintext
sessionInfo = res.SessionInfo
deviceID = res.DeviceID
} else {
var err error
plaintext, sessionInfo, err = deps.SessionTokenStore.Create(
ctx,
info.UserID,
info.DeviceID, // fallback雛形未綁 device_id為空僅 in-memory 可接受)
info.TokenHash,
auth.SessionTokenTTL,
)
if err != nil {
logOrDefault(deps.Logger).Error("pairing exchange: create session token failed",
"error", err,
"request_id", RequestIDFrom(c))
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
"failed to create session token", nil)
return
}
deviceID = info.DeviceID
}
// Mark pairing token as used。
//
// Phase 0.7 security fix M3 (見 .autoflow/05-implementation/review/phase-0.7-security-audit.md)
// MarkUsed 失敗代表「pairing token 一次性」保證可能被破壞 — 同一個 token
// 可能再被 exchange 一次。改為 abort撤銷剛產生的 session token、回 500
// 而不是 silent log warn 繼續往前。
//
// 注意deviceID 現在綁的是自建 deviceDB-on或 info.DeviceIDfallback
// MarkUsed 把 pairing token 也綁上同一個 device_id稽核哪台 device 用掉了這個 token
if err := deps.PairingStore.MarkUsed(ctx, req.PairingToken, deviceID); err != nil {
// 嘗試 revoke 剛產生的 session tokenrevoke 自身失敗不再 retry只 log。
revokeErr := deps.SessionTokenStore.Revoke(ctx, plaintext)
logOrDefault(deps.Logger).Error("pairing exchange: mark used failed; aborted",
"error", err,
"revoke_err", revokeErr,
"token_prefix", tokenPrefix(req.PairingToken),
"session_token_prefix", tokenPrefix(plaintext),
"device_id", deviceID,
"request_id", RequestIDFrom(c))
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
"pairing token mark-used failed; aborted", nil)
return
}
// 組 response
relayURL := deps.RelayPublicURL
if relayURL == "" {
relayURL = defaultRelayPublicURL
}
var expires time.Time
if sessionInfo.ExpiresAt != nil {
expires = *sessionInfo.ExpiresAt
} else {
expires = time.Now().UTC().Add(auth.SessionTokenTTL)
}
// 雛形 account — OIDC sub 通常是 UUID 不適合給人看,這裡用 userID + 固定 suffix
// 當 placeholder。Phase 1 接 DB 後可改回 session.EmailOIDC 已帶)。
account := info.UserID + "@visionA.local"
// 故意只 log token prefix避免完整 session token 進日誌
logOrDefault(deps.Logger).Info("pairing exchange: success",
"user_id", info.UserID,
"device_id", deviceID,
"session_token_prefix", tokenPrefix(plaintext),
"pairing_token_prefix", tokenPrefix(req.PairingToken),
"request_id", RequestIDFrom(c))
WriteSuccess(c, http.StatusOK, PairingExchangeResponse{
SessionToken: plaintext,
Account: account,
RelayURL: relayURL,
ExpiresAt: expires,
})
}
}
// mapPairingExchangeError 把 PairingStore.Validate 回傳的 sentinel error 轉成
// 對應的 pairing exchange error code + 對使用者可見的訊息。
//
// 未匹配時 fallback 到 INVALID_PAIRING_TOKEN避免洩漏內部錯誤細節
func mapPairingExchangeError(err error) (code, message string) {
switch {
case errors.Is(err, auth.ErrTokenExpired):
return ErrCodePairingTokenExpired, "pairing token expired"
case errors.Is(err, auth.ErrTokenUsed):
return ErrCodePairingTokenUsed, "pairing token already used"
case errors.Is(err, auth.ErrTokenRevoked):
return ErrCodePairingTokenRevoked, "pairing token revoked"
case errors.Is(err, auth.ErrInvalidToken):
return ErrCodeInvalidPairingToken, "pairing token invalid"
default:
return ErrCodeInvalidPairingToken, "pairing token invalid"
}
}