# API Spec — 對前端的 REST + WebSocket 端點 > **base URL**:`https://api.visiona.cloud`(Phase 1)/ `http://localhost:3001`(雛形) > **認證**:`Authorization: Bearer `(雛形可省略,走 `StaticAuthService`) > **通用回應格式**: > ```json > { "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: ```json { "success": false, "error": { "code": "NOT_IMPLEMENTED", "message": "Dev uses env VISIONA_PAIRING_TOKEN" } } ``` - Phase 1 Response: ```json { "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.go` `PairingExchangeRequest` 與 agent 端 `internal/tunnel/pairing.go` `exchangeRequest`): ```json { "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`): ```json { "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: ```json { "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 token(`pairing_tokens` + `session_tokens` 兩張表,by `device_id`,`UPDATE ... SET revoked_at = now()`)。對應 DB 層一致性定義見 `../database.md` §6。 - 撤銷後該 device 的既有 tunnel session 將無法續用,需重新配對。 --- ## 4. Models ### GET `/api/models` — 列出 user 的 model - 雲端模型(存 storage)+ preset models(硬編碼) - Response: ```json { "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: ```json { "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 動態 ∈ 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:/api/local/media/upload/{video|image|batch-images}`(port 由前端探測取得,見下方 §6.3) - Header:`X-Visiona-Local-Token: `(**一律要求,不看 Origin**;缺失 / 無效 → 401) - Host 驗證:`Host` header(去 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: ```json { "success": true, "data": { "serialHashes": ["", "..."], "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。 - 用途:前端掃描 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": , "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 內網入口)+ 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: 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:` 故不受影響)。 - **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 連線狀態 ```json { "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`](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 polling,frontend 間隔 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`](api-conversion.md) **內部設計** → [`../conversion.md`](../conversion.md) **ADR** → [`../adr/adr-014-conversion-integration.md`](../adr/adr-014-conversion-integration.md) --- ## 9. WebSocket ### WS `/ws/devices/events` - 訂閱「裝置上下線」事件 - Server push: ```json { "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: ```json { "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/health` - `GET /api/pairing/status` - `GET /api/devices` + 透過 tunnel forward - `GET /api/models` + `POST /api/models/init` + `/finalize`(LocalFS) - `/storage/*` 代理 - WS `/ws/devices/events` - WS `/ws/pairing/status` 其他可以先 501 或 stub。