// 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" "errors" "fmt" "log/slog" "strings" "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 // 本次自建(或依 serial 復用)的 device id SessionPlaintext string // 新 session token 原文(caller 只此一次能拿到) SessionInfo *auth.SessionToken // session token 儲存層表示(含 ExpiresAt) } // ExchangeDeviceInput 是 exchange payload 中 agent 上報的單顆實體 USB 裝置 // (WP-0 / ADR-018 序號地基,對齊 agent 端 tunnel.exchangeDevice 的 JSON)。 type ExchangeDeviceInput struct { SerialNumber string `json:"serial_number"` DeviceType string `json:"device_type,omitempty"` Firmware string `json:"firmware,omitempty"` } // fakeSerialNumber 是 agent 端 pyusb fallback(無 Kneron SDK,如 macOS 缺 dylib) // 寫死上報的假序號。多顆無 SDK 裝置會撞同一個值,不可當唯一鍵 / 路由鍵—— // 視同「無序號」寫 NULL(ADR-018 §2.2 / task-1 mapping R2)。 const fakeSerialNumber = "0x00000000" // firstUsableSerialDevice 從 agent 上報清單挑第一顆「序號可用」的裝置。 // // WP-0 最小落地(task-1 mapping R1):exchange 仍只落一筆 device,多顆 USB 的 // 完整模型(一 agent N device)是 WP-B / migration 0005 的範疇。這裡取第一顆 // 有效序號填入;空序號與假序號(0x00000000)跳過。 func firstUsableSerialDevice(devices []ExchangeDeviceInput) (ExchangeDeviceInput, bool) { for _, d := range devices { serial := strings.TrimSpace(d.SerialNumber) if serial == "" || strings.EqualFold(serial, fakeSerialNumber) { continue } d.SerialNumber = serial return d, true } return ExchangeDeviceInput{}, false } // 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)。 // // devices 為 agent 上報的實體 USB 清單(WP-0 序號地基,可為 nil = 舊 agent / // 撈不到清單,行為與現行完全一致)。序號可用時: // - 同 owner 已有同 serial 的未刪除 device → 復用既有 device_id(防 // uq_devices_owner_serial_active 23505 炸裂;「同序號重配 = 復用」,R4)。 // - 否則新建 device 並填 serial_number。 Provision(ctx context.Context, userID, parentTokenHash string, ttl time.Duration, devices []ExchangeDeviceInput) (ExchangeProvisionResult, error) } // ── Postgres 後端 ───────────────────────────────────────────────────────────── // pgDeviceSaver 是 device 在 tx 內 upsert + 依 serial 查詢的能力 // (由 device.PostgresRepository 滿足)。 // // GetBySerial 用於 WP-0 序號防炸:serial 有值時 exchange 先查同 owner 是否已有 // 同 serial 的未刪除 device,有則復用、不再自建(避免撞 partial unique // uq_devices_owner_serial_active → 23505 → exchange 500)。 type pgDeviceSaver interface { SaveTx(ctx context.Context, q db.Querier, d *device.Device) error GetBySerial(ctx context.Context, ownerUserID, serial string) (*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 在單一交易內:自建(或依 serial 復用)device → 建綁該 device 的 session token。 // // 任一步失敗整筆 rollback(device 不會「已建但沒 token」殘留在 DB)。 // // WP-0 序號地基(ADR-018):agent 上報清單有可用序號時,先 GetBySerial 查同 // owner 是否已有同 serial 的未刪除 device——有則復用既有 device_id(同序號重配 // = 復用,R4),沒有才新建並填 serial_number。已知限制:GetBySerial 走 pool // (非 tx 內),與並發 exchange 之間有極小 race window;撞到時 SaveTx 會被 // partial unique index 擋下(整筆 rollback、不產生重複 serial),同一顆 agent // 的配對操作實務上是序列的,可接受。 func (e *pgPairingExchanger) Provision( ctx context.Context, userID, parentTokenHash string, ttl time.Duration, devices []ExchangeDeviceInput, ) (ExchangeProvisionResult, error) { var res ExchangeProvisionResult now := time.Now().UTC() dev := &device.Device{ ID: uuid.NewString(), 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 input, ok := firstUsableSerialDevice(devices); ok { existing, gErr := e.devices.GetBySerial(ctx, userID, input.SerialNumber) switch { case gErr == nil: // 復用既有 device:保留既有欄位,只更新配對時間。 dev = existing dev.PairedAt = &now dev.UpdatedAt = now case errors.Is(gErr, device.ErrNotFound): // 新建 device,填入序號(+ agent 上報的 device type 若有)。 dev.SerialNumber = input.SerialNumber if input.DeviceType != "" { dev.DeviceType = input.DeviceType } default: return ExchangeProvisionResult{}, fmt.Errorf("exchange: get device by serial: %w", gErr) } } err := db.WithTx(ctx, e.pool, func(q db.Querier) error { 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, dev.ID, parentTokenHash, ttl) if createErr != nil { return fmt.Errorf("exchange: create session token: %w", createErr) } res.DeviceID = dev.ID 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 自建(或依 serial 復用)device 後建綁該 device 的 session token // (依序,非交易)。serial 處理邏輯與 pgPairingExchanger 對齊(WP-0)。 func (e *memPairingExchanger) Provision( ctx context.Context, userID, parentTokenHash string, ttl time.Duration, devices []ExchangeDeviceInput, ) (ExchangeProvisionResult, error) { now := time.Now().UTC() dev := &device.Device{ ID: uuid.NewString(), OwnerUserID: userID, Name: defaultPairedDeviceName, DeviceType: defaultPairedDeviceType, RemoteStatus: device.RemoteStatusOffline, Status: device.USBStatusUnknown, PairedAt: &now, CreatedAt: now, UpdatedAt: now, } if input, ok := firstUsableSerialDevice(devices); ok { existing, gErr := e.devices.GetBySerial(ctx, userID, input.SerialNumber) switch { case gErr == nil: dev = existing dev.PairedAt = &now dev.UpdatedAt = now case errors.Is(gErr, device.ErrNotFound): dev.SerialNumber = input.SerialNumber if input.DeviceType != "" { dev.DeviceType = input.DeviceType } default: return ExchangeProvisionResult{}, fmt.Errorf("exchange: get device by serial: %w", gErr) } } 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, dev.ID, parentTokenHash, ttl) if err != nil { return ExchangeProvisionResult{}, fmt.Errorf("exchange: create session token: %w", err) } return ExchangeProvisionResult{ DeviceID: dev.ID, SessionPlaintext: plaintext, SessionInfo: info, }, nil }