jim800121chen 4c962dfec1 docs(arch): ADR-019 影片/圖片/批次改走同機 localhost 直連(契約定稿)
新增 ADR-019:影片/圖片/批次上傳從經雲端 tunnel 改為同機瀏覽器直連
local-agent 的 localhost endpoint,修補 design-doc §5.3「大檔不走 tunnel」
原則未套用到 media 的邏輯漏洞。採混合路徑(上傳走 localhost、控制面 +
MJPEG 結果 + 推論 WS 仍走 tunnel)。

已完成 security pre-implementation review(1 Critical + 3 Major)+ architect
依審查修正契約 + security confirm-only 複審 APPROVED。契約含:
- C1: route 分離(新 route /api/local/media/upload/* 一律要 token、不看 Origin),
  消除「無 Origin 免 token」後門
- M2: Host header 驗證 = loopback 升為必做(DNS rebinding 緩解)
- M3: CORS 雲端 origin 完整字串精確比對 + Allow-Credentials: false
- 議題1: serial 回 SHA-256(visiona-local-v1‖serial) 雜湊、移除 agentVersion
- M1: media size 上限(video≤500MB / batch 合計 80MB)+ temp 檔清理升為必做

ADR 維持 Proposed,待實作完成 + code-level security 複審後轉 Accepted。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 03:31:16 +08:00

22 KiB
Raw Permalink Blame History

API Spec — 對前端的 REST + WebSocket 端點

base URLhttps://api.visiona.cloudPhase 1/ http://localhost:3001(雛形) 認證Authorization: Bearer <JWT>(雛形可省略,走 StaticAuthService 通用回應格式

{ "success": true, "data": {...} }
{ "success": false, "error": { "code": "ERR_CODE", "message": "..." } }

1. Auth雛形 stub

POST /api/auth/login

  • 雛形:回 501 { code: "NOT_IMPLEMENTED" }
  • Phase 1{ email, password }{ user, access_token, refresh_token }

POST /api/auth/register

  • 同上

POST /api/auth/logout

  • Phase 1清 refresh token

GET /api/auth/me

  • 雛形:回 demo-user hard-coded
  • Phase 1從 JWT 取

2. Pairing

POST /api/pairing/token

  • Auth required雛形靜默通過
  • 雛形 Response
    { "success": false, "error": { "code": "NOT_IMPLEMENTED", "message": "Dev uses env VISIONA_PAIRING_TOKEN" } }
    
  • Phase 1 Response
    {
      "success": true,
      "data": {
        "token": "pk_AbCd1234...",
        "expires_at": "2026-04-21T13:00:00Z"
      }
    }
    

POST /api/pairing/exchangeagent → 雲端public、不走 AuthMiddleware

呼叫方是 local agent不是瀏覽器前端agent 拿 Pairing Token 換 Session Token 時本身還沒有登入身份,故此端點註冊在 engine 層級、不套 auth。行為主文件見 ../visiona-agent-tdd.md §4.3 / §7.1;本節記錄 contract 形狀與 WP-0ADR-018擴充。

  • Request(實作對齊 visionA-backend/internal/api/pairing.go PairingExchangeRequest 與 agent 端 internal/tunnel/pairing.go exchangeRequest
    {
      "pairing_token": "vAc_<32 hex>",
      "devices": [
        {
          "serial_number": "0x1A2B3C4D",
          "device_type": "KL520",
          "firmware": "1.2.3"
        }
      ]
    }
    
    • pairing_tokenrequired):一次性 Pairing Token。
    • devicesoptional、WP-0 / migration 0004 新增agent 在 exchange 前撈本地 GET /api/devices 上報的實體 USB 清單(含 Kneron kn_number 序號)。陣列因應「一 agent 多 USB」device_type / firmware 為 optional 欄位(omitempty)。
  • Response200通用 envelope 的 data
    {
      "session_token": "vAs_...",
      "account": "demo@visionA.local",
      "relay_url": "wss://relay.visionA.cloud",
      "expires_at": "2026-07-21T00:00:00Z"
    }
    
  • 錯誤碼(走 error.code,對齊 TDD §7.1 四種 caseINVALID_PAIRING_TOKEN / PAIRING_TOKEN_EXPIRED / PAIRING_TOKEN_USED / PAIRING_TOKEN_REVOKED

devices 欄位的雲端行為WP-0 序號地基,決策見 ../adr/adr-018-agent-device-model.md

規則 行為
R1最小落地 exchange 仍只落一筆 device 記錄:從上報清單挑第一顆序號可用的裝置,把序號填進 devices.serial_numberagent 有帶 device_type 時一併填入)。多顆 USB 的完整模型(一 agent N device屬 WP-B / migration 0005 範疇
R2假序號 假序號 0x00000000agent 端 pyusb fallback、無 Kneron SDK 時寫死上報)與空字串序號視同無序號、跳過不用 → serial_number 寫 NULL
R4同序號重配 同 owner 已存在同 serial 的未刪除 device → 復用既有 device_id(只更新 paired_at / updated_at),不新建,避免撞 partial unique index uq_devices_owner_serial_active

向下相容devices 欄位缺省(舊 agent、local server 未起、撈不到清單、0 顆裝置)→ 行為與舊版完全一致:自建一筆 serial_number = NULL 的 device。序號是加值資訊配對本身不因撈不到 USB 而失敗。

GET /api/pairing/status

  • 查詢當前 user 的 tunnel 連線狀態
  • Response:
    {
      "success": true,
      "data": {
        "connected": true,
        "connected_at": "2026-04-21T12:00:00Z",
        "last_seen_at": "2026-04-21T12:34:56Z",
        "device_id": "dev-xxx",
        "agent_version": "local-tool 1.2.3"
      }
    }
    

GET /api/pairing/tokens

  • List 當前 user 的所有 tokens
  • Phase 1回 array of { id, device_id, kind, created_at, last_seen_at }

DELETE /api/pairing/tokens/:id

  • 撤銷指定 token
  • Phase 1 實作;雛形 501

3. Devices

以下大部分端點會被轉發到 local agent。api-server 行為:

  1. 檢查 user 有 tunnel 連線
  2. 若 device_id 有傳,檢查 ownership
  3. 透過 tunnel forward 請求到 local agent沿用 POC handleProxy
  4. 回傳 local agent 的 response

路徑與回應格式與 local-tool 相同,前端改 base URL 即可。

GET /api/devices — 列出當前本地掃到的裝置

POST /api/devices/scan — 觸發重掃

GET /api/devices/:id — 單一裝置

POST /api/devices/:id/connect

POST /api/devices/:id/disconnect

POST /api/devices/:id/flash — 燒韌體(透過 tunnel

POST /api/devices/:id/inference/start

POST /api/devices/:id/inference/stop

雲端特有(非 tunnel forward

GET /api/cloud/devices — 列出「我在雲端綁過的 Device records」

  • GET /api/devices 不同:這個是查雲端 DB不問 local agent
  • 雛形:從 InMemoryDeviceRepository
  • Response[{ id, name, device_type, serial_number, status, last_seen_at }]

POST /api/cloud/devices/:id/rename

  • 改雲端上的 device name

DELETE /api/cloud/devices/:id — 解除綁定unpair並刪除雲端 device record

  • DB 接入後行為(塊 5:刪除 device 會在同一交易內 cascade 撤銷該 device 的所有 pairing token + session tokenpairing_tokens + session_tokens 兩張表by device_idUPDATE ... SET revoked_at = now())。對應 DB 層一致性定義見 ../database.md §6。
  • 撤銷後該 device 的既有 tunnel session 將無法續用,需重新配對。

4. Models

GET /api/models — 列出 user 的 model

  • 雲端模型(存 storage+ preset models硬編碼
  • Response
    {
      "success": true,
      "data": [
        {
          "id": "abc-123",
          "name": "YOLOv5 Face",
          "target_chip": "kl520",
          "file_size": 12345678,
          "source": "uploaded",
          "created_at": "..."
        }
      ]
    }
    

GET /api/models/:id

  • Model 詳情

POST /api/models/init — 初始化上傳

  • Request: { name, file_size, checksum, target_chip, description? }
  • Response:
    {
      "success": true,
      "data": {
        "model_id": "new-id",
        "upload_url": "https://...presigned-put-url...",
        "upload_expires_at": "..."
      }
    }
    

POST /api/models/:id/finalize

  • 在 presigned PUT 成功後呼叫
  • api-server 驗證檔案已存在、size / checksum 對 → status 改 "ready"

DELETE /api/models/:id

POST /api/models/:id/load-to-device

  • Body{ device_id }
  • api-server 產 presigned GET URL → 透過 tunnel 送 local agent 「下載並載入」
  • 回傳 job status

5. Clusters從 POC 搬)

GET /api/clusters

POST /api/clusters

  • Body: { name, device_ids: [...] }

GET /api/clusters/:id

DELETE /api/clusters/:id

POST /api/clusters/:id/devices

DELETE /api/clusters/:id/devices/:deviceId

PUT /api/clusters/:id/devices/:deviceId/weight

POST /api/clusters/:id/flash

POST /api/clusters/:id/inference/start

POST /api/clusters/:id/inference/stop


6. Camera / Media

路徑分流2026-07 起,見 ADR-019Camera 與推論結果串流走雲端 tunnel forward影片 / 圖片 / 批次的檔案上傳改走同機 localhost 直連 local-agent(繞過 tunnel解決大檔頻寬雙倍 + nginx 100M + 300s timeout

6.1 走 tunnel forward與 local-tool 相同,不變)

GET /api/camera/list

POST /api/camera/start

POST /api/camera/stop

GET /api/camera/stream — MJPEG透過 tunnel streaming

GET /api/media/batch-images/:index — 讀取批次結果(走 tunnel

POST /api/media/seek — 影片 seek走 tunnel

Camera 支援遠端(跨機)檢視,這是產品核心價值,不受同機限制。

6.2 走 localhost 直連 local-agent影片 / 圖片 / 批次上傳,見 ADR-019

以下上傳 endpoint 由瀏覽器在同機情境下直接 POST 到 local-agent 的 loopback serverhttp://127.0.0.1:<port>port 動態 ∈ 37213740不經雲端 tunnel

[C1 — security review 定案route 分離] 瀏覽器 localhost 直連走新的、一律要求 token 的 route/api/local/media/upload/*不看 Origin;既有 tunnel 轉發路徑維持 /api/media/upload/* 不變。認證判準由 server 端固定 route 決定,由攻擊者可控的 Origin 決定(原「無 Origin 免 token」規則已刪除——見 ADR-019 §2.4.1)。

瀏覽器 localhost 直連 route新增、一律要 token

POST /api/local/media/upload/image — 直連 local-agent

POST /api/local/media/upload/video — 直連 local-agent大檔主要受益者

POST /api/local/media/upload/batch-images — 直連 local-agent

上傳請求契約(瀏覽器 → local-agent

  • Targethttp://127.0.0.1:<port>/api/local/media/upload/{video|image|batch-images}port 由前端探測取得,見下方 §6.3
  • HeaderX-Visiona-Local-Token: <token>一律要求,不看 Origin;缺失 / 無效 → 401
  • Host 驗證:Host header去 port必須 ∈ {127.0.0.1, localhost, ::1},否則 400DNS rebinding 防護,見 §6.4
  • Body既有 multipart/form-data(含 deviceId 欄位 + file),格式不變。此 route 內部轉呼叫既有 upload handlerhandler 零改動)。
  • size 上限必做ADR-019 §4.3.1video 硬牆 ≤ 500MB前端正常上限 90MBbatch 合計 ≤ 80MB。size 與 token 驗證都在讀 bodyFormFile之前
  • 驗證失敗 → 401 { "success": false, "error": { "code": "LOCAL_TOKEN_INVALID" } }
  • 檔案過大 → 413 { "success": false, "error": { "code": "LOCAL_UPLOAD_TOO_LARGE" } }

既有 tunnel 轉發 route保留不變供雲端路徑

POST /api/media/upload/image

POST /api/media/upload/video

POST /api/media/upload/batch-images

tunnel client 內部轉發至此,路徑 / 行為不變。此路徑不因本 ADR 新開放給雲端 origin;同機直打舊 route 仍受 §6.4 Host 驗證約束Host=loopback

推論結果MJPEG 串流 streamUrl / 推論 WS /ws/devices/:id/inference)仍走雲端 tunnel。這是「上傳走 localhost、結果走雲端」的混合路徑ADR-019 §2.1)。

6.3 localhost 直連的支援 endpointlocal-agent 新增,見 ADR-019

以下 endpoint 為 localhost 直連機制所需,僅存在於 local-agent 本機 server,不對應雲端路由:

GET /api/local/hello — 同機偵測 + 身分驗證

  • Authbootstrap 需要——拿 token 前需先知道 portserial 以雜湊揭露、最小化 bootstrap 洩露,見下方裁決)
  • Response 200
    {
      "success": true,
      "data": {
        "serialHashes": ["<hex>", "..."],
        "supportsLocalUpload": true
      }
    }
    
  • serialHashes 契約security review 議題 1 裁決,必守)
    • 每個元素 = SHA-256(salt || fullSerial)hex 字串lowercase hex。演算法固定 SHA-256(非 MD5 / SHA-1非 bcrypt/argon2——目的是「前端可重算比對」而非「防暴力還原」
    • salt = 固定公開常數 visiona-local-v1(前後端共用寫死的 app-level 常數、 server 私有隨機值)。前端用 Web Crypto crypto.subtle.digest('SHA-256', utf8("visiona-local-v1" + fullSerial)) 獨立重算比對。
      • 明確不要做:不得用 server 啟動時 crypto/rand 生成的私有 salt前端算不出、破壞比對不得 per-request 隨機 salt。
    • 用途:前端掃描 37213740比對 SHA-256("visiona-local-v1" || selectedDevice.serialNumber) ∈ serialHashes 判定同機(沿用 ADR-018 serial 路由慣例)。
    • 誠實揭露殘留風險:序號熵低 + salt 公開 → 雜湊不能真正隱藏序號可枚舉回推它擋的是「順手抓完整序號」的被動洩露非「防定向還原」。security 判定與威脅相稱(序號非 credential、控制裝置仍需 token+OIDC不需過度設計。
  • agentVersion 已移除security review 議題 1完整 build 版本 = 給攻擊者精準 CVE 對照A06。前端只需判斷「支不支援 local upload」→ supportsLocalUpload: true 布林已足夠。
  • 最小揭露hello 不回任何其他欄位deviceId 明文 / 裝置型號 / driver 版本 / 機器名一律不回)。
  • CORS需回完整 ACA* + PNA headerAccess-Control-Allow-Private-Network: trueHost 驗證 = loopback§6.4)。

POST /api/local/issue-token — 產 one-time upload token

  • 取得路徑:僅經既有 tunnel 由 api-server 轉發呼叫api-server 已驗 OIDC session + 裝置歸屬,見下方 /api/devices/:serial/local-upload-ticket)。不對瀏覽器 CORS 白名單開放(非 /api/local/media/upload/* 直連 route受 §6.4 Host 驗證約束。
    • C1 一致性):不再用「無 Origin 即放行」作判準(該反模式已於 ADR-019 §2.4.1 刪除。token 只是縱深防禦一層——同機惡意程序的攻擊面由 C1 route 分離 + Host 驗證處理issue-token 產出的 token 仍綁 deviceId + one-time + 120s TTL即使被同機程序取得也僅能對已歸屬裝置發一次短期上傳。
  • Request{ "serial": "string" }
  • Response 200{ "success": true, "data": { "token": "string", "expiresAt": <unix_ms>, "ttlSeconds": 120 } }
  • Response 429token 上限32 個未使用)已滿 → { "error": { "code": "LOCAL_TOKEN_LIMIT" } }
  • Token 設計見 ADR-019 §2.4,核心設計已經 security agent 審通過review 議題 2

POST /api/devices/:serial/local-upload-ticket — 雲端 ticket新增雲端 endpoint

  • 位置:api-server雲端,非 local-agent
  • 驗 OIDC session + 裝置歸屬 → 經既有 tunnel 打 local-agent 的 /api/local/issue-token → 回傳 token 給瀏覽器
  • 這是 token 的取得路徑,走既有已認證的 tunnel控制面

6.4 CORS / Host 驗證 / PNA 要求local-agent見 ADR-019 §2.5

  • Origin 白名單來源env VISIONA_CLOUD_ORIGINS(逗號分隔完整 originstage 預設含雙入口 https://stage-9527.innovedus.com:9527 + http://192.168.0.130:9527(純 HTTP 內網入口)+ dev http://localhost:3000
  • loopback 舊白名單(middleware.go:17-22)保留不動
  • [M3 — 必做] 雲端 origin 採「完整 origin 精確比對」scheme+host+port 全等逐字比對),不可沿用既有 isAllowedOrigin 的 hostname-only + 任意 port + 放寬 scheme 邏輯。白名單存完整 origin 字串、整串相等才通過(否則會變成「該網域任意 port / 任意 scheme 都放行」)。
  • Response雲端 origin 通過):Access-Control-Allow-Credentials: falseM3 必做,不可對雲端 origin 回 trueAccess-Control-Allow-Headers: Content-Type, X-Visiona-Local-TokenAccess-Control-Max-Age: 600Vary: Origin
  • [M2 — 必做] Host header 驗證 = loopback:對所有 /api/local/*(含 /api/local/media/upload/*)與舊 tunnel-path media routeHost(去 port必須 ∈ {127.0.0.1, localhost, ::1},否則 400 Bad RequestDNS rebinding 獨立第二道防護tunnel 轉發的 Host 本就是 127.0.0.1:<port> 故不受影響)。
  • PNA必做preflight 帶 Access-Control-Request-Private-Network: true 且通過白名單 → 回 Access-Control-Allow-Private-Network: true

6.5 錯誤碼(跨模組統一)

錯誤碼 HTTP 情境 誰產生
LOCAL_AGENT_NOT_FOUND —(前端內部狀態) 掃描無回應(非同機 / agent 沒跑) frontend
LOCAL_AGENT_MISMATCH —(前端內部狀態) 掃描有回應但 serial 不符 frontend
LOCAL_TOKEN_INVALID 401 token 不存在 / 過期 / 已使用 / 缺失(直連 route 一律要 token local-agent
LOCAL_TOKEN_LIMIT 429 未使用 token 達 32 上限 local-agent
LOCAL_UPLOAD_TOO_LARGE 413 上傳超過 size 上限video ≤500MB / batch 合計 ≤80MBM1 local-agent
Host 驗證失敗) 400 Host header 非 loopbackDNS rebinding 防護M2 local-agent

7. System

GET /api/system/health

  • 雲端側:回 api-server 自己的健康 + tunnel 連線狀態
    {
      "success": true,
      "data": {
        "api_server": "ok",
        "tunnel_connected": true,
        "agent_last_seen_at": "..."
      }
    }
    

GET /api/system/info

  • 版本資訊

GET /healthz — liveness / readiness給 load balancer

  • 純基礎設施健康檢查端點(非 /api/* 前綴),供 LB / orchestrator probe。
  • DB 接入後行為(塊 5PostgreSQL / Redis 啟用時會 ping,任一 ping 失敗 → 回 503(讓 load balancer 知道此實例不健康、停止導流)。未啟用的依賴略過檢查(雛形未配 DB/Redis 時,這些依賴視為 not-applicable不影響健康判定
  • Response健康200 { "status": "ok" }
  • Response不健康503 { "status": "unavailable", "failed": ["postgres"] }failed 列出 ping 失敗的依賴)
  • GET /api/system/health 的差異:/healthz 是基礎設施 probe含 DB/Redis ping/api/system/health 是業務層健康api-server 自身 + tunnel 連線狀態)。

8. Converter

8.1 Phase 1 stub既有保留

雛形 stub 路由Phase 0.8 的真實整合改走 §8.2 /api/conversion/*,下列路由保留為 placeholder 待 Phase 1 視需要 supersede。

POST /api/converter/jobs

  • Body{ source_model_key, target_chip, params? }
  • Response{ job_id, status: "queued" }

GET /api/converter/jobs

  • List user 的 jobs

GET /api/converter/jobs/:id

  • Job 狀態

GET /api/converter/jobs/:id/download

  • 下載產物presigned URL redirect

詳細契約api-converter-contract.md

8.2 Phase 0.8 — /api/conversion/*(轉檔功能整合)

正式對接 kneron_model_converter scheduler + FAA delegated download

  • POST /api/conversion/init — multipart streaming proxy 到 converter建 job
  • GET /api/conversion/{job_id} — 查狀態HTTP pollingfrontend 間隔 2s
  • POST /api/conversion/{job_id}/promote-to-models — 「加到模型庫」
  • POST /api/conversion/{job_id}/download-token — 換 browser 直連 FAA 的 delegated URL
  • GET /api/conversion/active — 查當前 user 是否有 active job

詳細契約api-conversion.md 內部設計../conversion.md ADR../adr/adr-014-conversion-integration.md


9. WebSocket

WS /ws/devices/events

  • 訂閱「裝置上下線」事件
  • Server push
    { "type": "device.connected", "device_id": "xxx", "at": "..." }
    { "type": "device.disconnected", "device_id": "xxx", "at": "..." }
    

WS /ws/devices/:id/flash-progress

  • 燒錄進度(透過 tunnel 從 local agent 取)

WS /ws/devices/:id/inference

  • 推論結果串流

WS /ws/server-logs

  • log broadcast沿用 local-tool 的 broadcaster

WS /ws/system

  • 系統事件server:shutdown-imminent 等)

WS /ws/clusters/:id/inference

WS /ws/clusters/:id/flash-progress

WS /ws/pairing/status(新)

  • 訂閱 tunnel 連線狀態變化
  • Server push
    { "type": "tunnel.connected", "connected_at": "..." }
    { "type": "tunnel.disconnected", "reason": "network_error", "at": "..." }
    

10. Storage雛形 LocalFS 代理)

GET /storage/*filepath?expires=...&signature=...

  • LocalFS 的假 presigned GET
  • 驗簽後讀檔回傳

PUT /storage/*filepath?expires=...&signature=...

  • LocalFS 的假 presigned PUT
  • 驗簽後收 body 寫檔

Phase 1:直接由 S3 提供,不走 api-server。


11. 錯誤碼清單

Code HTTP 說明
UNAUTHORIZED 401 未認證或 token 無效
FORBIDDEN 403 權限不足
NOT_FOUND 404 資源不存在
VALIDATION_FAILED 400 輸入驗證失敗
CONFLICT 409 唯一性衝突(如重複註冊 active device serial、email 已存在。DB unique violation 映射到此碼。
TUNNEL_DISCONNECTED 502 Local agent 未連線
TUNNEL_ERROR 502 Tunnel 傳輸錯誤
NOT_IMPLEMENTED 501 雛形尚未實作
RATE_LIMITED 429 請求過快Phase 1
INTERNAL_ERROR 500 未預期錯誤
SERVICE_UNAVAILABLE 503 後端依賴PostgreSQL / Redis連線失敗時的 fail-fast。持久資料相關 APImodel / device / token在 PG 不可用時回此碼,不回假資料。

DB 接入後的降級策略fail-fast2026-06-20 使用者拍板)

  • PostgreSQL 掉 → 持久資料相關 APImodel / device / token503 SERVICE_UNAVAILABLE不回假資料、不 fallback in-memory(避免回傳過期/不一致資料)。
  • Redis 掉 → session 驗證失敗(請求視為未認證 → 401 UNAUTHORIZED不自動 fallback in-memory session(避免多機部署下各實例 session 不同步)。
  • DB unique violation → 409 CONFLICT(而非 500讓前端能區分「衝突」與「未預期錯誤」。

12. Pagination

對會變大的 listmodels、devices、jobs用 cursor-based

GET /api/models?limit=50&cursor=...
Response:
  { "data": [...], "next_cursor": "..." | null }

雛形可先簡單回全部in-memoryPhase 1 接 DB 時實作 cursor。


雛形 MVP 清單(必須有):

  • GET /api/system/health
  • GET /api/pairing/status
  • GET /api/devices + 透過 tunnel forward
  • GET /api/models + POST /api/models/init + /finalizeLocalFS
  • /storage/* 代理
  • WS /ws/devices/events
  • WS /ws/pairing/status

其他可以先 501 或 stub。