# Design Doc — Kneron Model Converter 對外 API
## 作者:Architect Agent
## 狀態:Draft(Phase 0.8b 重寫,三方交叉審閱前)
## 最後更新:2026-05-16
## 範圍:Phase 1 完工 + Phase 0.8b 設計轉向(API key + `/result` endpoint)
> **auth 設計演進**:本文件反映 Phase 0.8b 拍板後的「目標狀態」(API key + `/result` 中轉)。完整決策歷史見 visionA repo:
> - `docs/autoflow/04-architecture/adr/adr-015-server-to-server-api-key.md` v2.1 — 為什麼 visionA → converter 改用 pre-shared API key
> - `docs/autoflow/04-architecture/adr/adr-016-download-via-converter.md` v1.0 — 為什麼 download 改成 converter `/result` 中轉
> - converter 端的設計沿革見本 repo `git log docs/autoflow/04-architecture/design-doc.md`
## 變更歷程
| 日期 | 變更 | 作者 |
|------|------|------|
| 2026-04-25 | 初版 Draft;OAuth resource server + promote 設計 | Architect Agent |
| 2026-04-25 | 原始模型上傳路徑改 multipart 直傳;移除 FAA GET/HEAD 相關 | Architect Agent |
| 2026-05-16 | **Phase 0.8b 重寫**:visionA → converter 改 API key;新增 `GET /api/v1/jobs/:id/result` streaming endpoint;保留 converter → FAA OAuth client(promote 用) | Architect Agent |
---
## 0. 文件導讀
本 Design Doc 聚焦「系統層級架構決策」。工程師實作細節請看 `TDD.md` 索引及其子檔案。
對應文件:
- 產品需求:`../02-prd/PRD.md`
- 使用者流程:`../03-design/user-flow-cross-system.md`
- 設計審閱:`../03-design/design-review.md`
- 專案健檢:`.autoflow/00-onboarding/health-check.md`
- 安全設計:`security.md`
- visionA repo 的 ADR-015 / ADR-016(caller 端設計脈絡)
---
## 1. 背景與範圍 (Context and Scope)
### 1.1 背景
Kneron Model Converter(下稱 Converter)原本是「只有 Web UI 的內部工具」,支援 AI 工程師以 GUI 執行 ONNX → BIE → NEF 三階段模型轉檔。L 級新功能將其擴展為「對外提供 REST API 的服務」,讓 Innovedus 生態中的其他服務(首個消費者為 visionA-backend)能以程式化方式整合轉檔能力。
Phase 1 上線後(2026-04-25 完工、5/9 部署 stage)撞了兩個 root cause、5/16 拍板設計轉向:
1. **5/9 stage e2e blocker**:MC 沒註冊 `converter:job.read/write` scope、converter image 過舊、FAA OAuth 狀態不明 → ADR-015 拍板 visionA ↔ converter 為 1:1 internal trust、改用 pre-shared API key
2. **5/16 grep MC source**:發現 MC 從未實作 `/file-access/download-tokens` endpoint、delegated download token 鏈從 5/2 寫完到現在一直是斷的 → ADR-016 拍板改成 visionA → converter `GET /api/v1/jobs/:id/result` 中轉
### 1.2 生態組成
- **Converter(本專案)**:Node.js Task Scheduler + Python Worker,部署在靠近 NAS 網段
- **visionA-backend**:Go 服務,Converter 對外 API 的唯一 caller(Persona C)
- **Member Center(MC)**:OAuth2 / OIDC authorization server(C#)— Phase 0.8b 後 Converter 對外 API **不再經過 MC**;但 Converter → FAA 仍以 client_credentials 取 token,這條保留
- **File Access Agent(FAA)**:tenant 邊界內檔案閘道(C# / ASP.NET Core),駐守 NAS 側,single-tenant per instance
### 1.3 系統定位
```mermaid
flowchart TB
subgraph AWS["AWS 側"]
VisionAFE["visionA 前端"]
VisionABE["visionA-backend
(Go)"]
MC["Member Center
(只給 converter → FAA
取 promote token)"]
end
subgraph NAS["NAS 側(內部網段)"]
subgraph ConverterNode["Converter 部署節點"]
Nginx["Nginx
(public + internal vhost)"]
Scheduler["Task Scheduler
(Node.js Express)"]
Redis["Redis
(job state + user index)"]
Workers["Workers
(onnx / bie / nef)"]
ConvBucket["Converter Bucket
(MinIO, 7d lifecycle)"]
end
FAA["File Access Agent
(C# ASP.NET Core)"]
NasBucket["NAS Bucket
(模型庫長期儲存)"]
FAA --- NasBucket
end
VisionAFE -->|HTTPS| VisionABE
VisionABE -->|1. POST /api/v1/jobs
multipart
Authorization: Bearer
<API key>| Nginx
Nginx -->|public vhost| Scheduler
Scheduler -->|constant-time
compare API key| Scheduler
Scheduler -->|寫 input| ConvBucket
Scheduler -->|put / get job state| Redis
Workers -->|consume queue| Redis
Workers -->|read input / write output| ConvBucket
VisionABE -->|2. GET /api/v1/jobs/:id
(poll, Bearer API key)| Nginx
VisionABE -->|3. POST /api/v1/jobs/:id/promote
(Bearer API key)| Nginx
Scheduler -->|3a. 取 token
client_credentials| MC
Scheduler -->|3b. PUT 結果檔
(files:upload.write)| FAA
VisionABE -->|4. GET /api/v1/jobs/:id/result
(Bearer API key)
NEW Phase 0.8b| Nginx
Scheduler -->|4a. stream 從 MinIO| ConvBucket
ConvBucket -->|NEF binary stream| Scheduler
Scheduler -->|stream proxy
application/octet-stream| VisionABE
classDef aws fill:#ffe0b2,stroke:#ef6c00
classDef nas fill:#c8e6c9,stroke:#2e7d32
classDef new fill:#bbdefb,stroke:#1565c0
class AWS aws
class NAS nas
class Nginx,Scheduler new
```
**關鍵資料流**(Phase 0.8b 後):
1. **建 job**(visionA-backend → Converter,multipart/form-data)
`POST /api/v1/jobs` 帶 `Authorization: Bearer `。Converter middleware 用 `crypto.timingSafeEqual` constant-time compare API key,通過後把 multipart 寫入 Converter Bucket(`jobs/{job_id}/input/{filename}`)。
2. **轉檔**(Worker pool 順序處理 onnx → bie → nef)
Workers 從 Converter Bucket 讀寫,產出結果寫回 Converter Bucket。
3. **Polling**(visionA-backend → Converter)
每 2-5 秒 `GET /api/v1/jobs/:id`。
4. **Promote 到 NAS**(visionA-backend → Converter → MC → FAA)
`POST /api/v1/jobs/:id/promote` 時 Converter 用既有 OAuth client(cached)取 `files:upload.write` token,PUT 結果檔到 FAA。**這條 OAuth 鏈條保留不變**。
5. **Download**(visionA-backend → Converter,**Phase 0.8b 新增**)
`GET /api/v1/jobs/:id/result` streaming 從 Converter Bucket 把 NEF binary proxy 回 visionA-backend。**不經過 FAA、不經過 MC**。
---
## 2. 目標與非目標 (Goals and Non-Goals)
### Goals(Phase 0.8b 後)
- [ ] 對外 API(visionA → converter)用 pre-shared API key 驗證
- [ ] Converter 仍保留 **OAuth Client** 身分(promote 流程用 client_credentials 取 FAA token)
- [ ] 提供 5 個對外端點:`POST /api/v1/jobs`、`GET /api/v1/jobs`、`GET /api/v1/jobs/:id`、`POST /api/v1/jobs/:id/promote`、`GET /api/v1/jobs/:id/result`(**新增**)
- [ ] `/result` endpoint streaming proxy NEF binary(不 buffer,支援數百 MB)
- [ ] 同使用者同時一個轉檔限制(以 `user_id` 為界)
- [ ] Recovery 支援(`GET /api/v1/jobs?user_id=...&status=in_progress`)
- [ ] 既有 `/jobs/*` 舊路徑保留不動,Web UI 零影響
- [ ] 部署分流:公網只開 `/api/v1/*`,`/jobs/*` 只在內網可達
- [ ] OpenAPI 3.0 規格產出
- [ ] API SLA 可觀測(p95、錯誤率)
### Non-Goals(Phase 0.8b 明確不做)
- [ ] **不再做 OAuth resource server**(visionA → converter 不驗 MC JWT;只有未來真有第二個 caller 才考慮回補)
- [ ] **不做 delegated download token**(MC 沒實作對應 endpoint、ADR-016 改成 `/result` 中轉)
- [ ] Webhook / SSE 對外推送(polling + `/result` 已足夠)
- [ ] Job 取消 / 重試(非本次範圍)
- [ ] Job 持久化 / 跨 Crash recovery(維持「Crash 即 Reset」哲學)
- [ ] Web UI 改走 API key 流程(內網工具,不動)
- [ ] 使用者層級 ACL(責任邊界在 visionA-backend)
- [ ] 多租戶(single-tenant per Converter deployment)
---
## 3. 架構設計 (The Actual Design)
### 3.1 架構模式選擇
- **選擇**:維持現有 **單體 Task Scheduler(Node.js Express)+ Worker Pool(Python)** 架構
- **理由**:
1. Phase 0.8b 改動範圍是「換 auth middleware + 加一個 streaming endpoint」,不足以撐起新服務的運維複雜度
2. 既有 Crash 即 Reset 哲學對單體有利:Scheduler stateless,重啟 = 復原
- **取捨**:
- Scheduler 單體承擔 download proxy 的網路 I/O;NEF 通常 < 50MB,stream 模式下記憶體足跡受 Node fetch / HTTP buffer 控制(不會 buffer 整個檔)
- 若未來 download QPS 高,可在 Nginx 層加 sendfile / proxy_cache,或把 `/result` 拆出獨立微服務(Phase 1 不做)
### 3.2 Auth 策略:純 API key(1:1 internal trust)
#### 為什麼從 OAuth 改 API key
| 維度 | OAuth client_credentials | Pre-shared API key |
|------|--------------------------|---------------------|
| 跨團隊依賴 | 需要 MC 註冊 audience / client / scope | 雙方協議好 secret 即可 |
| 部署阻塞 | 5/9 stage 撞到 MC 沒註冊 scope | 無 |
| trust model | 多 caller、scope-based authorization | 1:1 internal trust,full access |
| code 複雜度 | JWKS cache + JWT verify + scope check | constant-time string compare |
| Token rotation | MC 端管理 | 雙方手動同步 .env |
| 適用情境 | 多 caller、跨組織 | 單一 caller、同組織 |
當前 visionA ↔ converter 是 1:1 internal trust(同公司、單一 caller),OAuth 是 over-engineering。
#### API key middleware 設計
- **接受**:`Authorization: Bearer `(重用既有 Bearer header 格式,client / log infra 不變)
- **比對**:`crypto.timingSafeEqual` constant-time compare(防 timing attack)
- **失敗行為**:401 `invalid_token`、回應後主動 `socket.destroy()`(沿用既有 OAuth middleware 的 M2 行為,防大檔 body 繼續灌入)
- **req.auth shape**:通過後設定 `req.auth = { sub: 'visionA-service', clientId: 'visionA-service', tenantId: null, scopes: ['converter:job.write', 'converter:job.read'], raw: { authType: 'api_key' } }`,下游 handler / rate limiter / log 不需大改
- **Fail-fast**:啟動時 `CONVERTER_API_KEY` 未設定 → 直接 503 拒絕所有 request(不 silently allow)
詳細實作見 `auth.md` §1。
#### Trust boundary 簡化
| 風險 | OAuth 模型 | API key 模型 |
|------|-----------|-------------|
| caller 被 compromise → 冒充任意 user_id | 需要 OBO / HMAC-signed user_id 緩解 | 同樣風險、但 API key 本身就是 visionA 服務的完整身分證明、不需要 OBO |
| Token / Key rotation | MC 介面管理 | 手動 rotate 雙端 .env + redeploy |
→ API key 沒有比 OAuth 更安全(trust boundary 模型一致),但**也沒有更不安全**。差別只在 rotation 操作複雜度。
詳見 `security.md`。
### 3.3 `/result` endpoint:streaming proxy
#### 為什麼需要
ADR-016 §1:MC 沒實作 `POST /file-access/download-tokens`、FAA 的 `MemberCenterDelegatedDownloadTokenValidator` 從來沒跑通。delegated download token 鏈在 Phase 1 設計時就是斷的、只是因為從未 e2e 過所以沒人發現。
→ visionA 直接 download FAA 不可行(少了 delegated token endpoint)。改設計成 visionA → converter 拿 NEF。
#### 架構位置
```
visionA-backend Converter Scheduler MinIO
(Converter Bucket)
GET /jobs/:id/result
Authorization: Bearer
────────────────────→
requireApiKey middleware
────────────────────────
getJob from Redis
────────────────────────
check status / expires_at
────────────────────────
extractNefObjectKey
────────────────────────
minio.getObjectStream(nefKey)
─────────────────────────────────→
stream
←─────────────────────────────────
set headers:
Content-Type: application/octet-stream
Content-Length: ...
Content-Disposition: attachment;
filename="_.nef"
pipe stream → response
←──────────────────────── (streaming NEF binary)
```
#### 關鍵設計決定
1. **不 buffer 整個檔**:用 Node stream `pipe(res)`,NEF 可能數百 MB
2. **Content-Length 必須帶**:visionA 端用來決定 timeout
3. **Filename 規則**:`_.nef`(例:`yolov5s.onnx` + `KL720` → `yolov5s_kl720.nef`);fallback `job_.nef`
4. **雙路徑 NEF key 解析**:支援新格式(`result_object_keys.nef`)+ 舊格式(`output.nef_path`),對齊 promote 流程的 `getJobOutputKey` 邏輯
5. **4xx 情境**:401(invalid API key)/ 404(job_not_found)/ 409(job_not_completed)/ 410(result_expired,過 7 天)/ 502(storage_unavailable)/ 503(service_unavailable)
6. **Stream error handling**:headers 送出後 stream 失敗 → 只能 `res.destroy()`、client 看到 ECONNRESET;client `req.on('close')` → 主動釋放 MinIO stream connection
詳細 API spec 見 `api/api-result.md`。
### 3.4 Phase 0.8b 改動範圍總覽
| 元件 | 改動 |
|------|------|
| Nginx | **不動**(既有 public vhost 已 proxy `/api/v1/*`) |
| Task Scheduler — auth | **大改**:新增 `apiKeyMiddleware`、移除 `auth/middleware.js` (OAuth) / `auth/jwks.js`(但保留 `auth/oauthClient.js`,promote 用) |
| Task Scheduler — routes | **小改**:`POST /jobs` / `GET /jobs` / `GET /jobs/:id` / `POST /jobs/:id/promote` 改掛 `requireApiKey()`;新增 `/jobs/:id/result` 路由 + handler |
| Task Scheduler — config | 移除 `MEMBER_CENTER_ISSUER` / `MEMBER_CENTER_JWKS_URL` / `KNERON_CONVERTER_AUDIENCE` / `JWKS_*`;保留 `MEMBER_CENTER_TOKEN_URL` / `KNERON_CONVERTER_CLIENT_ID` / `KNERON_CONVERTER_CLIENT_SECRET` / `FILE_ACCESS_AGENT_*`(promote 用);新增 `CONVERTER_API_KEY` |
| Redis 資料模型 | **不動** |
| Workers | **不動** |
| MinIO(Converter Bucket) | **不動** |
| FAA / MC | **不動**(converter → FAA 仍走 OAuth client_credentials) |
### 3.5 技術選型(Technology Radar)
| 層級 | 技術選擇 | 狀態 | 選型理由 | 退出成本 |
|------|---------|------|---------|---------|
| Auth — API key compare | `crypto.timingSafeEqual` | Adopt | Node 標準庫,constant-time | 低 |
| Auth — 取 FAA token(promote) | 自寫 OAuth client + Basic auth + in-memory cache | Adopt | 既有實作、Phase 1 已驗證 | 低 |
| HTTP client 對 FAA(promote) | Node 18 原生 fetch + stream | Adopt | 支援大檔 stream | 低 |
| Stream proxy(`/result`) | Node Stream `pipe(res)` | Adopt | 標準做法,記憶體足跡受控 | 低 |
| Rate Limit | `express-rate-limit`(既有)+ per-client_id key | Adopt | 既有套件 | 低 |
| OpenAPI 產出 | 手寫 YAML | Adopt | 規格穩定、人工可審 | 低 |
| Redis 索引 | Redis Set(`user:{user_id}:jobs`)+ `job:{id}` | Adopt | 不引入 PG | 中 |
### 3.6 API 設計概覽
- **API 風格**:REST + JSON(`/result` 例外,回傳 binary stream)
- **Base Path**:`/api/v1/*`
- **認證**:所有端點(除 `/health`)都要 `Authorization: Bearer `
- **錯誤格式**:統一 `{error: {code, message, details, request_id}}`
- **版本策略**:breaking change 走 `/api/v2/*`,小變更在 `/api/v1/*` 內向後相容新增欄位
- **Rate Limit**:以 `client_id`(API key 模式下固定 `visionA-service`)為 key,預設 300 req / 5min
- **ETag 支援**:`GET /api/v1/jobs/:id` 支援 `If-None-Match`,304 Not Modified 省流量
詳細 API spec 見 `TDD.md` 索引 + `api/*.md` 子檔案。
### 3.7 資料架構
#### 核心資料(Redis)
| Key | 類型 | 內容 | TTL |
|-----|------|------|-----|
| `job:{job_id}` | String (JSON) | Job 完整 record(含 `user_id`、`tenant_id`、`created_by_client_id`、`metadata`、`stage_timings`、`expires_at`、`result_object_keys`、`promoted_object_keys`) | 7 天 |
| `user:{user_id}:jobs` | Set | 該 user 的 job_id 集合 | 隨最新 job 延長,建議 7 天 |
| `user:{user_id}:active_job` | String | 當前 in-progress job_id | 隨 job 完成時刪除 |
| `ratelimit:client:{client_id}` | 由 `express-rate-limit` 管理 | — | 5 min |
不引入 PostgreSQL:Phase 1 資料量小(單 user 7 天內 < 10 個 job),Redis 足以承擔。
#### 資料流(Phase 0.8b)
```mermaid
flowchart LR
BE[visionA-backend] -->|1. POST /api/v1/jobs
multipart + Bearer API key| Sched[Scheduler]
Sched -->|2. constant-time
compare API key| Sched
Sched -->|3. 檢查 active_job| Redis[(Redis)]
Sched -->|4. multer 寫 input| CB[(Converter Bucket)]
Sched -->|5. 建 job + 索引| Redis
Sched -->|6. enqueue onnx| Redis
Workers[Workers] -->|consume| Redis
Workers -->|read/write| CB
Workers -->|done event| Redis
BE -->|7. poll GET /api/v1/jobs/:id| Sched
Sched -->|8. 讀 job| Redis
BE -->|9. POST /promote| Sched
Sched -->|10. 取 FAA token
(client_credentials, cache)| MC[Member Center]
Sched -->|11. 讀結果| CB
Sched -->|12. PUT 結果| FAA[File Access Agent]
BE -->|13. GET /jobs/:id/result
NEW Phase 0.8b| Sched
Sched -->|14. stream NEF| CB
CB -->|15. NEF binary| Sched
Sched -->|16. stream proxy| BE
```
---
## 4. 可靠性設計 (Reliability)
### 4.1 SLI / SLO
| 服務 | SLI | SLO | 依據 |
|------|-----|-----|------|
| `/api/v1/*` 可用率 | 2xx+3xx 請求 / 總請求 | ≥ 99.5%(工作時段)| PRD §9.2.1 |
| `GET /api/v1/jobs/:id` p95 | 回應時間 95 百分位 | < 200ms | PRD §9.2.1 |
| `POST /api/v1/jobs` p95 | multipart 上傳到 MinIO 完成 | < 5s(200MB)/ < 12s(500MB)| 詳見 `performance.md` |
| `POST /api/v1/jobs/:id/promote` p95 | 回應時間 95 百分位 | < 3s | PRD §9.2.1 |
| `GET /api/v1/jobs/:id/result` p95 | TTFB(time to first byte) | < 500ms | Phase 0.8b 新增;NEF 50MB stream 在 50MB/s 鏈路下完整下載 ≈ 1s |
| API key 驗證失敗率 | 401 / 總請求 | < 0.1%(同 caller 不該錯)| Phase 0.8b 新增 |
### 4.2 容錯設計
| 失敗情境 | 設計應對 |
|---------|---------|
| MC token endpoint 不可達(取 Converter 自己的 FAA token)| Token cache(有效期內不重取);過期後重試 3 次,失敗則 promote 回 503 + `auth_service_unavailable` |
| multipart 上傳失敗 / 超過 500MB | `POST /api/v1/jobs` 回 400 / 413 |
| FAA promote 失敗 | Converter Bucket 檔案保留 7 天,可重試 promote |
| `/result` MinIO stream 失敗(過期清除)| 410 `result_expired` |
| Worker Crash | 既有 Crash 即 Reset;Worker 重啟後繼續 consume Redis Stream |
| Redis Crash | 符合「Crash 即 Reset」設計哲學,所有 job 遺失 |
| Scheduler Crash | 重啟後繼續服務 |
### 4.3 災難復原
維持既有 **Crash 即 Reset**:RPO = 無保證、RTO < 30s(Docker restart)。
---
## 5. 安全架構 (Security)
詳見 `security.md`。本節僅列高層原則。
### 5.1 威脅模型(STRIDE 摘要,Phase 0.8b 版)
| 威脅 | 風險 | 防護 |
|------|------|------|
| Spoofing(偽造 visionA-backend)| 中 | API key constant-time compare;TLS protected 傳輸 |
| Spoofing(偽造 user_id)| 中(接受)| 信任 visionA-backend 的 user_id;Converter 不做 user ACL |
| Tampering(改 job record)| 低 | Redis 在內部網段 |
| Repudiation(否認呼叫)| 中 | Log `client_id` + `request_id` + `user_id`,保留 30 天 |
| Info Disclosure(跨 client 看別人的 job)| 低(Phase 0.8b 只有 1 個 caller)| Job 查詢預設過濾 `created_by_client_id` |
| DoS | 中 | Rate Limit per `client_id`;檔案大小上限 500MB |
| Elevation of Privilege | **降低**(API key 沒有 scope,但只有 1 個 caller) | API key 即為完整 caller 身分 |
### 5.2 API key 管理
- 產生:`openssl rand -hex 32`(64 hex chars)
- 部署:放 .env / docker-compose env / k8s secret
- 雙端對齊:visionA `VISIONA_CONVERTER_API_KEY` = converter `CONVERTER_API_KEY`
- Rotation 策略:每環境獨立(dev / stage / prod);外洩時雙端同步 rotate + redeploy
- **絕不**進 git / Slack / email / log(log 只記 `api_key_length` 或 `api_key_set: true` boolean)
### 5.3 部署分流
維持既有設計:Nginx 雙 vhost(public `/api/v1/*` + internal `/jobs/*`)。詳見 `infra.md`。
---
## 6. 效能工程 (Performance)
詳見 `performance.md`。重點:
- `POST /api/v1/jobs` p95 < 5s(200MB) / < 12s(500MB)
- `GET /api/v1/jobs/:id` p95 < 200ms
- `GET /api/v1/jobs/:id/result` TTFB < 500ms(Phase 0.8b 新增 SLO)
- `POST /api/v1/jobs/:id/promote` p95 < 3s
---
## 7. 部署架構
詳見 `infra.md`。重點:
- Nginx 雙 vhost(`/api/v1/*` public、`/jobs/*` internal)— **不動**
- 環境變數變動:移除 `MEMBER_CENTER_ISSUER` / `JWKS_URL` / `KNERON_CONVERTER_AUDIENCE` / `JWKS_*`;新增 `CONVERTER_API_KEY`;保留 `MEMBER_CENTER_TOKEN_URL` / `KNERON_CONVERTER_CLIENT_*` / `FILE_ACCESS_AGENT_*`
- 部署順序:converter 先 deploy(並存舊 OAuth + 新 API key 一段時間)→ verify `/result` → visionA deploy → e2e → 砍 OAuth 殘留
---
## 8. ADR(架構決策紀錄)
### ADR-001:對外 API 採 Member Center OAuth2(已 superseded)
**狀態**:Superseded by ADR-010(Phase 0.8b)
**原因**:5/9 stage e2e 撞 MC 沒註冊 scope;visionA ↔ converter 為 1:1 internal trust、OAuth 為 over-engineering。
→ visionA repo `adr-015-server-to-server-api-key.md` v2.1 記錄完整轉向理由。
---
### ADR-002:promote 結果檔採「做法 2」— Converter 自己推到 FAA
**狀態**:Accepted(**不變**,Phase 0.8b 保留)
**範圍**:本 ADR 只針對結果檔 promote 的搬檔路徑;原始模型不會進 FAA。Converter → FAA 仍走 OAuth client_credentials + `files:upload.write` scope。
詳細決策內容沿用前版(5/2 寫入)。
---
### ADR-003:user_id 以 multipart 欄位傳遞
**狀態**:Accepted(**不變**,Phase 0.8b 保留)
API key 模式下,user_id 仍是 visionA-backend 傳的 multipart field。Trust boundary 假設不變(visionA-backend 內部受控)。
---
### ADR-004:Polling 而非 Webhook
**狀態**:Accepted(**不變**)
---
### ADR-005:Phase 1 使用者下載改用 `/result` 中轉(**取代原 delegated download token 設計**)
**狀態**:Accepted(Phase 0.8b 拍板,取代原 ADR-005「Phase 1 使用者下載延至 Phase 2」)
**背景**:5/16 grep MC source 發現 MC 從未實作 `/file-access/download-tokens` endpoint;FAA 的 `MemberCenterDelegatedDownloadTokenValidator.cs` 假設 MC 有對應 introspection endpoint,也是假設錯了。delegated download token 鏈從 5/2 寫完到現在一直是斷的。
**決定**:不動 MC、不動 FAA。改設計成 visionA → converter `GET /api/v1/jobs/:id/result` 中轉。
**理由**:
1. MC owner 時程不可控,延 Phase 2 不可行
2. NEF 通常 < 50MB,streaming proxy 對 Converter Scheduler 不重
3. 走 converter 而非 FAA,與 `/promote` 同一個 caller 介面,visionA 端 client 邏輯統一
**代價**:
- Converter Scheduler 多扛一條 download 路徑(streaming,記憶體足跡受控)
- NEF 在 Converter Bucket 7 天 TTL 後過期,client 需處理 410 `result_expired`
- 雙存(promote 後 NEF 仍在 Converter Bucket 7 天 + FAA NAS Bucket 永久),下載走 Converter Bucket、不下載 FAA
**替代方案**:詳見 visionA repo `adr-016-download-via-converter.md` v1.0 §3(6 個方案完整分析)。
---
### ADR-006:Phase 1 Web UI 不改
**狀態**:Accepted(**不變**)
Web UI 仍走 `/jobs/*` 路徑、無 auth。Phase 0.8b 不動。
---
### ADR-010:visionA → converter 改用 pre-shared API key(**Phase 0.8b 新增**)
**狀態**:Accepted(2026-05-09 + 2026-05-16 雙重 user 拍板)
**背景**:
1. 5/9 stage e2e 撞 MC 沒註冊 `converter:job.read/write` scope
2. converter image 過舊、缺 OAuth middleware
3. FAA OAuth 整合狀態不明
4. visionA ↔ converter 是 1:1 internal trust,OAuth 過度設計
**決定**:visionA → converter 改用 pre-shared API key(`CONVERTER_API_KEY`),constant-time compare。
**理由**:
1. 砍跨團隊依賴(不需要 MC 註冊任何東西)
2. visionA 是當前唯一 caller,OAuth 的 scope-based authorization 沒用上
3. API key 本身已是 visionA 服務的完整身分證明,scope 概念對 1:1 trust 無意義
**代價**:
- 砍掉 `auth/middleware.js` (OAuth resource server)、`auth/jwks.js`、相關 unit test
- 砍 `MEMBER_CENTER_ISSUER` / `JWKS_URL` / `KNERON_CONVERTER_AUDIENCE` / `JWKS_*` env
- 失去 scope-based fine-grained authorization(接受:1:1 trust 不需要)
- 失去多 caller 擴展彈性(未來真有第二個 caller 再加 OAuth 回來)
**保留**:
- Converter → FAA 仍走 OAuth client_credentials(`files:upload.write` scope)→ `MEMBER_CENTER_TOKEN_URL` / `KNERON_CONVERTER_CLIENT_*` 保留
- Promote 流程完全不動
**替代方案**:詳見 visionA repo `adr-015-server-to-server-api-key.md` v2.1 §3。
**保留設計脈絡的歷史記錄**:本 ADR 取代原 ADR-001。git history + visionA repo ADR-015 v2.1 是完整 audit trail。
---
### ADR-011:`/result` endpoint 採 streaming proxy(**Phase 0.8b 新增**)
**狀態**:Accepted(2026-05-16 user 拍板)
**背景**:見 ADR-005 superseded 改用 `/result` 中轉。
**決定**:`GET /api/v1/jobs/:id/result` 用 Node Stream `pipe(res)` 把 MinIO `GetObjectStream` 直接 proxy 回 caller,不 buffer。
**理由**:
1. NEF 可能數百 MB(極端情境),buffer 整個檔會 OOM
2. Stream 模式下 Scheduler 記憶體足跡受 Node fetch / HTTP buffer 控制(typical < 64KB per request)
3. Client(visionA)可以一邊收一邊處理,TTFB < 500ms 比完整下載時間短
**代價**:
- Stream error handling 較複雜(headers 已送出後 stream 中斷只能 `res.destroy()`)
- Content-Length 必須在 stream 開始前算好(從 MinIO HEAD 取)
**替代方案**:
- A. Buffer 整個 NEF 再回(簡單但會 OOM)— 排除
- B. Redirect 到 MinIO presigned URL(簡單但 MinIO 暴露公網風險)— 排除
- C. Stream proxy(選擇)
---
## 9. 風險與待確認事項
| # | 風險 / 議題 | 影響 | 行動 |
|---|-----------|------|------|
| R1 | CONVERTER_API_KEY rotation 流程未自動化 | 低 | Phase 1 接受手動 rotation;外洩時雙端同步改 .env + redeploy |
| R2 | `/result` 高並發 stream 壓力 | 低 | NEF 通常小、visionA 是唯一 caller、QPS 可控;觀測後再加防護 |
| R3 | visionA 一旦被 compromise 可冒充任意 user_id | 中(接受)| 同 OAuth 模型,本質 trust boundary 不變;audit log + anomaly detection 為主要 mitigation |
| R4 | Sec C1 暫緩(.env 進 git history)| 中 | Phase 1 ready 後做 history rewrite + rotate 所有 secret,包括 CONVERTER_API_KEY |
| R5 | 大檔 multipart OOM(多 user 並發)| 中 | 既有 `user_has_active_job` 鎖 + `MAX_CONCURRENT_UPLOADS` semaphore |
| R6 | NEF 7 天過期後 client 重新轉檔 | 低 | API spec 已定義 410 `result_expired`,visionA 端處理 |
---
## 10. Phase 0.8b 切分(架構層)
### Phase 0.8b 必做
- API key middleware(`auth/apiKeyMiddleware.js`)
- 砍 OAuth resource server(`auth/middleware.js` / `auth/jwks.js`)
- 保留 OAuth client(`auth/oauthClient.js`,promote 用)
- `/api/v1/jobs/:id/result` endpoint
- Config 變動(移除 OAuth resource server 相關、新增 `CONVERTER_API_KEY`)
- 4 個既有 endpoint 改掛 `requireApiKey()`(取代 `requireAuth(scope)`)
- README / OpenAPI / .env.example 同步更新
### Phase 2 預留(不在本次範圍)
- `DELETE /api/v1/jobs/:id`(仍回 501)
- `POST /api/v1/jobs/:id/download-tokens`(仍回 501,未來 MC 補完再啟用)
- Webhook
- 觀測強化(Prometheus / OpenTelemetry)
### 觸發條件
- Phase 0.8b:三方交叉審閱通過 + 使用者審核 + Backend 完成實作 → Reviewer → Testing → 部署
- Phase 2:取決於 visionA 後續需求 + MC owner 時程
---
## 11. 後續步驟
1. 本 Design Doc 送 PM / Design 交叉審閱
2. 使用者審核最終版
3. Backend Agent 依 `TDD.md` 索引 + `auth.md` + `api/api-result.md` 的任務拆分(Phase A 6 個子任務 + Phase B 4 個子任務)增量開發
4. Reviewer 每個任務把關 + Testing 整合測試
5. 雙端對齊部署(converter 先 → visionA 後 → e2e)
---
**附註**:本 Design Doc 約 470 行,未超過拆分門檻。詳細 TDD 內容拆分為 `TDD.md` 索引 + `auth.md` + `api/*.md` + `database.md` + `infra.md` + `performance.md` + `observability.md` 等子檔案。