jim800121chen 4d0b870480 feat(visionA-backend): DB 接入 — 6 store 接 PostgreSQL/Redis 持久化(塊 0-5)
把 visionA-backend 6 個 in-memory store 接到資料庫持久化,範圍=完整
(PG 全接 + session 接 Redis + 交易韌性)。interface / handler 不動,
只加 DB 實作 + 換 wiring,config 未設 DB 時保留 in-memory fallback。

- 塊 0 基礎建設:pgx/v5 連線池 + DatabaseConfig/RedisConfig + golang-migrate
  runner(embed)+ cmd/migrate + testcontainers 測試基礎建設
- 塊 1 model → Postgres:array 映射、upsert 保留 CreatedAt、faa_object_key、
  三維 filter(owner/chip/source)、soft-delete partial index
- 塊 2 device → Postgres:partial unique(已刪 serial 可重註冊)、雙狀態欄位
- 塊 3 token → Postgres:pairing_tokens + session_tokens 分表、token_hash 當 PK
- 塊 4 userSession → Redis:idle + absolute 雙 TTL 取代 cleanup goroutine
  (tunnel session 維持 in-memory,yamux handle 不可序列化)
- 塊 5 交易/韌性:WithTx helper + 刪 device cascade 撤銷 token(同 tx 原子)
  + /healthz ping PG/Redis(fail-fast 503)+ pgx error 統一映射(不洩漏 raw error)

降級策略(fail-fast):PG 掉 → 持久資料 API 回 503;Redis 掉 → session 失敗
不自動 fallback in-memory(避免多機 session 不同步)。

DB:PostgreSQL 14.23(gen_random_uuid 內建、無 citext → email 用 lower() unique
index)。每塊經 Reviewer 審查 + 真 PG/Redis testcontainers 全量 dbtest 綠燈,
in-memory fallback 未受影響。

docs: 同步更新 database.md(schema/config/migration 清單)+ api-spec.md
(409/503 錯誤碼、/healthz 新行為、device unpair cascade)。

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

517 lines
18 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
}
// Generate Session Token
plaintext, sessionInfo, err := deps.SessionTokenStore.Create(
ctx,
info.UserID,
info.DeviceID, // Pairing Token 雛形還沒綁 device_id為空沒關係
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
}
// 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 沿用 info.DeviceID可能為空。雛形 MarkUsed 對空字串
// 是安全的(它只是覆寫欄位)。
if err := deps.PairingStore.MarkUsed(ctx, req.PairingToken, info.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", info.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", info.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"
}
}