jim800121chen d8a9517c9d feat(task-scheduler): Phase 0.8b — API key auth + /result endpoint
Auth pillar 從 OAuth 2.0 resource server 改成 pre-shared API key
(visionA ↔ converter 1:1 internal trust)。新增 GET /api/v1/jobs/:id/result
streaming endpoint 給 visionA backend 中轉 NEF 下載。

Phase A(auth 切換):
- 新增 apiKeyMiddleware(constant-time compare、tokenFingerprint、4 audit events)
- 砍 OAuth middleware + JWKS(保留 oauthClient 供 promote → FAA 使用)
- 4 個 endpoint 換掛 requireApiKey
- 加 TRUST_PROXY env + Express trust proxy 設定(forensic source_ip)

Phase B(/result endpoint):
- streaming NEF download with 5min timeout + concurrent cap 10
- Two-tier rate limit(burst 5/10s + sustained 20/min)
- Bandwidth quota(1 GB/hr + 6 GB/24hr)by token_fingerprint
- Range header silently ignored + Accept-Ranges: none
- filename quote-escape + RFC 5987 fallback + sanitize
- 8 個 /result audit events(forensic 完整)

設計演進記錄:docs/TODO-visionA-integration-v2.md(5/2 OAuth → 5/16 API key
→ 5/16 download via converter;對應 visionA repo ADR-015/016)

Tests: 597 → 666 (+69)、29 suites all pass
Security: APPROVE WITH CONDITIONS(單 instance 部署、6 新 env、24hr 監控)
npm audit: 3 vuln → 0(transitive AWS SDK xml chain)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:47:28 +08:00

578 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Design Doc — Kneron Model Converter 對外 API
## 作者Architect Agent
## 狀態DraftPhase 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 | 初版 DraftOAuth 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 clientpromote 用) | 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-016caller 端設計脈絡)
---
## 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 的唯一 callerPersona C
- **Member CenterMC**OAuth2 / OIDC authorization serverC#)— Phase 0.8b 後 Converter 對外 API **不再經過 MC**;但 Converter → FAA 仍以 client_credentials 取 token這條保留
- **File Access AgentFAA**tenant 邊界內檔案閘道C# / ASP.NET Core駐守 NAS 側single-tenant per instance
### 1.3 系統定位
```mermaid
flowchart TB
subgraph AWS["AWS 側"]
VisionAFE["visionA 前端"]
VisionABE["visionA-backend<br/>(Go)"]
MC["Member Center<br/>(只給 converter → FAA<br/>取 promote token)"]
end
subgraph NAS["NAS 側(內部網段)"]
subgraph ConverterNode["Converter 部署節點"]
Nginx["Nginx<br/>(public + internal vhost)"]
Scheduler["Task Scheduler<br/>(Node.js Express)"]
Redis["Redis<br/>(job state + user index)"]
Workers["Workers<br/>(onnx / bie / nef)"]
ConvBucket["Converter Bucket<br/>(MinIO, 7d lifecycle)"]
end
FAA["File Access Agent<br/>(C# ASP.NET Core)"]
NasBucket["NAS Bucket<br/>(模型庫長期儲存)"]
FAA --- NasBucket
end
VisionAFE -->|HTTPS| VisionABE
VisionABE -->|1. POST /api/v1/jobs<br/>multipart<br/>Authorization: Bearer<br/>&lt;API key&gt;| Nginx
Nginx -->|public vhost| Scheduler
Scheduler -->|constant-time<br/>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<br/>(poll, Bearer API key)| Nginx
VisionABE -->|3. POST /api/v1/jobs/:id/promote<br/>(Bearer API key)| Nginx
Scheduler -->|3a. 取 token<br/>client_credentials| MC
Scheduler -->|3b. PUT 結果檔<br/>(files:upload.write)| FAA
VisionABE -->|4. GET /api/v1/jobs/:id/result<br/>(Bearer API key)<br/>NEW Phase 0.8b| Nginx
Scheduler -->|4a. stream 從 MinIO| ConvBucket
ConvBucket -->|NEF binary stream| Scheduler
Scheduler -->|stream proxy<br/>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 → Convertermultipart/form-data
`POST /api/v1/jobs``Authorization: Bearer <CONVERTER_API_KEY>`。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 clientcached`files:upload.write` tokenPUT 結果檔到 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)
### GoalsPhase 0.8b 後)
- [ ] 對外 APIvisionA → 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-GoalsPhase 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 SchedulerNode.js Express+ Worker PoolPython** 架構
- **理由**
1. Phase 0.8b 改動範圍是「換 auth middleware + 加一個 streaming endpoint」不足以撐起新服務的運維複雜度
2. 既有 Crash 即 Reset 哲學對單體有利Scheduler stateless重啟 = 復原
- **取捨**
- Scheduler 單體承擔 download proxy 的網路 I/ONEF 通常 < 50MBstream 模式下記憶體足跡受 Node fetch / HTTP buffer 控制不會 buffer 整個檔
- 若未來 download QPS 可在 Nginx 層加 sendfile / proxy_cache或把 `/result` 拆出獨立微服務Phase 1 不做
### 3.2 Auth 策略:純 API key1: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 | callerscope-based authorization | 1:1 internal trustfull access |
| code 複雜度 | JWKS cache + JWT verify + scope check | constant-time string compare |
| Token rotation | MC 端管理 | 雙方手動同步 .env |
| 適用情境 | caller跨組織 | 單一 caller同組織 |
當前 visionA converter 1:1 internal trust同公司單一 callerOAuth over-engineering
#### API key middleware 設計
- **接受**`Authorization: Bearer <CONVERTER_API_KEY>`重用既有 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` endpointstreaming proxy
#### 為什麼需要
ADR-016 §1MC 沒實作 `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
<CONVERTER_API_KEY>
────────────────────→
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="<stem>_<chip>.nef"
pipe stream → response
←──────────────────────── (streaming NEF binary)
```
#### 關鍵設計決定
1. **不 buffer 整個檔** Node stream `pipe(res)`NEF 可能數百 MB
2. **Content-Length 必須帶**visionA 端用來決定 timeout
3. **Filename 規則**`<source_filename_stem>_<chip>.nef``yolov5s.onnx` + `KL720` `yolov5s_kl720.nef`fallback `job_<jobID>.nef`
4. **雙路徑 NEF key 解析**支援新格式`result_object_keys.nef`+ 舊格式`output.nef_path`對齊 promote 流程的 `getJobOutputKey` 邏輯
5. **4xx 情境**401invalid API key/ 404job_not_found/ 409job_not_completed/ 410result_expired 7 / 502storage_unavailable/ 503service_unavailable
6. **Stream error handling**headers 送出後 stream 失敗 只能 `res.destroy()`client 看到 ECONNRESETclient `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 | **不動** |
| MinIOConverter Bucket | **不動** |
| FAA / MC | **不動**converter FAA 仍走 OAuth client_credentials |
### 3.5 技術選型Technology Radar
| 層級 | 技術選擇 | 狀態 | 選型理由 | 退出成本 |
|------|---------|------|---------|---------|
| Auth API key compare | `crypto.timingSafeEqual` | Adopt | Node 標準庫constant-time | |
| Auth FAA tokenpromote | 自寫 OAuth client + Basic auth + in-memory cache | Adopt | 既有實作Phase 1 已驗證 | |
| HTTP client FAApromote | 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 <CONVERTER_API_KEY>`
- **錯誤格式**統一 `{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 |
不引入 PostgreSQLPhase 1 資料量小 user 7 天內 < 10 jobRedis 足以承擔
#### 資料流Phase 0.8b
```mermaid
flowchart LR
BE[visionA-backend] -->|1. POST /api/v1/jobs<br/>multipart + Bearer API key| Sched[Scheduler]
Sched -->|2. constant-time<br/>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<br/>(client_credentials, cache)| MC[Member Center]
Sched -->|11. 讀結果| CB
Sched -->|12. PUT 結果| FAA[File Access Agent]
BE -->|13. GET /jobs/:id/result<br/>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 完成 | < 5s200MB/ < 12s500MB| 詳見 `performance.md` |
| `POST /api/v1/jobs/:id/promote` p95 | 回應時間 95 百分位 | < 3s | PRD §9.2.1 |
| `GET /api/v1/jobs/:id/result` p95 | TTFBtime 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 ResetWorker 重啟後繼續 consume Redis Stream |
| Redis Crash | 符合Crash Reset設計哲學所有 job 遺失 |
| Scheduler Crash | 重啟後繼續服務 |
### 4.3 災難復原
維持既有 **Crash 即 Reset**RPO = 無保證、RTO < 30sDocker restart)。
---
## 5. 安全架構 (Security)
詳見 `security.md`本節僅列高層原則
### 5.1 威脅模型STRIDE 摘要Phase 0.8b 版)
| 威脅 | 風險 | 防護 |
|------|------|------|
| Spoofing偽造 visionA-backend| | API key constant-time compareTLS protected 傳輸 |
| Spoofing偽造 user_id| 接受| 信任 visionA-backend user_idConverter 不做 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 / loglog 只記 `api_key_length` `api_key_set: true` boolean
### 5.3 部署分流
維持既有設計Nginx vhostpublic `/api/v1/*` + internal `/jobs/*`)。詳見 `infra.md`
---
## 6. 效能工程 (Performance)
詳見 `performance.md`重點
- `POST /api/v1/jobs` p95 < 5s200MB / < 12s500MB
- `GET /api/v1/jobs/:id` p95 < 200ms
- `GET /api/v1/jobs/:id/result` TTFB < 500msPhase 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-010Phase 0.8b
**原因**5/9 stage e2e MC 沒註冊 scopevisionA converter 1:1 internal trustOAuth over-engineering
visionA repo `adr-015-server-to-server-api-key.md` v2.1 記錄完整轉向理由
---
### ADR-002promote 結果檔採「做法 2」— Converter 自己推到 FAA
**狀態**Accepted**不變**Phase 0.8b 保留
**範圍** ADR 只針對結果檔 promote 的搬檔路徑原始模型不會進 FAAConverter FAA 仍走 OAuth client_credentials + `files:upload.write` scope
詳細決策內容沿用前版5/2 寫入)。
---
### ADR-003user_id 以 multipart 欄位傳遞
**狀態**Accepted**不變**Phase 0.8b 保留
API key 模式下user_id 仍是 visionA-backend 傳的 multipart fieldTrust boundary 假設不變visionA-backend 內部受控)。
---
### ADR-004Polling 而非 Webhook
**狀態**Accepted**不變**
---
### ADR-005Phase 1 使用者下載改用 `/result` 中轉(**取代原 delegated download token 設計**
**狀態**AcceptedPhase 0.8b 拍板取代原 ADR-005Phase 1 使用者下載延至 Phase 2」)
**背景**5/16 grep MC source 發現 MC 從未實作 `/file-access/download-tokens` endpointFAA `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 通常 < 50MBstreaming 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 §36 個方案完整分析)。
---
### ADR-006Phase 1 Web UI 不改
**狀態**Accepted**不變**
Web UI 仍走 `/jobs/*` 路徑 authPhase 0.8b 不動
---
### ADR-010visionA → converter 改用 pre-shared API key**Phase 0.8b 新增**
**狀態**Accepted2026-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 trustOAuth 過度設計
**決定**visionA converter 改用 pre-shared API key`CONVERTER_API_KEY`constant-time compare
**理由**
1. 砍跨團隊依賴不需要 MC 註冊任何東西
2. visionA 是當前唯一 callerOAuth 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-001git history + visionA repo ADR-015 v2.1 是完整 audit trail
---
### ADR-011`/result` endpoint 採 streaming proxy**Phase 0.8b 新增**
**狀態**Accepted2026-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. ClientvisionA可以一邊收一邊處理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 是唯一 callerQPS 可控觀測後再加防護 |
| 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` 等子檔案