把 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>
517 lines
18 KiB
Go
517 lines
18 KiB
Go
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 的所有 token(B5)
|
||
// - DELETE /api/pairing/tokens/:token → 撤銷指定 token(B5)
|
||
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 取出 userID(OIDC 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 — 雛形必定非 nil(pairingTokenTTL > 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
|
||
// 不帶 UserID,strict 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.4:DB 錯誤經 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 param(path 可被日誌記錄 — 雛形容忍;
|
||
// 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.4:DB 錯誤經 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/exchange(public — 不走 AuthMiddleware)
|
||
// ==========================================================================
|
||
//
|
||
// 行為對齊 visiona-agent-tdd.md §4.3 + security.md §1.2:
|
||
// 1. agent 送 Pairing Token 過來
|
||
// 2. 雲端驗證(存在 / 未過期 / 未使用 / 未撤銷)
|
||
// 3. 產生 Session Token(vAs_ + 64 hex,90 天 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 token;revoke 自身失敗不再 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.Email(OIDC 已帶)。
|
||
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"
|
||
}
|
||
}
|