// pairing_exchange.go — pairing exchange 自建 device 的協調者(DB-on FK 收尾,問題 #2)。 // // 背景: // // session_tokens.device_id 是 NOT NULL FK → devices(id),但雛形 pairing 流程從頭到尾沒有任何 // production 路徑會建 device(grep 確認 device.Save 只在 seed / test 被呼叫)。exchange 時 // info.DeviceID 必為空 → DB-on 下 session token INSERT 因 device_id 空字串 cast UUID 失敗。 // in-memory 模式因為不檢查 FK 而藏住此問題。 // // 修法(使用者拍板:exchange 時雲端自建 device,不動 local-tool): // // exchange 驗完 pairing token 後、建 session token 之前,雲端自建一筆 device 代表「這台配對 // 進來的 local agent」(owner = pairing token 綁的 user,這個 user 已透過 OIDC callback // provision 進 users 表 —— 見問題 #1)。然後用這個 device_id 建 session token。 // // 為什麼抽成 coordinator(比照 unpair.go 的 DeviceUnpairer): // - 讓 handler(pairing.go 的 exchange)維持薄。 // - Postgres 後端用 db.WithTx 把「建 device + 建 session token」包成單一交易——任一步失敗 // 整筆 rollback,杜絕「device 建了但 session token 沒建成」的中間態(database.md §6 一致性精神)。 // - in-memory 後端依序執行(無交易),行為一致。 // - main.go 依 dbPool 是否非 nil 擇一注入 Deps.PairingExchanger。為 nil 時 exchange handler // fallback 到「不自建 device、直接用 info.DeviceID(可能為空)建 session token」的舊行為 // (與 DB-off 雛形相容;in-memory store 不檢查 FK,空 deviceID 可接受)。 // // 冪等:pairing token 是一次性(MarkUsed 後 Validate 回 ErrTokenUsed),故同一 token 不會被 // exchange 兩次成功。每次成功 exchange 自建一筆新 device(新 UUID)是正確語意——不同次配對 // 視為不同 agent 連線。重試(exchange 後 MarkUsed 失敗被 abort)時 session token 已 revoke、 // device 已建但無 token 指向它(孤兒 device,無安全風險,僅一筆閒置紀錄;雛形可接受)。 package api import ( "context" "fmt" "log/slog" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" "visiona-backend/internal/auth" "visiona-backend/internal/db" "visiona-backend/internal/device" ) // defaultPairedDeviceName / defaultPairedDeviceType 是 exchange 自建 device 的預設值。 // // 雛形:agent 端 exchange request 只傳 pairing_token、不帶裝置資訊(不動 local-tool), // 故 Name / DeviceType 在雲端用預設值。Phase 1 若 agent 帶上 serial / device_type 可改填真值。 const ( defaultPairedDeviceName = "local-tool (paired)" defaultPairedDeviceType = "local-agent" ) // ExchangeProvisionResult 回報 exchange 自建 device + 建 session token 的結果。 type ExchangeProvisionResult struct { DeviceID string // 本次自建的 device id SessionPlaintext string // 新 session token 原文(caller 只此一次能拿到) SessionInfo *auth.SessionToken // session token 儲存層表示(含 ExpiresAt) } // PairingExchanger 把「自建 device + 建 session token」包成一個原子(Postgres tx)或 // 一致(in-memory 依序)操作。 // // Provision 語意:成功回 ExchangeProvisionResult;任一步失敗回 error(handler 經 errors.go // 映射成 5xx,不洩漏 raw error)。 type PairingExchanger interface { // Provision 自建一筆 device(owner = userID)並建一筆綁該 device 的 session token。 // // parentTokenHash 為來源 pairing token 的 hash(稽核鏈,寫進 session_tokens.parent_token_hash)。 Provision(ctx context.Context, userID, parentTokenHash string, ttl time.Duration) (ExchangeProvisionResult, error) } // ── Postgres 後端 ───────────────────────────────────────────────────────────── // pgDeviceSaver 是 device 在 tx 內 upsert 的能力(由 device.PostgresRepository 滿足)。 type pgDeviceSaver interface { SaveTx(ctx context.Context, q db.Querier, d *device.Device) error } // pgSessionTokenCreator 是「在 tx 內建 session token」的能力(由 auth.PostgresSessionTokenStore 滿足)。 type pgSessionTokenCreator interface { CreateTx(ctx context.Context, q db.Querier, userID, deviceID, parentTokenHash string, ttl time.Duration) (string, *auth.SessionToken, error) } // pgPairingExchanger 用單一 pgx 交易完成「自建 device + 建 session token」。 type pgPairingExchanger struct { pool *pgxpool.Pool devices pgDeviceSaver sessionToken pgSessionTokenCreator log *slog.Logger } // NewPostgresPairingExchanger 建立 Postgres 後端的 exchange 協調者。 func NewPostgresPairingExchanger( pool *pgxpool.Pool, devices pgDeviceSaver, sessionToken pgSessionTokenCreator, log *slog.Logger, ) PairingExchanger { return &pgPairingExchanger{ pool: pool, devices: devices, sessionToken: sessionToken, log: logOrDefault(log), } } // Provision 在單一交易內:自建 device → 建綁該 device 的 session token。 // // 任一步失敗整筆 rollback(device 不會「已建但沒 token」殘留在 DB)。 func (e *pgPairingExchanger) Provision( ctx context.Context, userID, parentTokenHash string, ttl time.Duration, ) (ExchangeProvisionResult, error) { var res ExchangeProvisionResult deviceID := uuid.NewString() now := time.Now().UTC() err := db.WithTx(ctx, e.pool, func(q db.Querier) error { dev := &device.Device{ ID: deviceID, OwnerUserID: userID, Name: defaultPairedDeviceName, DeviceType: defaultPairedDeviceType, // serial_number 留空(agent 未帶):SaveTx 把空 serial 寫成 SQL NULL, // 故同 owner 多次 exchange 各建一筆 serial=NULL 的 distinct device,不撞 // partial unique uq_devices_owner_serial_active(每個 NULL 互不相等)。 RemoteStatus: device.RemoteStatusOffline, Status: device.USBStatusUnknown, PairedAt: &now, CreatedAt: now, UpdatedAt: now, } if saveErr := e.devices.SaveTx(ctx, q, dev); saveErr != nil { return fmt.Errorf("exchange: save device: %w", saveErr) } plaintext, info, createErr := e.sessionToken.CreateTx(ctx, q, userID, deviceID, parentTokenHash, ttl) if createErr != nil { return fmt.Errorf("exchange: create session token: %w", createErr) } res.DeviceID = deviceID res.SessionPlaintext = plaintext res.SessionInfo = info return nil }) if err != nil { return ExchangeProvisionResult{}, err } return res, nil } // ── in-memory 後端 ──────────────────────────────────────────────────────────── // memSessionTokenCreator 是 in-memory store「建 session token」的能力 // (由 auth.InMemorySessionTokenStore 透過 SessionTokenStore interface 滿足)。 type memSessionTokenCreator interface { Create(ctx context.Context, userID, deviceID, parentTokenHash string, ttl time.Duration) (string, *auth.SessionToken, error) } // memPairingExchanger 依序(非交易)完成自建 device + 建 session token。 // // in-memory 為單機 local-dev fallback,無跨 store 交易需求;依序執行已能保證行為一致。 type memPairingExchanger struct { devices device.Repository sessionToken memSessionTokenCreator } // NewInMemoryPairingExchanger 建立 in-memory 後端的 exchange 協調者。 func NewInMemoryPairingExchanger( devices device.Repository, sessionToken memSessionTokenCreator, ) PairingExchanger { return &memPairingExchanger{ devices: devices, sessionToken: sessionToken, } } // Provision 自建 device 後建綁該 device 的 session token(依序,非交易)。 func (e *memPairingExchanger) Provision( ctx context.Context, userID, parentTokenHash string, ttl time.Duration, ) (ExchangeProvisionResult, error) { deviceID := uuid.NewString() now := time.Now().UTC() dev := &device.Device{ ID: deviceID, OwnerUserID: userID, Name: defaultPairedDeviceName, DeviceType: defaultPairedDeviceType, RemoteStatus: device.RemoteStatusOffline, Status: device.USBStatusUnknown, PairedAt: &now, CreatedAt: now, UpdatedAt: now, } if err := e.devices.Save(ctx, dev); err != nil { return ExchangeProvisionResult{}, fmt.Errorf("exchange: save device: %w", err) } plaintext, info, err := e.sessionToken.Create(ctx, userID, deviceID, parentTokenHash, ttl) if err != nil { return ExchangeProvisionResult{}, fmt.Errorf("exchange: create session token: %w", err) } return ExchangeProvisionResult{ DeviceID: deviceID, SessionPlaintext: plaintext, SessionInfo: info, }, nil }