新增 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>
22 KiB
API Spec — 對前端的 REST + WebSocket 端點
base URL:
https://api.visiona.cloud(Phase 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-userhard-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/exchange(agent → 雲端;public、不走 AuthMiddleware)
呼叫方是 local agent(不是瀏覽器前端):agent 拿 Pairing Token 換 Session Token 時本身還沒有登入身份,故此端點註冊在 engine 層級、不套 auth。行為主文件見
../visiona-agent-tdd.md§4.3 / §7.1;本節記錄 contract 形狀與 WP-0(ADR-018)擴充。
- Request(實作對齊
visionA-backend/internal/api/pairing.goPairingExchangeRequest與 agent 端internal/tunnel/pairing.goexchangeRequest):{ "pairing_token": "vAc_<32 hex>", "devices": [ { "serial_number": "0x1A2B3C4D", "device_type": "KL520", "firmware": "1.2.3" } ] }pairing_token(required):一次性 Pairing Token。devices(optional、WP-0 / migration 0004 新增):agent 在 exchange 前撈本地GET /api/devices上報的實體 USB 清單(含 Kneron kn_number 序號)。陣列因應「一 agent 多 USB」;device_type/firmware為 optional 欄位(omitempty)。
- Response(200,通用 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 四種 case):INVALID_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_number(agent 有帶 device_type 時一併填入)。多顆 USB 的完整模型(一 agent N device)屬 WP-B / migration 0005 範疇 |
| R2(假序號) | 假序號 0x00000000(agent 端 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 行為:
- 檢查 user 有 tunnel 連線
- 若 device_id 有傳,檢查 ownership
- 透過 tunnel forward 請求到 local agent(沿用 POC
handleProxy) - 回傳 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 token(
pairing_tokens+session_tokens兩張表,bydevice_id,UPDATE ... 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-019):Camera 與推論結果串流走雲端 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 server(http://127.0.0.1:<port>,port 動態 ∈ 3721–3740),不經雲端 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):
- Target:
http://127.0.0.1:<port>/api/local/media/upload/{video|image|batch-images}(port 由前端探測取得,見下方 §6.3) - Header:
X-Visiona-Local-Token: <token>(一律要求,不看 Origin;缺失 / 無效 → 401) - Host 驗證:
Hostheader(去 port)必須 ∈{127.0.0.1, localhost, ::1},否則400(DNS rebinding 防護,見 §6.4) - Body:既有
multipart/form-data(含deviceId欄位 +file),格式不變。此 route 內部轉呼叫既有 upload handler(handler 零改動)。 - size 上限(必做,ADR-019 §4.3.1):video 硬牆 ≤ 500MB(前端正常上限 90MB);batch 合計 ≤ 80MB。size 與 token 驗證都在讀 body(
FormFile)之前。 - 驗證失敗 →
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 直連的支援 endpoint(local-agent 新增,見 ADR-019)
以下 endpoint 為 localhost 直連機制所需,僅存在於 local-agent 本機 server,不對應雲端路由:
GET /api/local/hello — 同機偵測 + 身分驗證
- Auth:無(bootstrap 需要——拿 token 前需先知道 port;serial 以雜湊揭露、最小化 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 Cryptocrypto.subtle.digest('SHA-256', utf8("visiona-local-v1" + fullSerial))獨立重算比對。- 明確不要做:不得用 server 啟動時
crypto/rand生成的私有 salt(前端算不出、破壞比對);不得 per-request 隨機 salt。
- 明確不要做:不得用 server 啟動時
- 用途:前端掃描 3721–3740,比對
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 header(
Access-Control-Allow-Private-Network: true);Host 驗證 = 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 429:token 上限(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(逗號分隔完整 origin),stage 預設含雙入口https://stage-9527.innovedus.com:9527+http://192.168.0.130:9527(純 HTTP 內網入口)+ devhttp://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: false(M3 必做,不可對雲端 origin 回 true)、Access-Control-Allow-Headers: Content-Type, X-Visiona-Local-Token、Access-Control-Max-Age: 600、Vary: Origin - [M2 — 必做] Host header 驗證 = loopback:對所有
/api/local/*(含/api/local/media/upload/*)與舊 tunnel-path media route,Host(去 port)必須 ∈{127.0.0.1, localhost, ::1},否則400 Bad Request(DNS 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 合計 ≤80MB,M1) | local-agent |
| (Host 驗證失敗) | 400 | Host header 非 loopback(DNS 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 接入後行為(塊 5):PostgreSQL / 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,建 jobGET /api/conversion/{job_id}— 查狀態(HTTP polling,frontend 間隔 2s)POST /api/conversion/{job_id}/promote-to-models— 「加到模型庫」POST /api/conversion/{job_id}/download-token— 換 browser 直連 FAA 的 delegated URLGET /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。持久資料相關 API(model / device / token)在 PG 不可用時回此碼,不回假資料。 |
DB 接入後的降級策略(fail-fast,2026-06-20 使用者拍板):
- PostgreSQL 掉 → 持久資料相關 API(model / device / token)回
503 SERVICE_UNAVAILABLE,不回假資料、不 fallback in-memory(避免回傳過期/不一致資料)。- Redis 掉 → session 驗證失敗(請求視為未認證 →
401 UNAUTHORIZED),不自動 fallback in-memory session(避免多機部署下各實例 session 不同步)。- DB unique violation →
409 CONFLICT(而非 500),讓前端能區分「衝突」與「未預期錯誤」。
12. Pagination
對會變大的 list(models、devices、jobs)用 cursor-based:
GET /api/models?limit=50&cursor=...
Response:
{ "data": [...], "next_cursor": "..." | null }
雛形可先簡單回全部(in-memory);Phase 1 接 DB 時實作 cursor。
雛形 MVP 清單(必須有):
GET /api/system/healthGET /api/pairing/statusGET /api/devices+ 透過 tunnel forwardGET /api/models+POST /api/models/init+/finalize(LocalFS)/storage/*代理- WS
/ws/devices/events - WS
/ws/pairing/status
其他可以先 501 或 stub。