- local-agent/server:detector 保留 kn_number(parseScanDevices 可測化、 合成 ID 語意不動)+ DeviceInfo.SerialNumber + Manager serialToLocalID 反查表 + GetDevice 雙查(sessions 先、serial 後,純加法) - visiona-agent:pairing exchange 帶本地裝置清單(DeviceLister 失敗不中斷 配對、timeout 2s、omitempty 舊版相容) - visionA-backend:exchange 收 devices —— R1 取第一顆可用序號、R2 假序號 0x00000000 寫 NULL、R4 同 owner 同序號復用既有 device_id(防 23505)、 pg+mem 兩實作對齊 - 五個 proxy 操作(flash/inference/camera/connect/disconnect)收斂於 GetDevice 單一入口,serial 路由一處涵蓋 - docs:api-spec.md §2 增補 POST /api/pairing/exchange(schema + R1/R2/R4) - 測試:行為級四環節鏈 + dbtest 130 實跑 6/6 + DBOn 回歸 4/4; 三 module build/vet/test 全綠 - review:通過 0C/0M/6Mi/5Sug(.autoflow/05-implementation/review/ wp0-serial-routing-review.md);Minor #1 Rescan stale session 掛 WP-C 前置 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
497 lines
19 KiB
Go
497 lines
19 KiB
Go
// pairing.go — 雛形配對流程實作(AB5 範圍)。
|
||
//
|
||
// 對應 TDD §4.3「配對流程(雛形 + Phase 1)」與 Design spec §5。
|
||
//
|
||
// 責任:
|
||
// 1. 驗證 Pairing Token 格式(vAc_ + 32 hex)
|
||
// 2. 呼叫雲端 visionA-backend 的 POST /api/pairing/exchange
|
||
// 3. 若 mock mode = true(AB11 尚未上線),本地產生假 Session Token 供 dev 測試
|
||
// 4. 回傳 session token + account + relay URL 給 Manager 寫入 TokenStore
|
||
//
|
||
// ⚠️ 這個檔不動 visionA-backend 程式碼(那是 AB11 的事)。當 AB11 做完
|
||
// /api/pairing/exchange 上線,把 config.MockMode 設回 false 就會走真實呼叫。
|
||
package tunnel
|
||
|
||
import (
|
||
"bytes"
|
||
"crypto/rand"
|
||
"crypto/tls"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"net/http"
|
||
"regexp"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// PairingMockEnvVar 是控制 mock pairing 模式的環境變數名。
|
||
// 預設行為:unset / 任何 ≠ "true" 的值 → 走真實 exchange(production-safe default)。
|
||
// 必須明確設成 "true"(不分大小寫)才啟用 mock 模式。
|
||
//
|
||
// 此預設策略由 Fix-A3 引入;歷史上預設為 mock=on,AB11 完成後改為明確 opt-in 避免
|
||
// 使用者忘設環境變數誤用 mock token。
|
||
const PairingMockEnvVar = "VISIONA_PAIRING_MOCK"
|
||
|
||
// IsPairingMockOptIn 根據環境變數判斷是否啟用 mock pairing 模式。
|
||
//
|
||
// 規則(必須明確 opt-in):
|
||
// - "true"(不分大小寫)→ true
|
||
// - 其他任何值(unset / "false" / "1" / 拼錯字 / 空字串)→ false
|
||
//
|
||
// 抽出來的目的:app.go 與測試共用同一份判斷邏輯,避免規則飄移。
|
||
func IsPairingMockOptIn(envValue string) bool {
|
||
return strings.EqualFold(envValue, "true")
|
||
}
|
||
|
||
// InsecureSkipTLSVerifyEnvVar 是控制「跳過 TLS 憑證驗證」的環境變數名。
|
||
//
|
||
// ⚠️ DEV / TEST ONLY — 正式環境絕不可開。
|
||
//
|
||
// 用途:連線到使用自簽憑證的 stage / 測試環境(例如 stage 用 self-signed cert,
|
||
// curl 需要 -k 才連得上)。Go 預設 client 遇自簽憑證會直接 x509 驗證失敗,
|
||
// 導致 pairing exchange 與 tunnel WSS 連線都無法建立。
|
||
//
|
||
// 預設行為(production-safe):unset / 任何 ≠ "true" 的值 → 維持安全的 TLS 驗證。
|
||
// 必須明確設成 "true"(不分大小寫)才會跳過驗證。
|
||
const InsecureSkipTLSVerifyEnvVar = "VISIONA_INSECURE_SKIP_TLS_VERIFY"
|
||
|
||
// IsInsecureSkipTLSVerify 根據環境變數判斷是否要跳過 TLS 憑證驗證。
|
||
//
|
||
// 規則比照 IsPairingMockOptIn(必須明確 opt-in):
|
||
// - "true"(不分大小寫)→ true
|
||
// - 其他任何值(unset / "false" / "1" / 拼錯字 / 前導空白 / 空字串)→ false
|
||
//
|
||
// ⚠️ 回傳 true 代表進入不安全模式(中間人攻擊可竊聽 / 竄改流量),僅供 dev/test。
|
||
// 抽出來的目的:app.go、exchanger、tunnel client 共用同一份判斷邏輯,避免規則飄移。
|
||
func IsInsecureSkipTLSVerify(envValue string) bool {
|
||
return strings.EqualFold(envValue, "true")
|
||
}
|
||
|
||
// ErrInvalidTokenFormat 表示 Pairing Token 不符合 vAc_ + 32 hex 格式。
|
||
var ErrInvalidTokenFormat = errors.New("invalid pairing token format (expected vAc_ + 32 hex)")
|
||
|
||
// ErrTokenInvalid / ErrTokenExpired / ErrTokenUsed / ErrTokenRevoked 對應雲端
|
||
// /api/pairing/exchange 401 回應的四種 code(Design spec §5.4)。
|
||
// Manager 呼叫 Pair() 失敗時會把這些錯誤 emit 成 pairing:result event,前端依
|
||
// code 顯示本地化訊息。
|
||
var (
|
||
ErrTokenInvalid = errors.New("pairing token invalid")
|
||
ErrTokenExpired = errors.New("pairing token expired")
|
||
ErrTokenUsed = errors.New("pairing token already used")
|
||
ErrTokenRevoked = errors.New("pairing token revoked")
|
||
)
|
||
|
||
// ErrExchangeNetwork 為 network 層錯誤(DNS / TCP / TLS)。
|
||
var ErrExchangeNetwork = errors.New("exchange network error")
|
||
|
||
// pairingTokenRegex 對應 TDD §4.3:vAc_ 開頭 + 正好 32 個小寫 hex。
|
||
// 大寫 hex 不接受,與 visionA-backend 雛形生成格式一致。
|
||
var pairingTokenRegex = regexp.MustCompile(`^vAc_[0-9a-f]{32}$`)
|
||
|
||
// ValidatePairingToken 回傳 nil 代表格式正確。
|
||
func ValidatePairingToken(token string) error {
|
||
if !pairingTokenRegex.MatchString(token) {
|
||
return ErrInvalidTokenFormat
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ExchangeResult 是 exchange 成功後回傳給 Manager 的資料。
|
||
type ExchangeResult struct {
|
||
SessionToken string
|
||
// Account 是雲端帳號 email。雛形 mock 下為 "demo@visionA.local"。
|
||
Account string
|
||
// RelayURL 雲端告訴 agent 接下來要連哪個 relay(ws(s)://host/tunnel/connect)。
|
||
// 若回應沒帶此欄位,Manager 會 fallback 用 Config 中原本的 RelayURL。
|
||
RelayURL string
|
||
}
|
||
|
||
// exchangeRequest 對應 TDD §4.3 定義的 request body。
|
||
//
|
||
// Devices 為 WP-0(ADR-018 序號地基)新增的 optional 欄位:exchange 前向本地
|
||
// server 撈 `GET /api/devices` 取得實體 USB 清單(含 Kneron kn_number 序號),
|
||
// 讓雲端把序號填進 devices.serial_number(serial 路由的資料來源)。
|
||
// `omitempty` 保證舊行為相容:撈不到清單(local server 未起 / timeout / 0 裝置)
|
||
// 時不送此欄位,雲端走現行「自建 serial=NULL device」路徑。
|
||
type exchangeRequest struct {
|
||
PairingToken string `json:"pairing_token"`
|
||
Devices []exchangeDevice `json:"devices,omitempty"`
|
||
}
|
||
|
||
// exchangeDevice 是 exchange payload 中的單顆實體 USB 裝置。
|
||
// 用陣列因應「一 agent 多 USB」(WP-0 只保證序號送達,多顆的完整模型是 WP-B)。
|
||
type exchangeDevice struct {
|
||
SerialNumber string `json:"serial_number"`
|
||
DeviceType string `json:"device_type,omitempty"`
|
||
Firmware string `json:"firmware,omitempty"`
|
||
}
|
||
|
||
// LocalDevice 是 DeviceLister 回傳的本地 USB 裝置摘要(exchange payload 的來源)。
|
||
type LocalDevice struct {
|
||
SerialNumber string
|
||
DeviceType string
|
||
Firmware string
|
||
}
|
||
|
||
// DeviceLister 回傳本地 server 目前偵測到的 USB 裝置清單。
|
||
//
|
||
// 由 app.go 組裝時注入(NewLocalDeviceLister(port)),Exchanger 不自己猜 port。
|
||
// 回傳 error 或 nil 清單都不會讓 exchange 失敗——序號是加值資訊,配對本身
|
||
// 不能因為撈不到 USB 而中斷(fallback 到不帶 devices 的現行行為)。
|
||
type DeviceLister func() ([]LocalDevice, error)
|
||
|
||
// exchangeResponse 對齊雲端 /api/pairing/exchange 的成功 envelope(api/errors.go WriteSuccess):
|
||
//
|
||
// {
|
||
// "success": true,
|
||
// "data": {
|
||
// "session_token": "vAs_...",
|
||
// "account": "...@visionA.local",
|
||
// "relay_url": "wss://...",
|
||
// "expires_at": "2026-07-21T00:00:00Z"
|
||
// }
|
||
// }
|
||
//
|
||
// ⚠️ 歷史 contract drift:早期 agent 假設 session_token 在頂層(雛形註解寫「對齊雛形
|
||
// 雲端 handler」),但雲端後來統一改用 SuccessBody envelope(success + data),
|
||
// payload 全在 data. 底下。直接解頂層會讓 session_token 永遠為空、報
|
||
// "exchange response missing session_token"。此 struct 已對齊現行 envelope。
|
||
type exchangeResponse struct {
|
||
Success bool `json:"success"`
|
||
Data exchangeResponseData `json:"data"`
|
||
}
|
||
|
||
// exchangeResponseData 是成功 envelope 的 data payload。
|
||
// 對齊雲端 api.PairingExchangeResponse 的 json 欄位。
|
||
type exchangeResponseData struct {
|
||
SessionToken string `json:"session_token"`
|
||
ExpiresAt string `json:"expires_at,omitempty"`
|
||
Account string `json:"account,omitempty"`
|
||
RelayURL string `json:"relay_url,omitempty"`
|
||
}
|
||
|
||
// exchangeErrorResponse 對齊雲端的錯誤 envelope(api/errors.go WriteError):
|
||
//
|
||
// {
|
||
// "success": false,
|
||
// "error": { "code": "INVALID_PAIRING_TOKEN", "message": "...", "request_id": "..." }
|
||
// }
|
||
//
|
||
// ⚠️ 同樣的 contract drift:早期 agent 假設 401 body 為頂層 { "code": "token_invalid" },
|
||
// 但雲端用 ErrorBody envelope,code 在 error. 底下、且為大寫常數(見
|
||
// api.ErrCodeInvalidPairingToken 等)。此 struct + mapExchangeErrorCode 已對齊現行格式。
|
||
type exchangeErrorResponse struct {
|
||
Success bool `json:"success"`
|
||
Error exchangeErrorDetail `json:"error"`
|
||
}
|
||
|
||
// exchangeErrorDetail 是錯誤 envelope 的 error payload。
|
||
type exchangeErrorDetail struct {
|
||
Code string `json:"code"`
|
||
Message string `json:"message"`
|
||
}
|
||
|
||
// PairingExchanger 介面讓 Manager 在測試時能注入 fake(避免真的打 HTTP)。
|
||
type PairingExchanger interface {
|
||
Exchange(pairingToken string) (ExchangeResult, error)
|
||
}
|
||
|
||
// HTTPPairingExchanger 是生產用的實作,打真實的 HTTP 端點。
|
||
// MockMode = true 時不打 HTTP,改為本地產 fake session token,方便 AB11 未完成前
|
||
// 先做 end-to-end 驗證。
|
||
type HTTPPairingExchanger struct {
|
||
// CloudAPIURL 是 visionA-backend 的 base URL(不含 path)。
|
||
// 例:https://api.visionA.cloud
|
||
CloudAPIURL string
|
||
|
||
// Client 可注入自訂 http.Client(timeout 測試);nil 用預設 10 秒 timeout。
|
||
Client *http.Client
|
||
|
||
// MockMode = true 時跳過真實 HTTP、直接產假 session token。
|
||
// 僅用於 AB11 尚未落地的 dev 流程;正式上線必須 false。
|
||
MockMode bool
|
||
|
||
// MockAccount 可覆寫 mock 模式下回傳的 account email;空值用預設。
|
||
MockAccount string
|
||
|
||
// MockRelayURL 可覆寫 mock 模式下回傳的 relay URL;空值時不帶,讓 Manager
|
||
// fallback 用 Config.RelayURL。
|
||
MockRelayURL string
|
||
|
||
// InsecureSkipTLSVerify = true 時,exchange 用的 http.Client 會跳過 TLS 憑證
|
||
// 驗證(tls.Config{InsecureSkipVerify: true})。
|
||
//
|
||
// ⚠️ DEV / TEST ONLY — 正式環境絕不可開。僅用於連自簽憑證的 stage / 測試環境。
|
||
// 由 VISIONA_INSECURE_SKIP_TLS_VERIFY 控制,見 IsInsecureSkipTLSVerify。
|
||
// 僅在 Client == nil(走預設 client 建構)時生效;若呼叫者注入自訂 Client,
|
||
// 以該 Client 自帶的 Transport 為準。
|
||
InsecureSkipTLSVerify bool
|
||
|
||
// DeviceLister 供 exchange 前撈本地 USB 清單(含序號)塞進 payload。
|
||
// nil 或回傳失敗 → payload 不帶 devices(exchange 照常進行,見 DeviceLister 註解)。
|
||
DeviceLister DeviceLister
|
||
|
||
// Logf 為 optional 的 log 函式(app.go 注入 appLog)。nil 時 fallback 到
|
||
// 標準 log.Printf。撈裝置清單失敗屬「可恢復、不中斷」情況,必須留下紀錄。
|
||
Logf func(format string, args ...interface{})
|
||
}
|
||
|
||
// NewHTTPPairingExchanger 建立一個生產預設實例。
|
||
func NewHTTPPairingExchanger(cloudAPIURL string) *HTTPPairingExchanger {
|
||
return &HTTPPairingExchanger{
|
||
CloudAPIURL: cloudAPIURL,
|
||
Client: &http.Client{Timeout: 10 * time.Second},
|
||
}
|
||
}
|
||
|
||
// Exchange 執行 pairing exchange。Manager 會在 Pair() 流程呼叫。
|
||
func (e *HTTPPairingExchanger) Exchange(pairingToken string) (ExchangeResult, error) {
|
||
if err := ValidatePairingToken(pairingToken); err != nil {
|
||
return ExchangeResult{}, err
|
||
}
|
||
if e.MockMode {
|
||
return e.exchangeMock(pairingToken)
|
||
}
|
||
return e.exchangeReal(pairingToken)
|
||
}
|
||
|
||
// exchangeMock 不打 HTTP,僅用 crypto/rand 產一個合法格式的 Session Token。
|
||
// 格式:vAs_ + 64 hex,對齊 TDD §4.3 雛形規格。
|
||
func (e *HTTPPairingExchanger) exchangeMock(_ string) (ExchangeResult, error) {
|
||
token, err := generateMockSessionToken()
|
||
if err != nil {
|
||
return ExchangeResult{}, err
|
||
}
|
||
account := e.MockAccount
|
||
if account == "" {
|
||
account = "demo@visionA.local"
|
||
}
|
||
return ExchangeResult{
|
||
SessionToken: token,
|
||
Account: account,
|
||
RelayURL: e.MockRelayURL, // 空字串代表讓 Manager 用既有 RelayURL
|
||
}, nil
|
||
}
|
||
|
||
// exchangeReal 真實呼叫 visionA-backend /api/pairing/exchange。
|
||
func (e *HTTPPairingExchanger) exchangeReal(pairingToken string) (ExchangeResult, error) {
|
||
if e.CloudAPIURL == "" {
|
||
return ExchangeResult{}, errors.New("cloud API URL not configured")
|
||
}
|
||
client := e.Client
|
||
if client == nil {
|
||
client = &http.Client{Timeout: 10 * time.Second}
|
||
}
|
||
if e.InsecureSkipTLSVerify && client.Transport == nil {
|
||
// ⚠️ DEV / TEST ONLY:跳過 TLS 憑證驗證,供連自簽憑證的 stage。
|
||
// 正式環境絕不可開。WARNING log 由 app.go 啟動時統一印一次。
|
||
//
|
||
// 注意:只在 client.Transport == nil(含 NewHTTPPairingExchanger 的預設 client)
|
||
// 時覆寫,避免踩掉呼叫者注入的自訂 Transport;且不直接改 e.Client(用區域複本),
|
||
// 不影響傳入的共享 client。
|
||
c := *client
|
||
c.Transport = &http.Transport{
|
||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // dev-only, gated by VISIONA_INSECURE_SKIP_TLS_VERIFY
|
||
}
|
||
client = &c
|
||
}
|
||
|
||
body, err := json.Marshal(exchangeRequest{
|
||
PairingToken: pairingToken,
|
||
Devices: e.collectLocalDevices(),
|
||
})
|
||
if err != nil {
|
||
return ExchangeResult{}, err
|
||
}
|
||
|
||
endpoint := strings.TrimRight(e.CloudAPIURL, "/") + "/api/pairing/exchange"
|
||
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
|
||
if err != nil {
|
||
return ExchangeResult{}, err
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
return ExchangeResult{}, fmt.Errorf("%w: %v", ErrExchangeNetwork, err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
respBody, _ := io.ReadAll(resp.Body)
|
||
|
||
switch resp.StatusCode {
|
||
case http.StatusOK:
|
||
var ok exchangeResponse
|
||
if err := json.Unmarshal(respBody, &ok); err != nil {
|
||
return ExchangeResult{}, fmt.Errorf("decode exchange response: %w", err)
|
||
}
|
||
// session_token 在 envelope 的 data. 底下(見 exchangeResponse 註解)。
|
||
if ok.Data.SessionToken == "" {
|
||
return ExchangeResult{}, errors.New("exchange response missing session_token")
|
||
}
|
||
return ExchangeResult{
|
||
SessionToken: ok.Data.SessionToken,
|
||
Account: ok.Data.Account,
|
||
RelayURL: ok.Data.RelayURL,
|
||
}, nil
|
||
case http.StatusUnauthorized:
|
||
var errResp exchangeErrorResponse
|
||
_ = json.Unmarshal(respBody, &errResp)
|
||
// error code 在 envelope 的 error. 底下(見 exchangeErrorResponse 註解)。
|
||
return ExchangeResult{}, mapExchangeErrorCode(errResp.Error.Code)
|
||
case http.StatusNotFound:
|
||
// AB11 尚未完成時的清楚訊號;訊息指引使用者往 mock_mode 或等 AB11。
|
||
return ExchangeResult{}, fmt.Errorf("exchange endpoint not found (AB11 pending; set mock_mode or wait for backend deploy)")
|
||
default:
|
||
return ExchangeResult{}, fmt.Errorf("exchange failed: http %d: %s", resp.StatusCode, truncate(string(respBody), 256))
|
||
}
|
||
}
|
||
|
||
// collectLocalDevices 在 exchange 前撈本地 USB 清單、轉成 payload 形狀。
|
||
//
|
||
// 不弄壞守則(device-serial-task1-mapping.md §2 段 4a):撈清單失敗(local
|
||
// server 沒起 / timeout / 0 裝置)**不可讓 exchange 失敗**——回 nil 讓 payload
|
||
// 省略 devices 欄位(`omitempty`),雲端走現行「serial=NULL」路徑。
|
||
func (e *HTTPPairingExchanger) collectLocalDevices() []exchangeDevice {
|
||
if e.DeviceLister == nil {
|
||
return nil
|
||
}
|
||
devs, err := e.DeviceLister()
|
||
if err != nil {
|
||
e.logf("pairing: list local devices failed (exchange continues without serials): %v", err)
|
||
return nil
|
||
}
|
||
if len(devs) == 0 {
|
||
return nil
|
||
}
|
||
out := make([]exchangeDevice, 0, len(devs))
|
||
for _, d := range devs {
|
||
out = append(out, exchangeDevice{
|
||
SerialNumber: strings.TrimSpace(d.SerialNumber),
|
||
DeviceType: d.DeviceType,
|
||
Firmware: d.Firmware,
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// logf 走注入的 Logf(app.go 的 appLog);未注入時 fallback 標準 log。
|
||
func (e *HTTPPairingExchanger) logf(format string, args ...interface{}) {
|
||
if e.Logf != nil {
|
||
e.Logf(format, args...)
|
||
return
|
||
}
|
||
log.Printf(format, args...)
|
||
}
|
||
|
||
// localDeviceListTimeout 是撈本地 /api/devices 的 timeout。
|
||
// 比照 server_control.go probe 的 2 秒——序號是加值資訊,不能拖慢配對。
|
||
const localDeviceListTimeout = 2 * time.Second
|
||
|
||
// localDevicesEnvelope 對齊 local server `GET /api/devices` 的回應 envelope:
|
||
//
|
||
// { "success": true, "data": { "devices": [ { "id", "serialNumber", "type", "firmwareVersion", ... } ] } }
|
||
//
|
||
// 只解需要的欄位(serialNumber / type / firmwareVersion)。
|
||
type localDevicesEnvelope struct {
|
||
Success bool `json:"success"`
|
||
Data struct {
|
||
Devices []struct {
|
||
SerialNumber string `json:"serialNumber"`
|
||
Type string `json:"type"`
|
||
FirmwareVersion string `json:"firmwareVersion"`
|
||
} `json:"devices"`
|
||
} `json:"data"`
|
||
}
|
||
|
||
// NewLocalDeviceLister 建立一個向本地 server(127.0.0.1:port)撈
|
||
// `GET /api/devices` 的 DeviceLister。port 由 app.go 從 ServerController 取得
|
||
// 後注入(Exchanger 不自己猜 port)。
|
||
func NewLocalDeviceLister(port int) DeviceLister {
|
||
client := &http.Client{Timeout: localDeviceListTimeout}
|
||
url := fmt.Sprintf("http://127.0.0.1:%d/api/devices", port)
|
||
return func() ([]LocalDevice, error) {
|
||
resp, err := client.Get(url)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("local device list: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode != http.StatusOK {
|
||
return nil, fmt.Errorf("local device list: http %d", resp.StatusCode)
|
||
}
|
||
var env localDevicesEnvelope
|
||
if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
|
||
return nil, fmt.Errorf("local device list: decode: %w", err)
|
||
}
|
||
out := make([]LocalDevice, 0, len(env.Data.Devices))
|
||
for _, d := range env.Data.Devices {
|
||
out = append(out, LocalDevice{
|
||
SerialNumber: d.SerialNumber,
|
||
DeviceType: d.Type,
|
||
Firmware: d.FirmwareVersion,
|
||
})
|
||
}
|
||
return out, nil
|
||
}
|
||
}
|
||
|
||
// mapExchangeErrorCode 把雲端錯誤 envelope 的 error.code 映射成 agent 內部 sentinel error。
|
||
//
|
||
// code 為雲端的大寫常數(見 visionA-backend api.ErrCodeInvalidPairingToken 等):
|
||
// - INVALID_PAIRING_TOKEN → ErrTokenInvalid
|
||
// - PAIRING_TOKEN_EXPIRED → ErrTokenExpired
|
||
// - PAIRING_TOKEN_USED → ErrTokenUsed
|
||
// - PAIRING_TOKEN_REVOKED → ErrTokenRevoked
|
||
//
|
||
// ⚠️ contract drift 修正:早期 agent 比對的是 token_invalid 等小寫值(與雲端不符),
|
||
// 導致所有 401 都落到 default 分支、前端拿不到正確的 error code 顯示對應 UI 文案。
|
||
func mapExchangeErrorCode(code string) error {
|
||
switch code {
|
||
case "INVALID_PAIRING_TOKEN":
|
||
return ErrTokenInvalid
|
||
case "PAIRING_TOKEN_EXPIRED":
|
||
return ErrTokenExpired
|
||
case "PAIRING_TOKEN_USED":
|
||
return ErrTokenUsed
|
||
case "PAIRING_TOKEN_REVOKED":
|
||
return ErrTokenRevoked
|
||
default:
|
||
return fmt.Errorf("%w (code=%q)", ErrTokenInvalid, code)
|
||
}
|
||
}
|
||
|
||
// generateMockSessionToken 產生 vAs_ + 64 hex 的假 token(mock mode 用)。
|
||
func generateMockSessionToken() (string, error) {
|
||
buf := make([]byte, 32) // 32 bytes → 64 hex chars
|
||
if _, err := rand.Read(buf); err != nil {
|
||
return "", err
|
||
}
|
||
return "vAs_" + hex.EncodeToString(buf), nil
|
||
}
|
||
|
||
// MaskSessionToken 產生 Session Token 的遮蔽顯示字串,供 UI / log 使用。
|
||
// 格式:前綴(vAs_) + 前 8 hex + " ··· " + 後 4 hex,例「vAs_a1b2c3d4 ··· e7f8」。
|
||
// 對齊 Design spec §4.2 (B) 的 Session Token 遮蔽規則。
|
||
func MaskSessionToken(token string) string {
|
||
if !strings.HasPrefix(token, "vAs_") {
|
||
// 非預期格式;回空字串避免洩漏
|
||
return ""
|
||
}
|
||
rest := strings.TrimPrefix(token, "vAs_")
|
||
if len(rest) < 12 {
|
||
return ""
|
||
}
|
||
return "vAs_" + rest[:8] + " ··· " + rest[len(rest)-4:]
|
||
}
|
||
|
||
func truncate(s string, n int) string {
|
||
if len(s) <= n {
|
||
return s
|
||
}
|
||
return s[:n]
|
||
}
|