From dab13ed9840aebce8be4e3b62989474bfd4a8844 Mon Sep 17 00:00:00 2001 From: jim800121chen Date: Sat, 16 May 2026 12:30:46 +0800 Subject: [PATCH] =?UTF-8?q?docs(autoflow):=20ADR-016=20=E2=80=94=20visionA?= =?UTF-8?q?=20download=20=E6=94=B9=E8=B5=B0=20converter=20GetResult?= =?UTF-8?q?=EF=BC=8C=E6=92=A4=E5=9B=9E=20FAA=20delegated=20token=20?= =?UTF-8?q?=E9=8F=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 致命發現(grep MC + FAA source 確認): - MC source 沒有 issue delegated download token endpoint - MC source 沒有 validate delegated download token endpoint - FAA MemberCenterDelegatedDownloadTokenValidator.cs 假設的 MC introspection endpoint 不存在 - ADR-014 §2 從 5/2 寫完到現在這條鏈一直是斷的、只是因為從未實際 e2e 跑通過所以沒被發現 使用者拍板硬約束:不動 MC + 不動 FAA 新增 ADR-016: - visionA download 改用 converter GET /api/v1/jobs/{id}/result(新 endpoint) - visionA backend 用既有 ConverterAPIKey 認證(不需新增 secret) - 維持 T4 已實作的 stream proxy 結構(io.CopyN + Content-Disposition + size cap) - promote 仍 PUT FAA(converter 內部用自己的 OAuth、與 visionA 無關) - 不需動 MC + FAA + warrenchen - 6 個替代方案逐一說明排除理由 修訂既有文件: - ADR-014 v1.1 → v1.2:§2 download flow 標註被 ADR-016 部分 supersede - ADR-015 v2.0 → v2.1:§2 visionA → FAA delegated token 設計(v2.0 從 v1.x 撤回的設計)再次撤回;§9 env 表撤回 v2.0 加回的 OIDC ServiceClient* / TenantID / FAABaseURL;visionA 端 server-to-server 只剩 ConverterAPIKey 一把 - conversion.md v0.5 → v0.6:§1 sequence diagram 重畫(移除 MC node)、§2 模組設計(mc_token_client.go 整檔刪除確認、faa_client.go 改名 converter_result_client.go)、§3.2 visionA → FAA 整段標撤回、§4.1 download handler 改 converter.GetResult、§6 錯誤碼撤回 mc/faa 三個 code 加 result_not_found / result_expired - api-conversion.md v0.5 → v0.6:檔頭 Auth 段落改寫、§4 download endpoint 改述、error code 表撤回 mc_token_unavailable / download_token_failed - oidc-tdd.md v0.3 → v0.4:§13.1 環境變數表 OIDC ServiceClient* / TenantID / FAABaseURL 從「重新啟用」改回「再次廢棄」、§13.1.1 stage env 範例移除 service client / tenant_id / FAA URL、§13.1.3 改寫為「v0.4 單線設計」說明 整體影響: - 不需復活 mc_token_client.go(commit 86b7175 砍除狀態維持) - 不需復活 OIDCConfig.ServiceClientID/Secret/TenantID(commit 86b7175 移除狀態維持) - visionA backend faa_client.go 要改名為 converter_result_client.go、改呼叫 converter.GetResult - visionA backend flow.go DownloadStream / PromoteToModels 改用 converter.GetResult - jimchen 跨 repo 任務:converter scheduler 加 GET /api/v1/jobs/{id}/result endpoint Co-Authored-By: Claude Opus 4.7 (1M context) --- .../adr/adr-014-conversion-integration.md | 23 +- .../adr/adr-015-server-to-server-api-key.md | 611 +++++++-------- .../adr/adr-016-download-via-converter.md | 482 ++++++++++++ .../04-architecture/api/api-conversion.md | 86 ++- docs/autoflow/04-architecture/conversion.md | 708 ++++++++++++------ docs/autoflow/04-architecture/oidc-tdd.md | 81 +- 6 files changed, 1384 insertions(+), 607 deletions(-) create mode 100644 docs/autoflow/04-architecture/adr/adr-016-download-via-converter.md diff --git a/docs/autoflow/04-architecture/adr/adr-014-conversion-integration.md b/docs/autoflow/04-architecture/adr/adr-014-conversion-integration.md index bf884dd..a5c7371 100644 --- a/docs/autoflow/04-architecture/adr/adr-014-conversion-integration.md +++ b/docs/autoflow/04-architecture/adr/adr-014-conversion-integration.md @@ -1,7 +1,13 @@ # ADR-014:visionA 端轉檔功能架構(Phase 0.8) ## 狀態 -Accepted — 2026-04-30 +Accepted — 2026-04-30 / **§2 download flow 部分 supersede — 2026-05-16(ADR-016)** + +> **2026-05-16 更新**:§2「Download — FAA delegated token(browser 直連 / v1.1 後 server-side proxy)」整段被 [ADR-016](./adr-016-download-via-converter.md) 部分 supersede。原因:對 MC source 完整驗證後發現「MC issue + validate delegated download token」endpoint **從未存在**——本 §2 從 2026-05-02 寫定起即為 broken design,只是因從未 e2e 跑通 visionA → FAA download 而未被發現。 +> +> **新設計**:visionA download 改走 converter 新增的 `GET /api/v1/jobs/{id}/result` + visionA stream 中轉(jimchen 可單方控制兩端、不必動 MC / FAA / warrenchen)。詳見 ADR-016。 +> +> **本 ADR 仍有效的段落**:§1(upload streaming proxy)、§3(半自動分流的「加到模型庫」原則,但 server-side pull 的 FAA 部分改走 converter)、§4(模組劃分)、§5(service token cache 僅 converter 部分已被 ADR-015 §1 supersede;FAA 部分被 ADR-016 supersede)、§6(user_id trust boundary,**核心原則完全不變**)、§7(FAA / MC 相關 row 被 ADR-016 supersede;converter row 維持)、§8(active job 衝突處理不變)。 ## 上位 / 同層 ADR - 沿用:[ADR-006](./adr-006-no-redis-in-prototype.md)(in-memory state)、[ADR-010](./adr-010-oidc-bff.md)(OIDC BFF + confidential client)、[ADR-011](./adr-011-supersede-adr-005.md)(OIDC 取代 StaticAuth)、[ADR-013](./adr-013-public-client.md)(user OIDC client 為 public + PKCE-only;service client 仍為 confidential) @@ -53,6 +59,20 @@ Browser ──multipart──► visionA backend ──multipart streaming── ### 2. Download — 多次性 → FAA delegated token(server-side 302 redirect → browser 直連 FAA) +> ⚠️ **2026-05-16:本節整段被 [ADR-016](./adr-016-download-via-converter.md) supersede**。 +> +> 致命發現(2026-05-16): +> 1. MC source 沒有 `POST /file-access/download-tokens` endpoint(無法 issue delegated token) +> 2. MC source 沒有 `IDelegatedDownloadTokenValidator` 對應的 introspection endpoint(即使有 token 也無法 validate) +> 3. FAA `GET /files/{key}` 強制只接 delegated token、不接 service token(visionA 即使能拿 service token 也打不進去) +> +> 本節下方描述的「server-side 302 redirect」/「跟 MC 換 delegated token」/「FAA 跟 MC validate」整條鏈從 2026-05-02 起即為 broken design、從未 e2e 跑通。 +> +> **新設計(ADR-016)**:visionA download 改走 converter 新增的 `GET /api/v1/jobs/{id}/result` + visionA stream 中轉。不再有 visionA → MC、visionA → FAA 任何 server-to-server 路徑。 +> +> 本節以下內容**僅作歷史保留**、實作以 ADR-016 為準。 + + ``` Browser ──GET /api/conversion/{job_id}/download──► visionA backend ↓ @@ -230,3 +250,4 @@ converter 回 `409 user_has_active_job` → visionA-backend 透傳 `409 active_j |------|------|------| | 2026-04-30 | 1.0 | 初版 — Phase 0.8 轉檔整合架構決策 | | 2026-04-30 | 1.1 | Download flow 改為 server-side HTTP 302 redirect(仿 FAA TestSite `DownloadFileDirect`),token 不過 frontend JS、不需 FAA CORS | +| 2026-05-16 | 1.2 | §2 download flow 整段標 supersede by [ADR-016](./adr-016-download-via-converter.md):致命發現 MC source 沒有 issue / validate delegated download token endpoint、§2 從 2026-05-02 起即為 broken design。新設計 visionA download 改走 converter `GET /api/v1/jobs/{id}/result` + visionA stream 中轉。其他段落(§1 upload streaming proxy / §3 半自動分流 / §4 模組劃分 / §6 user_id trust boundary / §8 active job 衝突處理)維持有效;§5 / §7 中 FAA / MC 相關部分連帶 supersede。 | diff --git a/docs/autoflow/04-architecture/adr/adr-015-server-to-server-api-key.md b/docs/autoflow/04-architecture/adr/adr-015-server-to-server-api-key.md index 0d6ea70..2a2f673 100644 --- a/docs/autoflow/04-architecture/adr/adr-015-server-to-server-api-key.md +++ b/docs/autoflow/04-architecture/adr/adr-015-server-to-server-api-key.md @@ -1,12 +1,22 @@ -# ADR-015:visionA → converter / FAA 採 pre-shared API key 認證(取代 OAuth client_credentials) +# ADR-015:visionA → converter 採 pre-shared API key 認證(取代 OAuth client_credentials)— 範圍縮限至 visionA → converter ## 狀態 -Accepted — 2026-05-11 +Accepted — 2026-05-11 / **範圍縮限 — 2026-05-16 (v2.0)** / **§2 visionA → FAA 整段再次撤回 — 2026-05-16 (v2.1)** + +> **v2.1 撤回摘要(2026-05-16 下午)**:v2.0 §2「visionA → FAA 回到 ADR-014 §2(MC service token + delegated download token)」**整段再次撤回**。原因:對 MC source 完整驗證後發現 MC **從未有** issue / validate delegated download token endpoint—— ADR-014 §2 從 2026-05-02 起即為 broken design、v2.0 沿用該設計也是 fictional。 +> +> **v2.1 新設計**:visionA download 改走 [ADR-016](./adr-016-download-via-converter.md)(converter 新增 `GET /api/v1/jobs/{id}/result` + visionA stream 中轉)。visionA 端不再有任何 visionA → MC / visionA → FAA 的 server-to-server 路徑。**visionA → converter API key 路線(§1)維持完全不變**。 +> +> **v2.0 範圍縮限摘要(保留歷史)**:v1.x 原本決策「visionA → converter / FAA 兩條 server-to-server 線都改 API key」。2026-05-16 使用者拍板**撤回 visionA → FAA 改 API key 部分**,FAA 線回到 ADR-014 §2 原設計。**v2.0 後 v2.1 再次撤回** — FAA 線完全交由 ADR-016 取代(converter 中轉,不再有 visionA → FAA / visionA → MC 鏈)。 ## 上位 / 同層 ADR -- **部分 supersedes**:[ADR-014](./adr-014-conversion-integration.md) §5「Service token cache — 仿 converter scheduler 模式」、§6「user_id 注入 + trust boundary」(OAuth service token 段落)、§7「失敗模式 retry 矩陣」(MC token / MC delegated token 兩個 row)。ADR-014 的其他段落(upload streaming proxy、download 走 302 redirect、半自動 promote、不擴 model schema、模組劃分)仍有效。 -- **不影響**:[ADR-013](./adr-013-public-client.md) — user login 的 OIDC public PKCE client 仍照舊。本 ADR 只動「server-to-server」這條線;「user login」這條線完全不變。 +- **部分 supersedes**:[ADR-014](./adr-014-conversion-integration.md) §5「Service token cache — 仿 converter scheduler 模式」中 **converter 部分**(visionA → converter 的 service token / scope `converter:job.write/read` 取消)、§7「失敗模式 retry 矩陣」中 converter MC token row(converter 線不再經 MC)。 + - **v2.0 修正(保留歷史)**:v2.0 一度把 FAA 部分維持 ADR-014 §2 原設計,但 **v2.1 整段撤回**——FAA 部分的 supersede 改由 [ADR-016](./adr-016-download-via-converter.md) 接手(visionA download 改走 converter result endpoint,視 ADR-014 §2 / 本 ADR v2.0 §2 為 broken design)。 + - **v2.1 後**:本 ADR 對 ADR-014 的 supersede 範圍維持「§5 中 converter 部分 / §7 中 converter MC token row」;ADR-014 §2 / §5 中 FAA 部分 / §6 / §7 中 FAA + MC delegated token row 由 ADR-016 統一 supersede。 + - ADR-014 的其他段落(upload streaming proxy、半自動 promote 原則、不擴 model schema、模組劃分)仍有效。 +- **被 supersede**(v2.1 新增):本 ADR §2「visionA → FAA」整段被 [ADR-016](./adr-016-download-via-converter.md) supersede。visionA download 不再有 visionA → FAA / visionA → MC 任何鏈路。 +- **不影響**:[ADR-013](./adr-013-public-client.md) — user login 的 OIDC public PKCE client 仍照舊。本 ADR v2.1 後只動「server-to-server visionA → converter」這條線;「user login」維持不變;「server-to-server visionA → FAA」這條線**直接不存在**(由 ADR-016 撤回)。 - 沿用:[ADR-006](./adr-006-no-redis-in-prototype.md)(in-memory state)、[ADR-010](./adr-010-oidc-bff.md)(user login 的 OIDC BFF Pattern)、[ADR-011](./adr-011-supersede-adr-005.md)(取代 StaticAuth) ## 背景 (Context) @@ -31,35 +41,51 @@ Phase 0.8 stage 部署實際跑到才發現**整條鏈路有 4 個串行 blocker 要修齊這 4 個 blocker 必須跨 3 個 repo 改 + 同步 MC team + redeploy converter + warrenchen 配合 FAA — 對「2 條 1:1 trust 關係的 server-to-server 整合」明顯過度設計。 -### 過度設計的訊號 +### v1.x 過度設計的訊號(converter 線適用) OAuth `client_credentials` + JWKS + scope 機制適合的場景: - **多個 client 對同一個 resource server**(如 SaaS 平台對外開放 API、第三方 dev 串接),需要區分不同 client 權限、需要短 TTL token 限制 blast radius、需要 scope 細粒度 - **不同團隊 / 不同信任邊界**,client 端的 secret 不能由 server 端管理 -visionA → converter / FAA 的場景**完全不符合上面任一個**: +**visionA → converter** 的場景**完全不符合上面任一個**: -- **1:1 trust 關係**(visionA 是 converter / FAA 唯一的 server-to-server caller,沒有第三方) -- **使用者同時維護 visionA + converter**(jimchen),FAA 由 warrenchen 維護(同公司、可協調) +- **1:1 trust 關係**(visionA 是 converter 唯一的 server-to-server caller,沒有第三方) +- **使用者同時維護 visionA + converter**(jimchen),可單方拍板改 middleware - **全部 internal trust**(不是給外部 dev 用,沒有 untrusted client) -- **無需 scope 細分**(converter 只關心「是否為 visionA」、FAA 只關心「是否為 visionA」,單一布林) +- **無需 scope 細分**(converter 只關心「是否為 visionA」,單一布林) 而成本: - **複雜度**:MC 端要 onboard scope、issuer JWKS 要可達、token cache 要寫對、TTL 邊界要處理、4 個 5xx / 4xx 失敗模式要 retry & graceful degrade -- **鏈路長度**:visionA → MC → cache → converter / FAA,任一節點掛掉都不能轉檔 +- **鏈路長度**:visionA → MC → cache → converter,任一節點掛掉都不能轉檔 - **可觀測性負擔**:要追的 metrics 包含 token cache hit rate、MC 失敗率、scope 對齊狀態 -### 已洩漏的 stage service client secret(觀察事實) +### 為什麼 v2.0 撤回 FAA 改 API key(FAA 線適用相反邏輯) -`RciRUyiCkbd60ikkZGkfQ2xV4r02VW3/j0ASKV/DD/E=` 已在對話中外洩(progress.md 2026-05-11 紀錄)。改用 API key 後此 secret 直接作廢、不需 rotate,這是 Phase 0.8b 的順帶收益。 +v1.0 把同一套「過度設計」邏輯外推到 FAA,但 FAA 線的實際情況不同: + +1. **FAA 是 warrenchen 維護的公司共用 repo**:協調成本不對稱(converter 是 jimchen 自己的、可單方改;FAA 改一行都要走跨人協調) +2. **MC 端針對 FAA 的 scope 早已備妥**:使用者於 2026-05-16 提供的 stage service client(`4242ba63099d4f318dd3f143d27ef4c5`)含 `files:upload.write files:metadata.read files:delete files:download.delegate` 4 個 scope,**完整 cover FAA 4 個 endpoint**(PUT / GET metadata / HEAD / DELETE / GET file),不需要 MC team 額外 onboard +3. **FAA 已內建 dual-auth 設計**:`/Users/jimchen/file_access_agent/src/FileAccessAgent.Api/Program.cs` line 184-254 顯示 `GET /files/{key}` 下載 endpoint **沒掛 `RequireAuthorization()`**,改用 `IDelegatedDownloadTokenValidator.ValidateAsync(...)` 驗 delegated download token;其他 endpoint(`PUT` / `metadata` / `HEAD` / `DELETE`)才走 JWT Bearer + `EnsureJwtScopeAndTenant`。 + → **FAA 設計上 download 就是要 delegated token、不接 service token**;如果硬要把 FAA 全 endpoint 改成 API key middleware,等於要 warrenchen 重寫整套 dual-auth、把既有 delegated token validator 拔掉,遠超「補 middleware」的成本 +4. **5/9 撞 MC scope 沒註冊的痛主因在 converter 線**(`converter:job.read/write` 兩個 scope 不存在);FAA 線 4 個 scope 已備妥的事實在當時尚未驗證 +5. **converter 線 v1.0 改 API key 已能解決「最痛的」blocker**(不必動 MC、不必動 FAA、不必跨人);FAA 線本就 1:N(FAA 之後可能服務多個 visionA-like 產品線),維持 OAuth 框架對 FAA 端架構演進更友善 + +→ v2.0 縮限至「**只動 converter 線;FAA 線回到 ADR-014 §2 原設計**」是更精確的責任邊界劃分。 + +### 已洩漏的 stage service client secret(v1.x 觀察事實 — v2.0 部分仍適用) + +`RciRUyiCkbd60ikkZGkfQ2xV4r02VW3/j0ASKV/DD/E=` 已在對話中外洩(progress.md 2026-05-11 紀錄)。 + +- **v1.x 的處理**:改用 API key 後此 secret 直接作廢、不需 rotate +- **v2.0 的處理**:FAA 線改回 service token 路線後,使用者於 2026-05-16 提供的**新** stage service client `4242ba63099d4f318dd3f143d27ef4c5` 取代舊 client(舊的洩漏值仍作廢、不重用);新 secret **僅放 stage host `.env.stage` 與部署 secret store、絕不進 git / 文件**(本 ADR、TDD、env example 一律不寫真實 secret 值) ## 決策 (Decision) -採 **pre-shared API key + `Authorization: Bearer ` header** 取代 OAuth client_credentials 服務間認證。 +### v2.0 範圍:採 **pre-shared API key + `Authorization: Bearer ` header** 取代 OAuth client_credentials,**僅適用於 visionA → converter**。visionA → FAA 維持 ADR-014 §2 原設計(MC service token + delegated download token)。 -### 1. visionA → converter +### 1. visionA → converter(v1.0 採用 / v2.0 維持) ``` visionA backend 啟動時 @@ -86,49 +112,103 @@ subtle.ConstantTimeCompare(token, CONVERTER_API_KEY) match → 放行;mismatch → 401 ``` -### 2. visionA → FAA +### 2. ⚠️ visionA → FAA(v2.1:整段撤回,改走 ADR-016 converter 中轉) -對稱設計: +> **v2.1 撤回(2026-05-16 下午)**:v2.0 在本節「FAA 線回到 ADR-014 §2 原設計(MC service token + delegated download token)」**整段撤回**。 +> +> **理由(致命發現 2026-05-16)**: +> 1. MC source 沒有 `POST /file-access/download-tokens` endpoint(visionA 無法跟 MC 換 delegated token) +> 2. MC source 沒有 FAA `IDelegatedDownloadTokenValidator` assume 的 introspection endpoint(即使有 token 也無法 validate) +> 3. FAA `GET /files/{key}` 強制只接 delegated token、不接 service token +> +> → ADR-014 §2 與本 ADR v2.0 §2 描述的「visionA → MC → FAA delegated token 鏈」**完全是 fictional**(從 2026-05-02 起未曾 e2e 跑通過)。 +> +> **v2.1 採用的設計**:visionA download 改走 [ADR-016](./adr-016-download-via-converter.md)(converter 新增 `GET /api/v1/jobs/{id}/result` endpoint + visionA stream 中轉)。visionA 端不再有任何 visionA → MC server-to-server 路徑、不再有任何 visionA → FAA 直接呼叫。 +> +> 本節以下內容**僅作歷史保留**、實作以 ADR-016 為準;v2.1 後對應的 visionA 端 code 應撤回(mc_token_client.go 不需復活,已於 commit `86b7175` 砍除 → 維持砍除;OIDCConfig.ServiceClientID/Secret + ConversionConfig.TenantID 維持 v1.x 廢棄狀態)。 + +**v2.0 原採用的設計(即 ADR-014 §2 原設計,v2.1 整段撤回,僅作歷史保留)**: ``` visionA backend 啟動時 ↓ -讀 env VISIONA_FAA_API_KEY +讀 OIDCConfig.ServiceClientID / ServiceClientSecret + ConversionConfig.TenantID ↓ -[加到模型庫流程要 pull NEF] +[需要打 FAA,例如「加到模型庫」server-to-server pull、或「下載」proxy] ↓ -打 FAA: - Authorization: Bearer +visionA → MC POST {issuer}/oauth/token + grant_type=client_credentials + client_id= + client_secret= + scope=files:upload.write files:metadata.read files:delete files:download.delegate + ↓ +MC 回 service access_token(cache 至 exp - 15s) + ↓ +A 路線(FAA 寫 / metadata / delete / s2s download): + visionA 帶 Authorization: Bearer 打 FAA + FAA 端 .RequireAuthorization() + EnsureJwtScopeAndTenant 驗 + ├─ JWT 簽章(FAA `AddJwtBearer` Authority = MC issuer,自動 JWKS) + ├─ Audience(FAA `Auth:Audience`) + ├─ scope claim(PUT 要 files:upload.write;GET metadata / HEAD 要 files:metadata.read;DELETE 要 files:delete) + └─ tenant_id claim 必須等於 instanceOptions.TenantId + → 通過則放行 + +B 路線(download stream proxy — visionA backend 中轉到 browser): + 1. visionA 帶 service-token 打 MC POST /file-access/download-tokens + (scope: files:download.delegate;針對特定 object_key + GET method + 5 分鐘 TTL) + 2. MC 回 delegated download token (opaque) + 3. visionA 帶 Authorization: Bearer 打 FAA GET /files/{key} + 4. FAA 端 GET /files/{key} 沒掛 .RequireAuthorization(), + 改走 IDelegatedDownloadTokenValidator.ValidateAsync(...) + ├─ token active + ├─ tenant_id match + ├─ object_key match + └─ method == "GET" + → 通過則 stream NEF binary + 5. visionA backend io.CopyN(...) 中轉回 browser ``` -FAA 端 middleware: +**為什麼 v2.0 設計「download 線必須是 delegated token」而非「service token」**: -``` -讀 env FAA_API_KEY - ↓ -[收到請求] - ↓ -parse Authorization header → 取 token - ↓ -constant-time compare(C# `CryptographicOperations.FixedTimeEquals` 或等效) - ↓ -match → 放行;mismatch → 401 -``` +FAA 端的 dual-auth 設計(已實作於 `/Users/jimchen/file_access_agent/src/FileAccessAgent.Api/Program.cs`)強制如此: -### 3. 每個下游各自獨立的 key +| FAA endpoint | line range | auth 機制 | 適用 scope / token type | +|--------------|-----------|----------|------------------------| +| `GET /files/metadata/{**objectKey}` | 80-111 | `.RequireAuthorization()` + `EnsureJwtScopeAndTenant` | `files:metadata.read` (service token) | +| `HEAD /files/{**objectKey}` | 113-148 | `.RequireAuthorization()` + `EnsureJwtScopeAndTenant` | `files:metadata.read` (service token) | +| `PUT /files/{**objectKey}` | 150-182 | `.RequireAuthorization()` + `EnsureJwtScopeAndTenant` | `files:upload.write` (service token) | +| **`GET /files/{**objectKey}`** | **184-254** | **無 `.RequireAuthorization()`;用 `IDelegatedDownloadTokenValidator.ValidateAsync(...)`** | **`files:download.delegate` 換出來的 delegated token** | +| `DELETE /files/{**objectKey}` | 256-287 | `.RequireAuthorization()` + `EnsureJwtScopeAndTenant` | `files:delete` (service token) | -**不共用一把**: +→ FAA `GET /files/{key}` **不接 service token**,必須用 MC 簽的 delegated download token。 +→ visionA download flow 必須做「換 delegated token」這一步、不能省。 +→ visionA「加到模型庫」server-to-server pull 流程因為走的也是 `GET /files/{key}` 下載端點,**也要走 delegated download token 路徑**(v2.0 修正:v1.x 與 ADR-014 §3 「scope `files:download.read`」描述不精確;FAA 端 source 真相是 download endpoint 一律用 delegated token,scope `files:download.delegate` 是 service client 用來「向 MC 換 delegated token」的能力,不是 FAA 端 endpoint 接收的 scope) + +**stage 端證據(使用者 2026-05-16 提供)**: + +- FAA stage URL:`https://stage-9527.innovedus.com:5081` +- TenantId:`732270c0-449c-489c-bfad-321e9bf89b3d` +- ServiceClientId:`4242ba63099d4f318dd3f143d27ef4c5`(取代 v1.x 提到的舊 client `23605e14...`) +- ServiceScopes:`files:upload.write files:metadata.read files:delete files:download.delegate` +- ServiceClientSecret:放 stage host `.env.stage`,不進 git / 文件 + +**待 verify(合規性段落追蹤)**:MC stage 端是否確實註冊上述 4 個 scope 並對該 service client 生效,需 stage redeploy 前實測 `POST /oauth/token` 拿到含 4 scope 的 access_token。 + +### 3. 單一下游:converter(v1.x「每個下游各自獨立的 key」表格 v2.0 縮限) + +**v1.x 的 key 表格** 縮限至 converter 一條: | Key | 持有者 | 用途 | |-----|--------|------| | `VISIONA_CONVERTER_API_KEY`(visionA 端) / `CONVERTER_API_KEY`(converter 端) | jimchen | visionA → converter | -| `VISIONA_FAA_API_KEY`(visionA 端) / `FAA_API_KEY`(FAA 端) | jimchen / warrenchen | visionA → FAA | -理由:每條 trust boundary 各自獨立,一條 rotate 不影響另一條;converter / FAA 不應該因為對方洩漏被連坐。 +**v1.x 中的 FAA key row(`VISIONA_FAA_API_KEY` / `FAA_API_KEY`)撤回**——FAA 改回 MC service token 路徑、不需要 pre-shared API key。 -### 3.5 Reference Middleware Implementation +理由(converter 維持):每條 trust boundary 各自獨立。converter 線 1:1 trust,雙方都由 jimchen 維護,rotate 對齊成本可控。 -本節提供兩端 middleware 的可直接照抄 reference snippet。converter 端 jimchen 跨 repo 用 Go 實作;FAA 端 warrenchen 跨 repo 用 C# 實作。兩端的設計原則一致:constant-time compare、統一 401、不洩漏 token、不洩漏「key 對 / 不對」的差異。 +### 3.5 Reference Middleware Implementation(v2.0 縮限至 converter 端) + +本節提供 converter 端 middleware 的可直接照抄 reference snippet。 #### 3.5.1 converter 端(Go — net/http 標準 middleware pattern) @@ -232,208 +312,24 @@ authMiddleware := middleware.NewAPIKeyAuth(expectedKey, logger) mux.Handle("/api/v1/", authMiddleware(apiHandler)) ``` -#### 3.5.2 FAA 端(C# ASP.NET Core middleware) +#### 3.5.2 ~~FAA 端(C# ASP.NET Core middleware)~~(v2.0 撤回 — 整段刪除) -`CryptographicOperations.FixedTimeEquals` 是 .NET 6+ 提供的 constant-time byte 比較函式。下方提供 classic Middleware class 寫法(推薦給既有 ASP.NET Core MVC / Web API 專案)+ minimal API 的 inline middleware 寫法(推薦給 .NET 8 minimal API 新 project)。warrenchen 視 FAA 既有架構選一即可。 +> **v2.0 撤回**:v1.1 在此處提供的 FAA 端 ASP.NET Core middleware snippet(含 Classic Middleware Class 寫法 A + Minimal API Inline Middleware 寫法 B)**整段刪除**。 +> +> **理由**:FAA 線回到 ADR-014 §2 原設計(MC service token + delegated download token)後,FAA 端**不需要新增任何 API key middleware**: +> +> - 既有 `AddJwtBearer` + `RequireAuthorization()` + `EnsureJwtScopeAndTenant` 已涵蓋 PUT / metadata / HEAD / DELETE 4 個 endpoint +> - 既有 `IDelegatedDownloadTokenValidator` 已涵蓋 GET download endpoint +> - **FAA 端零變更**——這是 v2.0 撤回的核心收益(不必動公司共用 FAA repo、不必跟 warrenchen 協調) -**寫法 A:Classic Middleware Class(推薦既有 ASP.NET Core 專案)** +#### 3.5.3 部署檢查清單(v2.0 縮限至 converter 端) -```csharp -// Middleware/ApiKeyAuthMiddleware.cs -using System.Security.Cryptography; -using System.Text; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging; - -namespace FAA.Middleware; - -public class ApiKeyAuthMiddleware -{ - private readonly RequestDelegate _next; - private readonly ILogger _logger; - private readonly byte[] _expectedKeyBytes; - - private const string BearerPrefix = "Bearer "; - - public ApiKeyAuthMiddleware( - RequestDelegate next, - ILogger logger, - string expectedKey) - { - _next = next; - _logger = logger; - - // 啟動時 fail-fast — 不允許 server 在沒有 key 的狀態下啟動 - if (string.IsNullOrEmpty(expectedKey)) - { - throw new InvalidOperationException("FAA_API_KEY env not set"); - } - _expectedKeyBytes = Encoding.UTF8.GetBytes(expectedKey); - } - - public async Task InvokeAsync(HttpContext context) - { - var authHeader = context.Request.Headers.Authorization.ToString(); - - // missing Authorization header - if (string.IsNullOrEmpty(authHeader)) - { - _logger.LogWarning( - "api key auth failed: reason=missing_authorization_header path={Path} remote={Remote}", - context.Request.Path, context.Connection.RemoteIpAddress); - await WriteUnauthorizedAsync(context); - return; - } - - // 不是 Bearer prefix - if (!authHeader.StartsWith(BearerPrefix, StringComparison.Ordinal)) - { - _logger.LogWarning( - "api key auth failed: reason=missing_bearer_prefix path={Path} remote={Remote}", - context.Request.Path, context.Connection.RemoteIpAddress); - await WriteUnauthorizedAsync(context); - return; - } - - var token = authHeader[BearerPrefix.Length..].Trim(); - - // token 為空 - if (string.IsNullOrEmpty(token)) - { - _logger.LogWarning( - "api key auth failed: reason=empty_token path={Path} remote={Remote}", - context.Request.Path, context.Connection.RemoteIpAddress); - await WriteUnauthorizedAsync(context); - return; - } - - // constant-time compare — FixedTimeEquals 要求兩邊長度相同,所以先比長度 - // (長度本身不是 secret,預先 reject 不會洩漏資訊) - var tokenBytes = Encoding.UTF8.GetBytes(token); - if (tokenBytes.Length != _expectedKeyBytes.Length || - !CryptographicOperations.FixedTimeEquals(tokenBytes, _expectedKeyBytes)) - { - _logger.LogWarning( - "api key auth failed: reason=token_mismatch path={Path} remote={Remote}", - context.Request.Path, context.Connection.RemoteIpAddress); - // 注意:log 絕對不印 token 本身 - await WriteUnauthorizedAsync(context); - return; - } - - // 通過 — 放行到 next middleware - await _next(context); - } - - private static async Task WriteUnauthorizedAsync(HttpContext context) - { - context.Response.StatusCode = StatusCodes.Status401Unauthorized; - context.Response.ContentType = "application/json"; - await context.Response.WriteAsync("{\"error\":\"unauthorized\"}"); - } -} - -// IApplicationBuilder extension — 讓 Program.cs 可以 app.UseApiKeyAuth(...) -public static class ApiKeyAuthMiddlewareExtensions -{ - public static IApplicationBuilder UseApiKeyAuth( - this IApplicationBuilder builder, string expectedKey) - { - return builder.UseMiddleware(expectedKey); - } -} -``` - -Program.cs 使用範例: - -```csharp -// Program.cs(節錄) -var expectedKey = builder.Configuration["FAA_API_KEY"] - ?? Environment.GetEnvironmentVariable("FAA_API_KEY") - ?? throw new InvalidOperationException("FAA_API_KEY env not set"); - -var app = builder.Build(); -app.UseApiKeyAuth(expectedKey); -// 之後才掛 controllers / endpoints -app.MapControllers(); -app.Run(); -``` - -**寫法 B:Minimal API Inline Middleware(.NET 8 新 project)** - -```csharp -// Program.cs(節錄) -using System.Security.Cryptography; -using System.Text; - -var builder = WebApplication.CreateBuilder(args); -var expectedKey = builder.Configuration["FAA_API_KEY"] - ?? Environment.GetEnvironmentVariable("FAA_API_KEY") - ?? throw new InvalidOperationException("FAA_API_KEY env not set"); - -var expectedKeyBytes = Encoding.UTF8.GetBytes(expectedKey); - -var app = builder.Build(); - -app.Use(async (context, next) => -{ - var logger = context.RequestServices.GetRequiredService() - .CreateLogger("ApiKeyAuth"); - var authHeader = context.Request.Headers.Authorization.ToString(); - const string prefix = "Bearer "; - - string? failReason = authHeader switch - { - "" => "missing_authorization_header", - var s when !s.StartsWith(prefix, StringComparison.Ordinal) => "missing_bearer_prefix", - _ => null - }; - - if (failReason is null) - { - var token = authHeader[prefix.Length..].Trim(); - if (string.IsNullOrEmpty(token)) - { - failReason = "empty_token"; - } - else - { - var tokenBytes = Encoding.UTF8.GetBytes(token); - if (tokenBytes.Length != expectedKeyBytes.Length || - !CryptographicOperations.FixedTimeEquals(tokenBytes, expectedKeyBytes)) - { - failReason = "token_mismatch"; - } - } - } - - if (failReason is not null) - { - logger.LogWarning( - "api key auth failed: reason={Reason} path={Path} remote={Remote}", - failReason, context.Request.Path, context.Connection.RemoteIpAddress); - context.Response.StatusCode = StatusCodes.Status401Unauthorized; - context.Response.ContentType = "application/json"; - await context.Response.WriteAsync("{\"error\":\"unauthorized\"}"); - return; - } - - await next(); -}); - -app.MapGet("/api/v1/files/{key}", (string key) => Results.Ok(/* ... */)); -app.Run(); -``` - -#### 3.5.3 兩端共通的部署檢查清單 - -不分 Go / C#,部署前必須逐項確認: +不分 client 端 / server 端,部署前 converter 兩側必須逐項確認: | # | 檢查項 | 為什麼 | |---|--------|--------| | 1 | env 已設定且非空(啟動 fail-fast)| 避免「未設定 = 全部放行」災難;server 應在啟動時 panic / throw、不要等到第一個 request 才發現 | -| 2 | constant-time compare(Go `subtle.ConstantTimeCompare` / C# `CryptographicOperations.FixedTimeEquals`)| 避免 timing attack 反推 key | +| 2 | constant-time compare(Go `subtle.ConstantTimeCompare`)| 避免 timing attack 反推 key | | 3 | 401 response body 統一(不洩漏「key 對 / 不對」/ 「missing / mismatch」差異)| 對外只回 `{"error":"unauthorized"}`,差異只記在 server 端 log | | 4 | log 絕對不印 token 本身 | 即使是失敗的 token 也不印(攻擊者可能用半正確的 token 試探);只印 reason / path / remote | | 5 | Bearer prefix 嚴格驗證(缺 prefix 也 401)| 不允許 `Authorization: ` 這種格式(即使內容對也 reject、強制 client 用標準 Bearer scheme) | @@ -441,43 +337,58 @@ app.Run(); | 7 | key 不進 git | `.gitignore` 嚴格 ignore `.env*`;CI / CD secret 從 Secrets Manager / Vault 注入 | | 8 | 健康檢查 endpoint 是否要 bypass auth | 通常 `/healthz` / `/readyz` 應 bypass(讓 LB / k8s 可探測),但業務 endpoint 全部要 auth | -### 4. 不再有 scope 概念 +> **FAA 端不需要本清單**——v2.0 起 FAA 端使用既有 OAuth + delegated token 機制,無新增 API key middleware。FAA 端的 OAuth / delegated token 部署檢查請參考 ADR-014 §2 與 `conversion.md` §3.2。 -OAuth `client_credentials` 設計中四個 scope(`converter:job.write/read`、`files:download.read/delegate`)取消: +### 4. 不再有 scope 概念(converter 線適用;FAA 線 v2.0 撤回) -- 單一 API key 就是「visionA 有權打 converter」/ 「visionA 有權打 FAA」的完整證明 +**converter 線**:OAuth `client_credentials` 設計中兩個 converter scope(`converter:job.write/read`)取消。 + +- 單一 API key 就是「visionA 有權打 converter」的完整證明 - 不再有「同一個 client 但拿不同 scope」的細粒度區分(在 1:1 trust 中本來就沒意義) -- converter / FAA 端 middleware 也不需要驗 scope +- converter 端 middleware 也不需要驗 scope -### 5. 不再有 tenant 概念 +**FAA 線**:v1.x 取消 `files:upload.write / files:metadata.read / files:delete / files:download.delegate` 4 個 scope 的決策**撤回**。FAA 4 個 scope 全部恢復、由 MC service client `4242ba63...` 持有,並由 FAA `EnsureJwtScopeAndTenant` 驗。 -visionA → converter / FAA 不再帶 tenant_id: +### 5. tenant 概念 -- converter 端的 user_id 從 multipart body `user_id` field 拿(仍由 visionA 從 OIDC sub 灌入,這條 trust boundary 不變) -- FAA 端也不需要 tenant 概念(pull NEF 用 object_key 定位) -- `VISIONA_OIDC_TENANT_ID` env 在 conversion 場景**廢棄**(如果其他場景還有用就保留,目前未發現其他依賴) +**converter 線**:visionA → converter 不再帶 tenant_id。converter 端的 user_id 從 multipart body `user_id` field 拿(仍由 visionA 從 OIDC sub 灌入,這條 trust boundary 不變)。converter 端不需要 tenant 概念。 -### 6. visionA backend 移除的程式碼 +**FAA 線(v2.0 修正 — 從 v1.x「不再有 tenant」改為「FAA 線需要 tenant」)**: -| 項目 | 處理 | -|------|------| -| `internal/conversion/mc_token_client.go`(整個 package) | **整個檔案刪除**(~440 行 — token cache + delegated download token + double-checked locking)| -| `internal/conversion/converter_client.go` 內呼叫 `MCTokenClient.ServiceToken()` 的地方 | 改成讀 `cfg.Conversion.ConverterAPIKey` 直接 set header | -| `internal/conversion/faa_client.go` 內呼叫 `MCTokenClient.ServiceToken()` 的地方 | 改成讀 `cfg.Conversion.FAAAPIKey` 直接 set header | -| `internal/conversion/flow.go` 內呼叫 `mc.IssueDelegatedDownload()` 的地方 | **delegated download token 路徑取消**(見下方 §7) | -| `internal/config/config.go` 內 `OIDCConfig.ServiceClientID` / `ServiceClientSecret` 兩個欄位 | **欄位廢棄**(保留欄位以保持 backward compat,但 conversion 已不依賴);env `VISIONA_OIDC_SERVICE_CLIENT_ID` / `SECRET` 從 `.env*.example` 移除 | -| `internal/config/config.go` 內 `ConversionConfig.TenantID` 欄位 + env `VISIONA_OIDC_TENANT_ID` | conversion 模組不再依賴;如其他模組未使用即可移除 | +- FAA 端 `EnsureJwtScopeAndTenant` 函式會驗 service token 內的 `tenant_id` claim 等於 `instanceOptions.TenantId`(見 FAA `Program.cs` line 303-312) +- delegated download token 路徑也驗 `validationResult.TenantId.Value != instanceOptions.TenantId`(line 218-221) +- → visionA 的 service token / delegated download token 必須含正確的 `tenant_id` claim +- 來源:MC service client `4242ba63...` 註冊時對應的 tenant,stage 為 `732270c0-449c-489c-bfad-321e9bf89b3d` +- → `VISIONA_OIDC_TENANT_ID` env **重新啟用**(v1.x 標廢棄;v2.0 撤回廢棄) -新增的 config 欄位: +### 6. visionA backend 移除的程式碼(v1.x 移除清單,v2.0 部分復活) + +| 項目 | v1.x 處理 | **v2.0 處理** | +|------|-----------|---------------| +| `internal/conversion/mc_token_client.go`(整個 package) | 整個檔案刪除(~440 行) | **部分復活** — 保留 service token cache + delegated download token issue 邏輯(給 FAA 用),但不再被 converter_client 引用 | +| `internal/conversion/converter_client.go` 內呼叫 `MCTokenClient.ServiceToken()` | 改成讀 `cfg.Conversion.ConverterAPIKey` 直接 set header | **同 v1.x**(不變)—— converter 線維持 API key | +| `internal/conversion/faa_client.go` 內呼叫 `MCTokenClient.ServiceToken()` | 改成讀 `cfg.Conversion.FAAAPIKey` 直接 set header | **撤回**——回到 v1.x 之前:呼叫 `MCTokenClient.ServiceToken()` 拿 service token、帶 `Authorization: Bearer `;download 路徑額外呼叫 `MCTokenClient.IssueDelegatedDownload(...)` | +| `internal/conversion/flow.go` 內呼叫 `mc.IssueDelegatedDownload()` | 「delegated download token 路徑取消」 | **撤回**——download stream proxy 路徑要呼叫 `IssueDelegatedDownload(...)` 拿 token、再呼叫 `faa.DownloadWithDelegated(token, objectKey)` | +| `internal/config/config.go` 內 `OIDCConfig.ServiceClientID` / `ServiceClientSecret` 兩個欄位 | 廢棄(保留 struct field 為 backward compat、不再使用);env `VISIONA_OIDC_SERVICE_CLIENT_ID` / `SECRET` 從 `.env*.example` 移除 | **重新啟用**——FAA 線需要;env 加回 `.env.stage.example` | +| `internal/config/config.go` 內 `ConversionConfig.TenantID` 欄位 + env `VISIONA_OIDC_TENANT_ID` | conversion 模組不再依賴;如其他模組未使用即可移除 | **重新啟用**——FAA 端 `EnsureJwtScopeAndTenant` 驗 tenant_id claim、token 必須帶 | + +config 欄位 v2.0 樣貌: ```go // internal/config/config.go type ConversionConfig struct { ConverterBaseURL string // 既有 FAABaseURL string // 既有 - ConverterAPIKey string // 新增 — env VISIONA_CONVERTER_API_KEY - FAAAPIKey string // 新增 — env VISIONA_FAA_API_KEY - // TenantID 廢棄 + ConverterAPIKey string // v1.0 新增 — env VISIONA_CONVERTER_API_KEY(v2.0 維持) + // FAAAPIKey string // v1.0 加的;v2.0 撤回(不再需要) + TenantID string // v1.x 廢棄;v2.0 重新啟用 — env VISIONA_OIDC_TENANT_ID +} + +// OIDCConfig 維持有 ServiceClientID / ServiceClientSecret 兩欄位(v2.0 重新啟用) +type OIDCConfig struct { + // ... user login 相關欄位(不變)... + ServiceClientID string // v2.0 重新啟用 — env VISIONA_OIDC_SERVICE_CLIENT_ID + ServiceClientSecret string // v2.0 重新啟用 — env VISIONA_OIDC_SERVICE_CLIENT_SECRET } // Enabled 改判定: @@ -485,39 +396,59 @@ func (c ConversionConfig) Enabled() bool { return c.ConverterBaseURL != "" && c.FAABaseURL != "" && c.ConverterAPIKey != "" && - c.FAAAPIKey != "" + // FAA 線判定 OIDCConfig 有 ServiceClientID / Secret + ConversionConfig.TenantID + // — 由 main.go 啟動時組合判斷,這裡只判 conversion 自己的欄位 + c.TenantID != "" } ``` -### 7. Delegated download token 路徑的處理(重要 — Phase 0.8b 範圍說明) +### 7. Delegated download token 路徑的處理(v2.0 撤回 v1.x 的撤回,回到 ADR-014 §2 設計) -ADR-014 §2 設計 download 流程是: +ADR-014 §2 原設計 download 流程: ``` browser → visionA /download → MC issue delegated token → 302 → browser → FAA?access_token=... ``` -API key 模式下「MC issue delegated token」這條鏈不存在了。Phase 0.8b 對 download 路徑的處理: +**v1.x 的選項 A(短期 server-side proxy with API key)撤回**:v1.0 / v1.1 在本節原本提案「visionA backend 直接用 `Authorization: Bearer ` 拉 FAA、stream 回 browser」整段全部撤回。 -**選項 A(Phase 0.8b 採用)— 短期:保持 server-side download proxy** - -visionA backend 直接用 `Authorization: Bearer ` 拉 FAA,stream 回 browser: +**v2.0 採用的設計(保留 server-side stream proxy 不退回 302、但 token 來源改回 delegated download token)**: ``` browser → visionA /download → visionA backend ↓ - Bearer + ownership 檢查 ↓ - FAA + ensurePromoted(拿 target_object_key) ↓ - stream NEF 回 browser + MCTokenClient.ServiceToken() → MC service access_token + ↓ + MCTokenClient.IssueDelegatedDownload(token, object_key, "GET", 5min) + → MC POST /file-access/download-tokens + ↓ + FAA GET /files/{key} + Authorization: Bearer + ↓ + FAA IDelegatedDownloadTokenValidator.ValidateAsync(...) + ↓ + FAA stream NEF binary + ↓ + visionA backend io.CopyN → browser ``` -- 跨 internet 流量 N 倍是接受的取捨(Phase 0.8 MVP user 量小、單檔 NEF 通常 < 50MB) -- token 結構性不過 frontend JS(仍維持 ADR-014 §2 表格的「server-side 比 frontend 拿 token 更安全」的所有優點) -- visionA backend 變成 streaming bottleneck,Phase 1 量大時再評估 +**為什麼保留 server-side stream proxy(不退回 ADR-014 §2 的 302 redirect)**: -**選項 B(Phase 1+ 升級路徑)— FAA 上 delegated token 機制改用「visionA 自己簽 short-TTL HMAC token」** +- T4 已經在 Phase 0.8 把 download 改成 server-side stream proxy(`conversion.md` v0.4 / `api/api-conversion.md` v0.4),實測 frontend `` 流程已驗證 +- 退回 302 redirect 等於 frontend 行為改變、要重做 e2e 驗證、無收益 +- delegated token 在 server-side(不洩漏給 frontend JS / browser URL bar)反而比 302 模式更安全 +- 流量成本(每次下載繞 visionA backend N×)Phase 0.8 MVP 量小可接受;Phase 1 量大時再評估升級 + +**為什麼 token 來源改回 delegated download token(不繼續用 v1.x 的 visionA API key)**: + +- FAA `GET /files/{key}` endpoint 強制使用 delegated token(line 184-254 沒掛 `RequireAuthorization()`、改用 `IDelegatedDownloadTokenValidator`),FAA 端不接受其他 token type +- v1.x 若要用 visionA API key,需要 warrenchen 在 FAA 端為 download endpoint 額外實作「API key middleware」並改寫 dual-auth 路由——成本遠超 v2.0 撤回的收益 + +**選項 B(Phase 1+ HMAC token 升級路徑)保留為 follow-up**: 如果 Phase 1 流量壓力大要回 302 redirect 模式,visionA 可以自己簽 short-TTL HMAC token(不需要 MC 介入),FAA 端 middleware 多加一條「驗 visionA HMAC token」的路徑: @@ -528,41 +459,50 @@ browser → visionA /download → visionA 用 HMAC_KEY 簽 short-TTL token ↓ browser → FAA?access_token= ↓ - FAA middleware:API key (server-to-server) OR HMAC (browser direct) 二選一 + FAA middleware:JWT (s2s) OR delegated (current) OR HMAC (browser direct) 三選一 ``` -此升級路徑與本 ADR 的決策無衝突,記入 Phase 1 follow-up。 +此升級路徑與本 ADR v2.0 決策無衝突,記入 Phase 1 follow-up(同 v1.x 規劃)。 -### 8. user_id 注入 trust boundary 不變 +### 8. user_id 注入 trust boundary 不變(v1.x / v2.0 一致) ADR-014 §6「visionA backend 是唯一灌 user_id 的點」邏輯不變: - user_id 仍從 OIDC cookie session 拿(OIDC sub) - 仍透過 multipart streaming 注入 converter request 的 `user_id` field(converter 端視 visionA 為 trusted caller) -- API key 證明的是「caller 是 visionA」,user_id 的真實性由 visionA 內部的 OIDC 機制保證 — 兩條獨立鏈 +- API key(converter)/ service token(FAA)證明的是「caller 是 visionA」,user_id 的真實性由 visionA 內部的 OIDC 機制保證 — 兩條獨立鏈 -### 9. 部署層的 env 注入 +### 9. 部署層的 env 注入(v2.1 修訂) -Phase 0.8b 採用: +Phase 0.8b v2.1 採用(visionA 端 server-to-server secret 只剩 converter API key 一把): -| Env | Stage | Production | -|-----|-------|-----------| -| `VISIONA_CONVERTER_API_KEY`(visionA 端) | `.env.stage`(jimchen 持有,不進 git) | AWS Secrets Manager / Vault | -| `CONVERTER_API_KEY`(converter 端) | `.env`(jimchen 持有,不進 git) | 同上 | -| `VISIONA_FAA_API_KEY`(visionA 端) | `.env.stage` | 同上 | -| `FAA_API_KEY`(FAA 端) | warrenchen 設置 | 同上 | +| Env | Stage | Production | v2.1 變更 | +|-----|-------|-----------|----------| +| `VISIONA_CONVERTER_API_KEY`(visionA 端) | `.env.stage`(jimchen 持有,不進 git) | AWS Secrets Manager / Vault | 維持(v1.0 新增、v2.0 維持、v2.1 維持) | +| `CONVERTER_API_KEY`(converter 端) | `.env`(jimchen 持有,不進 git) | 同上 | 維持 | +| `VISIONA_CONVERTER_BASE_URL` | `.env.stage` | Secrets Manager | 維持(既有) | +| ~~`VISIONA_FAA_API_KEY`(visionA 端)~~ | — | — | **撤回**(v1.0 加的;v2.0 移除;v2.1 維持移除) | +| ~~`FAA_API_KEY`(FAA 端)~~ | — | — | **撤回**(v1.0 加的;v2.0 移除;v2.1 維持移除)| +| ~~`VISIONA_OIDC_SERVICE_CLIENT_ID`~~ | — | — | **v2.1 再次撤回**(v1.x 廢棄;v2.0 重新啟用;v2.1 撤回 v2.0 啟用)| +| ~~`VISIONA_OIDC_SERVICE_CLIENT_SECRET`~~ | — | — | **v2.1 再次撤回**(同上) | +| ~~`VISIONA_OIDC_TENANT_ID`~~ | — | — | **v2.1 再次撤回**(同上) | +| ~~`VISIONA_FAA_BASE_URL`~~ | — | — | **v2.1 撤回**(visionA 端不再直接打 FAA、走 converter 中轉)| -key 產生方式:`openssl rand -hex 32`(64 字元 hex) +key 產生方式: +- converter API key(兩端對齊):`openssl rand -hex 32`(64 字元 hex) +- ~~service client secret~~(v2.1 不需要) + +**v2.1 後 visionA 端 server-to-server 鏈路收斂為單條**:visionA → converter(API key),download 也走同一條(converter `GET /api/v1/jobs/{id}/result`,詳見 ADR-016)。 ## 考慮過的替代方案 (Alternatives Considered) -### 方案 A:維持 OAuth client_credentials(ADR-014 原方案) +### 方案 A:維持 OAuth client_credentials(ADR-014 原方案 — converter / FAA 兩條都走) | 評估 | 內容 | |------|------| | 優點 | 標準化、跨團隊可重用、短 TTL token、scope 細粒度可控 | -| 缺點 | 需要 MC team 配合 onboard scope、converter / FAA 都要重寫 middleware、JWKS 取得失敗時 graceful degrade 複雜、stage e2e 鏈路 4 個 blocker 全要修齊 | -| 排除原因 | 對 1:1 internal trust 場景過度設計;Phase 0.8 stage 部署實際遇到的 4 個 blocker 證明這條路 ROI 不好 | +| 缺點 | 需要 MC team 配合 onboard converter scope、converter / FAA 都要重寫 middleware、JWKS 取得失敗時 graceful degrade 複雜、stage e2e 鏈路 4 個 blocker 全要修齊 | +| 排除原因 | 對 1:1 internal trust 場景(converter)過度設計;Phase 0.8 stage 部署實際遇到的 4 個 blocker 證明這條路 ROI 不好 | ### 方案 B:mTLS(mutual TLS) @@ -586,72 +526,103 @@ key 產生方式:`openssl rand -hex 32`(64 字元 hex) |------|------| | 優點 | env 少一個、部署設定簡單 | | 缺點 | 一處洩漏兩處連坐;converter rotate 必須同步 FAA;違反「每條 trust boundary 各自獨立」原則 | -| 排除原因 | 每個下游各自獨立的 key 是低成本(只多一個 env),但隔離效益高,採方案決策的反方案 | +| 排除原因 | 在 v1.x 兩條都 API key 的設計中作為反方案被排除;v2.0 因 FAA 撤回 API key、議題自然不存在 | + +### 方案 E(v2.0 採用):visionA → converter API key、visionA → FAA 仍走 MC service token + delegated download token + +| 評估 | 內容 | +|------|------| +| 優點 | (1) 不必動 FAA repo(warrenchen 維護成本歸零);(2) 不必動 MC 端 scope onboarding(4242ba63 service client 已備妥 4 scope);(3) FAA 既有 dual-auth 設計(JWT for write / metadata / delete + delegated token for download)零修改;(4) converter 線 v1.0 已得到的「不必動 MC、不必協調」收益**對 converter 而言維持**;(5) FAA 線維持 OAuth 框架對 FAA 多 client 演進更友善(FAA 之後可能服務多個 visionA-like 產品線)| +| 缺點 | (1) visionA 仍要保留 mc_token_client 的 service token cache + delegated download token issue 邏輯(v1.x 砍掉的 ~440 行要部分復活);(2) MC 仍是 FAA 線的依賴(MC 掛 → FAA 用不了,但 converter 不受影響);(3) 兩條線的認證機制不對稱(converter API key、FAA OAuth),心智負擔略高 | +| 排除 v1.0 方案(兩條都 API key)的原因 | 使用者 2026-05-16 拍板:(1) 不希望動 FAA(共用 repo、warrenchen 維護);(2) 不希望動 MC(5/9 撞 scope 沒註冊的痛);(3) 但 5/16 提供的 service client `4242ba63...` 證明 MC 端針對 FAA 的 4 個 scope 已備妥 — v1.0 拒絕走 OAuth 的「MC scope 沒備妥」前提在 FAA 線**不成立**| +| 採用 | **v2.0 採用** | ## 後果 (Consequences) ### 正面影響 -- **實作大幅簡化**:visionA backend 砍 `internal/conversion/mc_token_client.go`(~440 行 — 含 token cache + delegated download token + double-checked locking + retry policy) -- **不依賴 MC scope onboarding**:MC team 完全不需介入,stage e2e blocker 從 4 個降到 0 -- **converter / FAA middleware 極簡**:兩端各自只需「比對單一字串 + constant-time compare」,無需驗 JWKS / scope / tenant;converter Phase 1 之前舊 image 可直接補 middleware 上線(不需 redeploy 大改) -- **失敗模式收斂**:原本「MC 5xx / MC 4xx / token cache miss / scope mismatch」四個失敗類型,收斂為「API key 對 / 不對」單一布林 -- **可觀測性減負**:不需追 token cache hit rate、不需追 MC 失敗率 -- **已洩漏的 stage service client secret 直接作廢**:不需協調 MC team rotate +- **converter 線實作大幅簡化**(v1.0 收益保留):visionA backend 對 converter 的呼叫不查 cache、不打 MC、不重簽 +- **converter / FAA stage e2e blocker 收斂**:converter 線 0 個 blocker;FAA 線靠使用者已備妥的 service client `4242ba63...` 驗證後即可上線(待 verify) +- **不必動 FAA repo、不必動 warrenchen**(v2.0 新增收益):v1.x 規劃要 warrenchen 配合改 FAA middleware 的工作完全取消 +- **不必動 MC scope onboarding**(部分 v2.0 新增收益):FAA 線 4 個 scope 已備妥;converter 線本來就不依賴 MC +- **converter middleware 極簡**(v1.0 收益保留):只需「比對單一字串 + constant-time compare」 +- **converter 失敗模式收斂**:原本「MC 5xx / MC 4xx / token cache miss / scope mismatch」四個失敗類型,收斂為「API key 對 / 不對」單一布林 +- **可觀測性減負(部分)**:converter 線不需追 token cache hit rate / MC 失敗率;FAA 線仍需追,但範圍縮小一半 +- **已洩漏的 stage service client secret `RciRUyi...` 直接作廢**:使用者於 v2.0 提供新 client `4242ba63...` 取代 ### 負面影響(接受的取捨) -- **API key 是 long-lived secret**:不像 OAuth token 有 TTL(通常 1 小時);rotate 需要 visionA + 下游同步換 env 並 redeploy;secret 管理責任更重 -- **沒有 scope 細粒度**:未來如果 visionA 內部要區分「能 init job 但不能 promote」這種需求,要回頭加(但 1:1 trust 場景幾乎不會有此需求) -- **沒有 audit trail(誰用 token 做什麼)**:OAuth + JWT 的 sub claim 提供天然 audit;API key 模式下 converter / FAA 只知道「是 visionA」,需要靠 visionA 內部 log + request_id 串接才能追到 user_id(既有 request_id 機制可滿足) -- **delegated download token 暫時不能用 302 redirect 模式**:Phase 0.8b 退回 server-side download proxy;Phase 1 量大時要另外設計(見 §7 選項 B) +- **converter 線 API key 是 long-lived secret**:不像 OAuth token 有 TTL(通常 1 小時);rotate 需要 visionA + converter 同步換 env 並 redeploy;secret 管理責任更重 +- **converter 線沒有 scope 細粒度**:未來如果 visionA 內部要區分「能 init job 但不能 promote」這種需求,要回頭加(但 1:1 trust 場景幾乎不會有此需求) +- **converter 線沒有 audit trail(誰用 token 做什麼)**:OAuth + JWT 的 sub claim 提供天然 audit;API key 模式下 converter 只知道「是 visionA」,需要靠 visionA 內部 log + request_id 串接才能追到 user_id(既有 request_id 機制可滿足) +- **FAA 線維持 OAuth client_credentials 鏈條的部分複雜度**(v2.0 新增):visionA 仍需 mc_token_client(service token cache + delegated download token issue + retry policy);MC 仍是 FAA 線的單點依賴;FAA 線的可觀測性負擔(token cache hit rate / MC 失敗率)保留 +- **兩條線認證機制不對稱**(v2.0 新增):converter 線 API key、FAA 線 OAuth;運維 / 排查時需區分兩種失敗模式(converter 401 = key 不同步;FAA 401 = service token 取得失敗 / scope mismatch / tenant mismatch / delegated token 過期) ### 風險 | 風險 | 緩解 | |------|------| -| API key 洩漏(git log、log shipping、Slack 訊息等)| (1) `.gitignore` 嚴格 ignore `.env*`;(2) log 不印 token;(3) stage / prod 用不同 key;(4) 一旦發現洩漏 → 換 env → redeploy(雙方協調 < 1 小時可完成)| -| Rotate 流程缺失 | 配套必須產出 rotate runbook(jimchen 對 converter,warrenchen 對 FAA) | -| 兩個 key 管理混淆(converter / FAA) | env 命名嚴格區分(`VISIONA_CONVERTER_API_KEY` / `VISIONA_FAA_API_KEY`);config validate 啟動時檢查兩個都非空 | -| 開發環境 / stage / prod 用同一把 key | 嚴格分環境產 key(dev / stage / prod 各自 `openssl rand -hex 32`),不重用 | +| converter API key 洩漏(git log、log shipping、Slack 訊息等)| (1) `.gitignore` 嚴格 ignore `.env*`;(2) log 不印 token;(3) stage / prod 用不同 key;(4) 一旦發現洩漏 → 換 env → redeploy(雙方協調 < 1 小時可完成)| +| converter API key Rotate 流程缺失 | 配套必須產出 rotate runbook(jimchen 對 converter)| +| FAA 線 service client secret 洩漏(v2.0 新增)| 同 v1.x 處理:MC team 換 client secret → visionA 同步 env → restart;運維事件,需跨 MC team 協調 | +| MC stage 端 4242ba63 service client 4 個 scope 是否真的有效 / 啟用(v2.0 新增)| **待 verify**:stage redeploy 前實測 `POST /oauth/token` 帶 4 scope 拿到 access_token;若部分 scope 缺失需 MC team 補(合規性段落追蹤)| +| FAA 線 MC 不可達 → 無法 issue delegated token → 下載失敗(v2.0 新增)| graceful degradation:對 frontend 回 502 `download_token_failed` / `mc_token_unavailable`(仍維持 ADR-014 §7 原 retry 矩陣)| +| 開發環境 / stage / prod 用同一把 converter API key | 嚴格分環境產 key(dev / stage / prod 各自 `openssl rand -hex 32`),不重用 | ## 合規性 -- [x] 與使用者確認:採 API key + Authorization Bearer + 每個下游獨立 key -- [x] 與 jimchen 確認(同時為 visionA + converter 維護者):converter middleware 改寫由 jimchen 處理 -- [ ] 與 warrenchen 確認:FAA 端 middleware 改寫由 warrenchen 處理(**待 Phase 0.8b 步驟 5**) -- [x] 與 ADR-014 對齊:本 ADR 部分 supersede ADR-014 §5 / §6(OAuth 段落) / §7(MC token retry rows),不影響 ADR-014 其他段落 +- [x] 與使用者確認:v2.0 範圍縮限至 visionA → converter API key;visionA → FAA 回到 ADR-014 §2 原設計(2026-05-16) +- [x] 與 jimchen 確認(同時為 visionA + converter 維護者):converter middleware 改寫由 jimchen 處理(沿用 v1.0 步驟 4 規劃) +- [x] ~~與 warrenchen 確認:FAA 端 middleware 改寫由 warrenchen 處理~~(**v2.0 撤回此項;FAA repo 不需改動**) +- [ ] **與 MC team 驗證 stage 端 4242ba63 service client 4 個 scope 都可用**(v2.0 新增;step 6 stage redeploy 前必驗): + - `files:upload.write` ✅ 對應 FAA `PUT /files/...` + - `files:metadata.read` ✅ 對應 FAA `GET /files/metadata/...` + `HEAD /files/...` + - `files:delete` ✅ 對應 FAA `DELETE /files/...` + - `files:download.delegate` ✅ 用來向 MC `POST /file-access/download-tokens` 換 delegated download token,再打 FAA `GET /files/{key}` +- [x] 與 ADR-014 對齊(v2.0 修訂):本 ADR v2.0 起對 ADR-014 的 supersede 範圍縮限至「§5 中 converter 部分 service token / `converter:job.write/read` scope」;ADR-014 §2 / §5 中 FAA 部分 / §6 / §7 中 FAA 與 MC delegated token row 全部恢復有效 - [x] 與 ADR-013 對齊:本 ADR 不影響 user login 的 public PKCE client -- [ ] DevOps rotate runbook 待產出(Phase 0.9 follow-up) -- [ ] 已洩漏的 stage service client secret `RciRUyi...` 自動作廢(不需 MC rotate) +- [ ] DevOps rotate runbook 待產出(Phase 0.9 follow-up;範圍縮限至 converter API key) +- [ ] 已洩漏的 stage service client secret `RciRUyi...` 自動作廢(不需 MC rotate;v2.0 由新 client `4242ba63...` 取代) ## 配套產出(給後續 Phase) -### Phase 0.8b 範圍內 +### Phase 0.8b 範圍內(v2.0 修訂) -- visionA backend 程式碼改造(backend agent 任務) -- converter middleware 改造(jimchen 跨 repo) -- FAA middleware 改造(warrenchen 跨 repo) -- `.env.stage.example` 更新(移除 service client env、新增 API key env) -- 設計文件更新(conversion.md / api-conversion.md / oidc-tdd.md — 本 ADR 同步產出) +- visionA backend 程式碼改造(backend agent 任務): + - converter_client.go 維持 v1.0 改造(API key) + - **faa_client.go 改回呼叫 MCTokenClient.ServiceToken() + 為 download 路徑增 DownloadWithDelegated 變體** + - **mc_token_client.go 部分復活**(service token cache + delegated download token issue 邏輯) + - flow.go download 路徑改回呼叫 IssueDelegatedDownload + DownloadWithDelegated + - config.go 重新啟用 OIDCConfig.ServiceClientID/Secret + ConversionConfig.TenantID +- converter middleware 改造(jimchen 跨 repo) — 維持 v1.0 規劃 +- ~~FAA middleware 改造(warrenchen 跨 repo)~~ — **v2.0 撤回** +- `.env.stage.example` 更新(v2.0 修訂): + - 維持 `VISIONA_CONVERTER_API_KEY` 新增 + - 撤回 `VISIONA_FAA_API_KEY` + - 加回 `VISIONA_OIDC_SERVICE_CLIENT_ID` / `VISIONA_OIDC_SERVICE_CLIENT_SECRET` / `VISIONA_OIDC_TENANT_ID` / `VISIONA_FAA_BASE_URL` + - **這部分 .env*.example 更新由 backend agent 下次任務(複活 mc_token_client 時)一併處理;本次架構任務範圍只動共享文件** +- 設計文件更新(conversion.md / api-conversion.md / oidc-tdd.md — 本 ADR v2.0 同步產出) ### Phase 0.9 / Phase 1 follow-up -- [ ] API key rotate runbook(每個下游一份,含「產新 key → 雙方同步 env → restart → 驗證 → 拔舊 key」步驟) -- [ ] 是否設 API key 有效期(例如 1 年到期自動提醒)— 由 SRE 流程決定 -- [ ] FAA 上「visionA 自己簽 HMAC token」delegated 機制(§7 選項 B),用於 download 路徑回 302 redirect -- [ ] 觀察 server-side download proxy 在 Phase 1 量大時的效能 / 頻寬 cost +- [ ] converter API key rotate runbook(含「產新 key → 雙方同步 env → restart → 驗證 → 拔舊 key」步驟;範圍縮限至 converter) +- [ ] 是否設 converter API key 有效期(例如 1 年到期自動提醒)— 由 SRE 流程決定 +- [ ] FAA 上「visionA 自己簽 HMAC token」delegated 機制(§7 選項 B),用於 download 路徑回 302 redirect(同 v1.x 規劃) +- [ ] 觀察 server-side download proxy 在 Phase 1 量大時的效能 / 頻寬 cost(同 v1.x 規劃) ## 相關文件 -- 部分 supersedes:[`adr-014-conversion-integration.md`](./adr-014-conversion-integration.md)(§5 / §6 OAuth 段落 / §7 MC rows) +- 部分 supersedes:[`adr-014-conversion-integration.md`](./adr-014-conversion-integration.md)(**v2.0 範圍縮限**:僅 §5 中 converter 部分 service token / scope;§2 / §5 中 FAA 部分 / §6 / §7 中 FAA + MC delegated token row 全部恢復有效) - 不影響:[`adr-013-public-client.md`](./adr-013-public-client.md)(user login 部分) -- 詳細實作(本 ADR 同步更新):`conversion.md`、`api/api-conversion.md`、`oidc-tdd.md` -- 觸發背景:`progress.md` 「Phase 0.8b 啟動原因(2026-05-11)」段落 +- 詳細實作(本 ADR v2.0 同步更新):`conversion.md` v0.5、`api/api-conversion.md` v0.5、`oidc-tdd.md` v0.3 +- 觸發背景:`progress.md` 「Phase 0.8b 啟動原因(2026-05-11)」+ 「Phase 0.8b 步驟 2 — Backend agent 任務範圍盤點」+ 「v2.0 範圍縮限(2026-05-16)」段落 +- FAA dual-auth 設計參考:`/Users/jimchen/file_access_agent/src/FileAccessAgent.Api/Program.cs` line 41-57(JWT Bearer 設定)、line 184-254(download endpoint 不掛 RequireAuthorization、用 IDelegatedDownloadTokenValidator)、line 291-322(EnsureJwtScopeAndTenant + HasScope) ## 版本記錄 | 日期 | 版本 | 變更 | |------|------|------| -| 2026-05-11 | 1.0 | 初版 — Phase 0.8b 將 server-to-server 認證從 OAuth client_credentials 改 pre-shared API key | +| 2026-05-11 | 1.0 | 初版 — Phase 0.8b 將 server-to-server 認證從 OAuth client_credentials 改 pre-shared API key(visionA → converter / FAA 兩條線都改)| | 2026-05-15 | 1.1 | 補 §3.5 Reference Middleware Implementation — Go (converter) + C# (FAA) snippet + 部署檢查清單,給跨 repo 改 middleware 時照抄 | +| 2026-05-16 | 2.0 | **範圍縮限** — 撤回 visionA → FAA API key 部分(FAA 不動、改回 ADR-014 §2 MC service token + delegated download token);visionA → converter API key 路線保留。撤回原因:(1) 使用者明示不希望動 FAA / MC、(2) 5/9 撞 MC scope 沒註冊的痛尚在、但 (3) 使用者今天提供 FAA stage service client (`4242ba63099d4f318dd3f143d27ef4c5`) 證明 MC 端 4 個 scope 都已備好(含 `files:download.delegate`)、舊路線可走通。涉及修訂:§2 整段標撤回 + 加 v2.0 設計(FAA dual-auth + delegated token download)、§3.5.2 C# FAA snippet 整段刪除、§5 tenant 概念分 converter/FAA 兩段、§6 mc_token_client 改部分復活 + OIDCConfig.ServiceClientID/Secret + ConversionConfig.TenantID 重新啟用、§7 download 路徑改回 delegated token(保留 server-side proxy 不退回 302)、§9 env 表加回 service client + tenant id + 撤回 FAA API key、新增方案 E 排除 v1.0 兩條都 API key 路線、後果重估、合規性「跟 warrenchen 確認」改為「跟 MC team 驗 4 scope」。對 commit `86b7175` 影響:visionA backend faa_client.go 要從 API key 改回 service token + delegated token(待下次 backend agent 處理);mc_token_client.go 要部分復活(保留 ServiceToken cache + IssueDelegatedDownload 邏輯);config.go 加回 ServiceClientID/Secret/TenantID 三欄位;`.env.stage.example` 加回對應 env、撤回 `VISIONA_FAA_API_KEY`。本次純文件修訂、source code 改造留給 backend agent 下次任務。 | +| 2026-05-16 | 2.1 | **§2 visionA → FAA 整段再次撤回** — 對 MC source 完整驗證後發現:(1) MC source 沒有 `POST /file-access/download-tokens` endpoint(visionA 無法跟 MC 換 delegated token)、(2) MC source 沒有 FAA `IDelegatedDownloadTokenValidator` assume 的 introspection endpoint(即使有 token 也無法 validate)、(3) FAA `GET /files/{key}` 強制只接 delegated token、不接 service token。→ ADR-014 §2 與本 ADR v2.0 §2 描述的「visionA → MC → FAA delegated token 鏈」**完全是 fictional**(從 2026-05-02 寫定起即為 broken design、未曾 e2e 跑通過)。**v2.1 採用 [ADR-016](./adr-016-download-via-converter.md)**(visionA download 改走 converter 新增的 `GET /api/v1/jobs/{id}/result` + visionA stream 中轉)。涉及修訂:狀態行加 v2.1 撤回說明、上位 ADR 區註明 §2 被 ADR-016 supersede、§2 整段標 v2.1 撤回(v2.0 內容保留歷史)、§9 env 表撤回 v2.0 加回的 `VISIONA_OIDC_SERVICE_CLIENT_ID/_SECRET` / `VISIONA_OIDC_TENANT_ID` / `VISIONA_FAA_BASE_URL`、改寫「v2.1 visionA 端 server-to-server 鏈路收斂為單條」。對 commit `86b7175` 影響:visionA backend mc_token_client.go **維持砍除狀態**(撤回 v2.0 「部分復活」規劃)、faa_client.go 改名為 converter_result_client.go(或併入 converter_client.go)、config.go 不需加回 ServiceClient* / TenantID / FAABaseURL、`.env.stage.example` 維持只有 converter env。本次純文件修訂、source code 改造留給 backend agent 下次任務(範圍含「converter 跨 repo 新增 GET result endpoint」)。**§1 visionA → converter API key(v1.0 / v2.0 / v2.1 都維持不變)**。 | diff --git a/docs/autoflow/04-architecture/adr/adr-016-download-via-converter.md b/docs/autoflow/04-architecture/adr/adr-016-download-via-converter.md new file mode 100644 index 0000000..adc0f27 --- /dev/null +++ b/docs/autoflow/04-architecture/adr/adr-016-download-via-converter.md @@ -0,0 +1,482 @@ +# ADR-016:visionA download NEF 改走 converter `GET /api/v1/jobs/{id}/result` 中轉(撤回 FAA 鏈) + +## 狀態 + +Accepted — 2026-05-16 + +## 上位 / 同層 ADR + +- **部分 supersedes**: + - [ADR-014](./adr-014-conversion-integration.md) §2「Download — FAA delegated token」整段(visionA → FAA delegated token 鏈撤回;visionA download 改走 converter `GET /api/v1/jobs/{id}/result`)。ADR-014 §2 中關於「server-side proxy / 不退回 302 / `` frontend pattern」的決策**沿用、由本 ADR 接手**。 + - [ADR-014](./adr-014-conversion-integration.md) §3「半自動 — 加到模型庫」中「visionA backend 跟 FAA pull NEF(scope `files:download.read`)」的部分:本 Phase 0.8 範圍內 visionA 不主動 pull NEF;如 Phase 1+ 需要 server-side pull,改走 converter `GET /api/v1/jobs/{id}/result` 同一條路徑。 + - [ADR-014](./adr-014-conversion-integration.md) §5「Service token cache — 仿 converter scheduler 模式」中 **FAA 部分**(scope `files:download.read / delegate` 不再由 visionA 取;對應 service client / tenant_id env 撤回)。 + - [ADR-014](./adr-014-conversion-integration.md) §7「失敗模式 retry 矩陣」中「FAA pull(加到模型庫)」、「MC token endpoint」、「MC delegated token」三 row(visionA 端不再觸發;converter 端有自己的 retry 矩陣,不在本 ADR 範圍)。 + - [ADR-015 v2.0](./adr-015-server-to-server-api-key.md) §2「visionA → FAA(MC service token + delegated download token)」整段(v2.0 於 §2 維持 ADR-014 §2 設計,本 ADR-016 於 §2 撤回;visionA 端 mc_token_client 整檔再次砍除,OIDC ServiceClient* / TenantID env 廢棄)。 + +- **沿用**(不受本 ADR 影響): + - [ADR-013](./adr-013-public-client.md):user login 的 OIDC public PKCE client(與 server-to-server 完全解耦) + - [ADR-014](./adr-014-conversion-integration.md) §1(upload streaming proxy)、§3「加到模型庫」走 visionA `/api/models/init+finalize`、§4 模組劃分(不擴 model schema、`internal/conversion/` 包裝)、§6 user_id trust boundary + - [ADR-015 v2.0](./adr-015-server-to-server-api-key.md) §1「visionA → converter 採 pre-shared API key」(本 ADR 在 download 路徑沿用同一條認證機制) + +- **同層**:本 ADR 與 ADR-014 / ADR-015 v2.0 三者並存後的最終態為: + - visionA → converter(init / poll / promote / **result download**):API key(ADR-015 §1) + - converter → FAA(promote 內部 PUT NEF):converter 自己用 OAuth client_credentials + scope `files:upload.write`(converter 既有實作,與 visionA 無關) + - visionA ↔ FAA:**Phase 0.8b 範圍內不存在這條鏈**(本 ADR 撤回) + - visionA ↔ MC(server-to-server):**Phase 0.8b 範圍內不存在這條鏈**(本 ADR 撤回;只有 user login 的 OIDC 公開 client 仍接 MC) + +--- + +## 背景 (Context) + +### 起因:致命發現(2026-05-16) + +2026-05-16 在 backend agent 準備依 ADR-015 v2.0 §6「mc_token_client 部分復活」實作之前,對 Innovedus Member Center(MC)與 File Access Agent(FAA)的 source code 做全面驗證,發現 **ADR-014 §2 設計的 delegated download token 鏈路從 2026-05-02 寫文件至今一直是斷的、只是因為 visionA 從未實際 e2e 跑通 download 而沒人發現**。 + +#### 致命發現 1:MC 沒有「issue delegated download token」endpoint + +對 `/Users/jimchen/member_center/src/MemberCenter.Api/Controllers/` 完整 grep(8 個 controller): + +``` +AdminNewsletterListsController.cs AdminOAuthClientsController.cs AdminTenantsController.cs +AuthController.cs NewsletterController.cs OAuthController.cs +SendEngineIntegrationController.cs SubscriptionsController.cs TokenController.cs +UserController.cs +``` + +全部 endpoint 清單: +- `POST /oauth/token`, `POST /oauth/authorize`(TokenController.cs / OAuthController.cs,OpenIddict OAuth/OIDC 標準) +- `POST /auth/login`, `/auth/refresh`, `/auth/logout`, `/auth/register`, `/auth/password/{forgot,reset}`, `/auth/email/verify`(AuthController.cs) +- `GET/PUT /user/profile`(UserController.cs) +- Admin endpoints(tenants / oauth-clients / newsletter-lists) +- `POST /integrations/send-engine/webhook-clients/upsert`(SendEngineIntegrationController.cs) +- `POST/GET /subscriptions`(SubscriptionsController.cs) +- `POST/GET /newsletter`(NewsletterController.cs) + +**MC source 完全沒有 `/file-access/download-tokens` endpoint,也沒有任何 file / download / token issuance 相關的 controller**。ADR-014 §2 line 60-68 描述的「MC POST /file-access/download-tokens」流程從未存在。 + +#### 致命發現 2:MC 沒有「validate delegated download token」endpoint + +FAA 端 `/Users/jimchen/file_access_agent/src/FileAccessAgent.Infrastructure/Services/MemberCenterDelegatedDownloadTokenValidator.cs` 線 27-72: + +```csharp +public async Task ValidateAsync( + DelegatedDownloadTokenValidationRequest request, + CancellationToken cancellationToken) +{ + var accessToken = await GetServiceAccessTokenAsync(cancellationToken); + // ... + using var response = await http.PostAsJsonAsync( + _options.DownloadTokenValidationPath, // ← 對 MC 打的 validation endpoint + payload, + cancellationToken); + // ... +} +``` + +FAA assume MC 有 `_options.DownloadTokenValidationPath`(一個 token introspection endpoint,例如 `POST /file-access/validate-file-download-token`),但對 MC source 全 grep `download-token` / `file-access` / `validate` 都找不到對應 controller。 + +→ 即使 visionA 哪天真的拿到一個 delegated download token、帶去打 FAA `GET /files/{key}`,FAA 也會在 `IDelegatedDownloadTokenValidator.ValidateAsync()` 階段因為 MC 不回 200 而把 token 視為 invalid,回 403。 + +#### 致命發現 3:ADR-014 從一開始就 assume 而沒驗 source + +對 ADR-014 line 17-18 寫的: + +> **Innovedus Member Center (MC)** 是 OAuth/OIDC IdP,**同時負責簽 service-to-service token 與 delegated download token** + +line 75: + +> server-to-server 跟 MC 換 delegated token(scope `files:download.delegate`) + +→ 2026-05-02 寫 ADR-014 時沒實際讀 MC source、直接 assume「MC 有 delegated token endpoint」。整條 delegated token 鏈路是 fictional。 + +ADR-015 v2.0 §2 line 109-153(2026-05-16 上午寫)也沿用這個 assumption(雖然 v2.0 描述的設計「正確的」是 ADR-014 §2 原意圖,但 ADR-014 §2 原意圖本身就建立在錯誤的 MC 能力假設上)。 + +#### 致命發現 4:FAA download endpoint 對 service token 也不接受 + +`/Users/jimchen/file_access_agent/src/FileAccessAgent.Api/Program.cs` line 184-254:`GET /files/{**objectKey}` **沒掛 `.RequireAuthorization()`**,改用 `IDelegatedDownloadTokenValidator.ValidateAsync(...)` 驗 delegated download token。 + +→ FAA download endpoint 強制只接 delegated token、不接 service token。 +→ 即使 visionA 從 MC 拿到 valid service token(這條鏈是真的 work 的,已有實證——converter promote 走 client_credentials + `files:upload.write` scope 正常 work),帶去打 FAA `GET /files/{key}` 也會 401 / 403。 +→ visionA 端**唯一**能 download FAA 上的 NEF 的方法是先取得 delegated token;但 MC 沒有 issue delegated token 的 endpoint(致命發現 1)。 + +#### 為什麼這個缺口從 2026-05-02 一直沒被發現 + +時間軸: + +| 日期 | 事件 | 影響 | +|------|------|------| +| 2026-05-02 | ADR-014 v1.0 寫定 | assume MC 有 delegated token endpoint | +| 2026-05-09 ~ 11 | Phase 0.8 stage 部署遇 4 個 OAuth client_credentials blocker(converter `:read/write` scope 沒 onboard / converter image 舊版 / converter MC env / FAA OAuth 整合不明)| 發現的是 converter 線、不是 FAA delegated token 線 | +| 2026-05-11 | ADR-015 v1.0:兩條都改 API key | FAA 線改 API key、跳過了 delegated token 鏈、致命缺口被掩蓋 | +| 2026-05-15 | ADR-015 v1.1:補 reference middleware | 仍是 API key 路徑 | +| 2026-05-16(上午) | ADR-015 v2.0:撤回 FAA API key、回到 ADR-014 §2 delegated token 設計 | **重新踩到 2026-05-02 的缺口**(但寫 v2.0 時還沒驗 MC source、再次 assume)| +| 2026-05-16(下午 / 本 ADR) | 對 MC source 全 grep 確認 → 缺口從未修復 | 撤回 v2.0 §2、改走 converter 中轉 | + +**Phase 0.8 從來沒有真的把 download 跑到 e2e**:T4 frontend `` 觸發 download 的測試都是對 stub backend,從未真的觸發 visionA → MC issue delegated token → FAA validate 整條鏈,所以 4 處致命缺口疊起來成為一個從未被執行的 dead path。 + +### 使用者拍板的硬約束 + +2026-05-16 在發現上述缺口後,使用者明確拍板: + +| 約束 | 理由 | +|------|------| +| **不動 MC** | MC 是公司共用 IdP;新加 endpoint 需 MC team 設計 + onboard scope + redeploy + 配 tenant,跨人協調週期長;過去 5/9 撞 scope 沒註冊已驗證此風險 | +| **不動 FAA** | FAA 是 warrenchen 維護的公司共用 repo;既有 dual-auth 設計(JWT for write / delegated for download)改一行都要走跨人協調 | +| 但要解決 download | PRD Phase 0.8 範圍內 user 要能下載 NEF(半自動「下載」按鈕 + Wireframe success card 都有) | + +→ 解決方案必須在 jimchen 自己維護的 repo(visionA + converter)內完成、不觸碰 MC / FAA。 + +### 既有可用基礎建設 + +| 元件 | 狀態 | 可用性 | +|------|------|--------| +| converter promote → FAA PUT NEF(OAuth + `files:upload.write`) | Phase 1 已上線 | ✅ work(已在 prod 跑 / `apps/task-scheduler/src/fileAccessAgent/client.js`)| +| converter MinIO 存 NEF | converter scheduler 既有 | ✅ promote 後 NEF 同時保留在 converter MinIO + FAA(converter expires_at = 7 天)| +| visionA → converter API key(ADR-015 §1)| commit `86b7175` 已實作 | ✅ work(init / poll / promote 都已驗證)| +| converter scheduler 是 jimchen 自己 repo | — | ✅ 加新 endpoint 對 coordination cost 低 | +| visionA backend stream proxy 結構(T4 已實作 `internal/conversion/faa_client.go` `DownloadStream` + `io.CopyN` size cap + `Content-Disposition` + context cancellation)| commit `e02059e` 已實作 | ✅ 結構保留、只是 stream 來源從 FAA 改 converter | + +--- + +## 決策 (Decision) + +### 採 **converter 加 `GET /api/v1/jobs/{id}/result` endpoint + visionA stream 中轉** 解決 download;promote 路徑不變。 + +### 1. converter 新增 endpoint:`GET /api/v1/jobs/{id}/result` + +#### 1.1 完整 API spec + +``` +GET /api/v1/jobs/{id}/result HTTP/1.1 +Host: 192.168.0.130:9501 +Authorization: Bearer +Accept: application/octet-stream +``` + +| 元素 | 規格 | +|------|------| +| Method | `GET` | +| Path | `/api/v1/jobs/:id/result` | +| Path param `id` | converter job id(UUID v4) | +| Auth | `Authorization: Bearer `(同 ADR-015 §1 既有 middleware;converter middleware `subtle.ConstantTimeCompare` 比對 env) | +| Query params | 無 | +| Request body | 無 | + +#### 1.2 Response — 成功(200) + +``` +HTTP/1.1 200 OK +Content-Type: application/octet-stream +Content-Length: +Content-Disposition: attachment; filename="_.nef" +Cache-Control: no-store, no-cache, must-revalidate, max-age=0 + + +``` + +| Header | 來源 / 規格 | +|--------|-----------| +| `Content-Type` | 固定 `application/octet-stream` | +| `Content-Length` | converter MinIO 物件實際大小(必填,給 visionA backend 設 Content-Length 透傳到 browser)| +| `Content-Disposition` | converter 自行構造 filename:`_.nef`(例 `yolov5s_kl720.nef`,對齊 wireframe success card 顯示範例)。**visionA backend 收到後可選擇透傳或覆寫**(visionA 端有自己的 `defaultDownloadFilename(cj)` helper)| +| `Cache-Control` | 固定 `no-store, no-cache, must-revalidate, max-age=0` | +| Body | NEF binary stream(converter 從 MinIO get object 後直接 stream 出來) | + +#### 1.3 Response — 錯誤 + +| HTTP | code | 條件 | converter 回 body(JSON) | +|------|------|------|--------------------------| +| 401 | `unauthorized` | API key 不對 / 缺 / 格式錯 | `{"error":"unauthorized"}`(同 ADR-015 §3.5.1 既有 middleware)| +| 404 | `job_not_found` | job_id 不存在 / 已被 GC(converter 7 天 expires_at)| `{"error":"job_not_found"}` | +| 409 | `job_not_completed` | job 尚未 completed(status ≠ completed)| `{"error":"job_not_completed","status":""}` | +| 410 | `result_expired` | job completed 但 MinIO 內 NEF 已被 GC(超 expires_at) | `{"error":"result_expired"}` | +| 500 | `internal_error` | converter / MinIO 故障 | `{"error":"internal_error"}` | +| 502 | `storage_unavailable` | MinIO 5xx / 不可達 | `{"error":"storage_unavailable"}` | +| 503 | `service_busy` | converter 過載 | `{"error":"service_busy"}` | + +#### 1.4 Size cap + +converter 端不設 size cap(converter MinIO 容量為準)。visionA backend 端用 `io.CopyN(w, stream, 1 GiB)` 設 1 GiB 上限保護(同 conversion.md §4.1 既有設計)。 + +#### 1.5 與既有 endpoint 的關係 + +converter 既有 `POST /api/v1/jobs/:id/download-tokens` 是 Phase 2 預留 501(見 `apps/task-scheduler/src/routes/v1/jobs.js` line 1059-1066)。本 ADR 新增的 `GET /api/v1/jobs/:id/result` 是 Phase 0.8b 的「直接 stream 中轉」實作,**不取代** Phase 2 的 download-tokens endpoint(後者是「給 browser 直連 converter」設計,Phase 2 才評估)。 + +#### 1.6 converter 端實作要點(給 jimchen 跨 repo 任務) + +- Route 加在 `apps/task-scheduler/src/routes/v1/jobs.js`(或新建 `result.js` 並在 `v1/index.js` `router.use(...)`) +- 套用既有 `requireReadAuth` middleware(與 `GET /api/v1/jobs/:id` 同一 scope;API key 比對由 middleware 統一) +- handler 流程: + 1. 從 jobService 查 job(status 必須是 `completed`、未過 expires_at) + 2. 從 MinIO get object(`target_object_key` 或 promote 後同步保留在 MinIO 的物件 key) + 3. set Content-Type / Content-Length / Content-Disposition / Cache-Control headers + 4. `pipeline(minioStream, res)` 直接 stream(不暫存記憶體) +- Test 加 `apps/task-scheduler/src/routes/v1/__tests__/result.integration.test.js`(mock MinIO + jobService) + +### 2. visionA download flow(撤回 FAA / MC 鏈) + +#### 2.1 流程 + +``` +Browser ──GET /api/conversion/{job_id}/download (Cookie: visiona_session)──► visionA backend + ↓ + AuthMiddleware (OIDC cookie) + ↓ + ownership 檢查 (user_id ↔ job_id) + ↓ + ensurePromoted (對 converter 冪等 promote) + ↓ + converter_client.GetResult(ctx, job_id) + GET {CONVERTER_BASE_URL}/api/v1/jobs/{id}/result + Authorization: Bearer + ↓ + (converter middleware: ConstantTimeCompare) + ↓ + 200 NEF stream + Content-Length + Content-Disposition + ↓ + visionA backend handler: + - Content-Type: application/octet-stream + - Content-Length: + - Content-Disposition: attachment; filename="..." + (visionA 用自己的 defaultDownloadFilename(cj) 覆寫、保留與 wireframe 對齊) + - Cache-Control: no-store, no-cache, must-revalidate + - io.CopyN(c.Writer, stream, 1 GiB) + ↓ + Browser 收到 200 + attachment + 自動觸發下載 +``` + +#### 2.2 visionA backend 既有 stream proxy 結構保留 + +T4 已實作的 `internal/conversion/faa_client.go` 的 stream proxy 結構(io.CopyN + size cap + Content-Disposition + context cancellation)**全部沿用**,只是 stream 來源從 FAA 改 converter。 + +對應的程式碼變更(給 backend agent 下次任務): + +- `internal/conversion/faa_client.go` → **改名** `internal/conversion/converter_result_client.go`(或合併進 `converter_client.go`);移除 `DownloadWithDelegated` 簽名;新增 `GetResult(ctx, jobID) (io.ReadCloser, *DownloadMetadata, error)` +- `internal/conversion/flow.go` DownloadStream 內部:移除「IssueDelegatedDownload」步驟、直接呼叫 `converter.GetResult` +- `internal/conversion/mc_token_client.go` **整檔刪除**(ADR-015 v2.0 §6 部分復活 → 本 ADR 再次砍除;Phase 0.8b visionA 端不再有任何 visionA → MC server-to-server 路徑) +- `internal/conversion/conversion.go` Service interface:`DownloadStream` 簽名不變(仍回 `(io.ReadCloser, *DownloadMetadata, error)`) +- handler `internal/api/conversion.go`:`conversionDownloadHandler` 完全不動 + +### 3. Promote 路徑完全不變 + +promote 仍走 visionA → converter `POST /api/v1/jobs/{id}/promote`: + +``` +Browser ──POST /api/conversion/{job_id}/promote-to-models──► visionA backend + ↓ + converter POST /api/v1/jobs/{id}/promote + Authorization: Bearer + ↓ + converter 內部:用自己的 OAuth client + scope files:upload.write + PUT FAA /files/{target_object_key} + ↓ + promote 成功;NEF 同時在 FAA 與 converter MinIO + ↓ + 回 {target_object_key} 給 visionA + ↓ + visionA backend 用既有 /api/models/init + /api/models/finalize 流程把 NEF 寫進 visionA model store + ↓ + (但注意:visionA 自己不 pull FAA;走的是 converter.GetResult 同一條路徑) +``` + +「加到模型庫」流程中 visionA 需要拿到 NEF binary 寫進 model store——本 ADR 後此路徑也走 `converter.GetResult`(與 download 共用同一條),visionA 完全不直接打 FAA。 + +**converter → FAA 這條鏈仍是 OAuth client_credentials + `files:upload.write` scope**(converter 自己管,與 visionA 無關;本 ADR 不影響)。 + +### 4. 認證統一:visionA 端只需 `VISIONA_CONVERTER_API_KEY` + +| 認證 secret | 用途 | Phase 0.8b v0.5(ADR-015 v2.0)| **Phase 0.8b v0.6(本 ADR)** | +|------------|------|------------------------------|-----| +| `VISIONA_CONVERTER_API_KEY` | visionA → converter(init / poll / promote / **download**)| ✅ 使用中 | ✅ 使用中(**新增 download 走同一把**)| +| `VISIONA_OIDC_SERVICE_CLIENT_ID` | visionA → MC 換 service token(給 FAA 線用)| ✅ 重新啟用 | ❌ **再次廢棄**(撤回 v2.0 復活) | +| `VISIONA_OIDC_SERVICE_CLIENT_SECRET` | 同上 | ✅ 重新啟用 | ❌ **再次廢棄** | +| `VISIONA_OIDC_TENANT_ID` | FAA tenant_id claim 驗證 | ✅ 重新啟用 | ❌ **再次廢棄**(visionA 端不再需要)| +| `VISIONA_FAA_BASE_URL` | visionA → FAA 的 base URL | ✅ 使用中 | ❌ **再次廢棄**(visionA 端不再直接打 FAA)| +| `VISIONA_FAA_API_KEY` | (v1.0 加的)| ❌ 撤回 | ❌ 維持撤回(v2.0 已撤回) | + +→ visionA 端 server-to-server 認證 secret 只剩 `VISIONA_CONVERTER_API_KEY` 一把(user login 的 OIDC public PKCE 仍維持 ADR-013 設計、不變)。 + +### 5. visionA backend mc_token_client 整檔刪除(撤回 v2.0 部分復活) + +ADR-015 v2.0 §6 規劃「mc_token_client.go 部分復活」(保留 ServiceToken cache + IssueDelegatedDownload 邏輯)—— 本 ADR 撤回此規劃。 + +- mc_token_client.go 整檔刪除(commit `86b7175` 已經砍掉、不需重建) +- mc_token_client_test.go 整檔刪除 +- `OIDCConfig.ServiceClientID` / `ServiceClientSecret` 兩欄位從 struct 刪除 +- `ConversionConfig.TenantID` 從 struct 刪除 +- main.go 不再 wire `MCTokenClient` + +--- + +## 考慮過的替代方案 (Alternatives Considered) + +### 方案 A(採用):converter 新增 `GET /api/v1/jobs/{id}/result` + visionA stream 中轉 + +| 評估 | 內容 | +|------|------| +| 優點 | (1) 不動 MC、不動 FAA、不動 warrenchen、不動公司 MC team;(2) 認證統一 API key(jimchen 自己管 rotate);(3) visionA backend 既有 stream proxy 結構保留、改動範圍小;(4) 失敗模式少(只剩 converter 401 / 4xx / 5xx);(5) 修復 ADR-014 §2 從 2026-05-02 起的設計缺口(delegated token 鏈本來就 broken);(6) converter 與 visionA 都是 jimchen 維護、coordination 成本最低;(7) 與 Phase 1 follow-up「converter 加 download-tokens」路徑相容(Phase 2 升級時這個 GET result endpoint 可保留為「server-side pull」用途)| +| 缺點 | (1) converter 變 download bottleneck(visionA 跨 internet 從 converter stream、再中轉給 user,雙倍流量);(2) converter MinIO 的 retention 變成 download 可用性 SLA(converter 7 天 expires_at = 7 天後 user 不能 download);(3) converter API 變 download 的單點故障(converter down → user 不能 download,即使 FAA 上有 NEF);(4) 跨 repo 任務(converter scheduler 加 endpoint)— 但因 jimchen 維護兩端、cost 可控 | +| 排除原因 | **未排除 — 採用** | + +### 方案 B:metadata-only 模型庫(NEF 永遠在 converter MinIO、不 promote 到 FAA) + +| 評估 | 內容 | +|------|------| +| 優點 | (1) 不必動 FAA / MC;(2) promote 流程簡化(converter 自己保留 NEF、不 PUT FAA) | +| 缺點 | (1) Phase 1 已上線的 converter → FAA promote 流程要拆掉(converter 端改造範圍大);(2) **retention 議題嚴重**:converter MinIO 7 天 expires_at = NEF 7 天後消失、但 visionA model store 設計是「model 是持久資產」、矛盾;(3) 若要延長 converter MinIO retention 必須改 converter scheduler GC 邏輯、影響其他 converter 用途;(4) visionA 「加到模型庫」流程從「pull NEF 進 visionA 自己 storage」變成「永遠依賴 converter MinIO」,違反 ADR-014 §3「加到模型庫 = 進 visionA storage 給後續 device load 用」原意 | +| 排除原因 | **retention 模型不可接受**——「加到模型庫」是 user 預期能「永久持有」的動作 | + +### 方案 C:converter promote 改成「直接 stream NEF 回 visionA」 + +| 評估 | 內容 | +|------|------| +| 優點 | promote 同時完成「NEF 已給 visionA」,少一次 round-trip | +| 缺點 | (1) promote 是 POST + idempotent 設計、回 stream 違反 REST semantic;(2) promote 同時要做兩件事(推 FAA + 回 stream),失敗模式爆炸(推 FAA 成功但回 stream 失敗時怎麼辦?);(3) frontend wireframe 已設計「先 promote、後 user 選下載 / 加到模型庫」的兩步流程、要改 UX;(4) 「下載」按鈕本來就是 demand-trigger(user 可能 promote 完很久才下載)、把 stream 綁在 promote 不符合使用場景 | +| 排除原因 | **promote 設計大改、UX 倒退** | + +### 方案 D:visionA 自己簽 HMAC token、要求 FAA 加新 auth path + +| 評估 | 內容 | +|------|------| +| 優點 | visionA 不依賴 MC、token 機制比 API key 細粒度(短 TTL + 綁 object_key + 綁 method) | +| 缺點 | (1) **動 FAA**:warrenchen 要在 FAA dual-auth(JWT + IDelegatedDownloadTokenValidator)外再加第三條「visionA HMAC」路徑、改公司共用 repo;(2) HMAC secret 需 visionA + FAA 同步管理(與 API key 一樣 long-lived,但多一層複雜度);(3) ADR-015 v2.0 §7 「選項 B」已記為 Phase 1+ follow-up(量大需回 302 redirect 時才考慮)、Phase 0.8b 範圍內動 FAA cost 過高 | +| 排除原因 | **使用者硬約束「不動 FAA」**;保留為 Phase 1+ 選項(量大時回 302) | + +### 方案 E:協調 MC team 加 「issue + validate delegated download token」endpoint + +| 評估 | 內容 | +|------|------| +| 優點 | 修復 ADR-014 §2 原設計(讓那條鏈真的可走通)、長期看是 OAuth 框架的正確補完 | +| 缺點 | (1) **動 MC**:MC team 設計 → onboard scope → 註冊 token endpoint → 配 tenant → redeploy;2026-05-09 撞 MC scope 沒註冊已驗證跨人協調週期長;(2) 即使 MC 補了 endpoint、FAA 端 `MemberCenterDelegatedDownloadTokenValidator._options.DownloadTokenValidationPath` 還要對齊配置;(3) 為了 Phase 0.8b 一個 download path 動兩個公司共用 repo 不划算;(4) Phase 1+ 後若決定走 OAuth 框架可重新評估 | +| 排除原因 | **使用者硬約束「不動 MC、不動 FAA」**;長期可重啟此方案(Phase 1+) | + +### 方案 F:放棄 download 功能(只保留「加到模型庫」) + +| 評估 | 內容 | +|------|------| +| 排除原因 | **PRD Phase 0.8 明確含 download**(wireframe success card「下載」按鈕 + flow-conversion.md 半自動分流);不能放棄 | + +--- + +## 後果 (Consequences) + +### 正面影響 + +- **修復 ADR-014 §2 從 2026-05-02 起的設計缺口**:delegated download token 鏈從未存在,本 ADR 撤回對其的依賴後 visionA download 不再有 dead path +- **不動 MC、不動 FAA、不動公司 MC team / warrenchen**:跨人協調成本歸零,落地速度最快 +- **認證統一**:visionA 端 server-to-server secret 只剩 `VISIONA_CONVERTER_API_KEY` 一把;MC service client / tenant_id 配置全部撤回,env 表簡化 +- **失敗模式收斂**:原本「visionA → MC 4xx/5xx + MC → service token + MC → delegated token + FAA → service token validate + FAA → delegated token validate」5 條 fail path 收斂為「converter 401 / 4xx / 5xx」3 條 +- **可觀測性減負**:visionA 端不需追 MC token cache hit rate、MC 失敗率、delegated token issue 失敗率 +- **mc_token_client.go 整檔可刪**:commit `86b7175` 已砍掉、本 ADR 確認不需復活(撤回 ADR-015 v2.0 §6 部分復活規劃),visionA backend 程式碼庫進一步簡化 +- **與 frontend 對 user 完全透明**:response shape / call pattern / 錯誤文字一律不變;只是 visionA backend 內部 stream 來源演進 + +### 負面影響(接受的取捨) + +- **converter 變 download bottleneck**:原 ADR-014 §2 設計是「browser 直連 FAA」、流量 1×;ADR-015 v2.0 改 server-side stream proxy 後變 2×(FAA → visionA → browser);本 ADR 後仍是 2× 但路徑改 `converter MinIO → visionA → browser`(流量 cost 與 v2.0 相同、但 hop 改變) +- **converter MinIO retention 變 download SLA**:converter 7 天 expires_at = 7 天後 user 不能 download。對應的 frontend UX: + - `expires_at` 仍由 visionA 透傳給 frontend(`conversion.md` §2.6.2 既有設計) + - frontend 在 success card 顯示倒數(「6 天 21 小時後自動清除」) + - 過期後按「下載」會收 410 `result_expired`(visionA backend 把 converter 410 透傳) +- **converter 是 download 單點故障**:converter scheduler 掛 → user 不能 download,即使 FAA 上有 NEF(沒辦法繞 converter 直接拉 FAA,因為 visionA 沒有 delegated token 路徑) +- **「加到模型庫」流程也依賴 converter**:visionA 端 server-side pull NEF 從「直接 FAA」改成「converter result endpoint」、多一 hop(converter 從 MinIO get object 後 stream 給 visionA) +- **與 Phase 1+「browser 直連 converter」路徑稍有不一致**:converter Phase 2 預留的 `POST /api/v1/jobs/:id/download-tokens` endpoint 是給 browser 直連 converter 用、與本 ADR 新增的 `GET /api/v1/jobs/:id/result` 是兩條設計不同的 path。Phase 2 升級時需評估兩條 path 是否共存或合併 + +### 風險 + +| 風險 | 緩解 | +|------|------| +| converter MinIO 容量規劃失準(大量 user promote 後 NEF 累積)| converter 既有 GC(7 天 expires_at)即會自動清理;Phase 1+ 可加 retention 監控 | +| converter scheduler 在 Phase 0.8b 量小可承受 download bottleneck、但 Phase 1+ 量大時不行 | Phase 1+ 升級到方案 D(visionA HMAC token + FAA 加第三條 auth path + 回 302 redirect);本 ADR 不阻擋此升級路徑 | +| converter 加新 endpoint 期間(jimchen 跨 repo)若部署不同步 → visionA download 404 | (1) 加 endpoint 是純增量改動、不影響既有 endpoint;(2) visionA backend 在 converter 端 endpoint 上線前部署也安全(既有 download path 會直接 404、不會破壞其他功能);(3) 部署順序:先 converter ship → 再 visionA ship | +| converter MinIO 物件不在(job 過期、或 promote 沒同步寫到 MinIO)| converter 端 handler 處理(410 `result_expired`);visionA backend 透傳給 frontend | +| converter 端 result endpoint 實作 bug(stream pipeline 寫錯導致記憶體爆)| 加 integration test(mock MinIO 大檔,驗 stream 不暫存)| + +--- + +## 配套產出(給後續 Phase) + +### Phase 0.8b 範圍內(本 ADR 觸發的 follow-up) + +#### A. converter 跨 repo(jimchen) + +- 新增 `apps/task-scheduler/src/routes/v1/result.js`(或加進 `jobs.js`):`GET /api/v1/jobs/:id/result` handler +- 套用既有 `requireReadAuth` middleware(API key 比對) +- handler 內:job status / expires_at 檢查 → MinIO stream pipeline → 設 headers +- 整合測試:`apps/task-scheduler/src/routes/v1/__tests__/result.integration.test.js` +- 更新 `apps/task-scheduler/openapi.yaml`(加 `GET /api/v1/jobs/{id}/result` path) +- 更新 `apps/task-scheduler/README.md`(API 清單加新 endpoint) + +#### B. visionA backend(backend agent 下次任務) + +- 刪 `internal/conversion/mc_token_client.go`(commit `86b7175` 已砍、確認狀態) +- 刪 `internal/conversion/mc_token_client_test.go`(確認狀態) +- 改 `internal/conversion/faa_client.go`(**改名** `converter_result_client.go` 或併入 `converter_client.go`): + - 移除 `DownloadWithDelegated(ctx, delegatedToken, objectKey)` + - 新增 `GetResult(ctx, jobID) (io.ReadCloser, *DownloadMetadata, error)`,內部打 `GET {ConverterBaseURL}/api/v1/jobs/{jobID}/result` + `Authorization: Bearer ` +- 改 `internal/conversion/flow.go`: + - 移除 `tokens *MCTokenClient` 欄位(撤回 v2.0 §6 加回) + - `DownloadStream` 內部:移除 `IssueDelegatedDownload` 步驟、改呼叫 `converter.GetResult` + - `PromoteToModels` 內部(如需要從 FAA pull NEF):同樣改呼叫 `converter.GetResult` +- 改 `internal/config/config.go`: + - `ConversionConfig.TenantID` 欄位刪除(撤回 v2.0 復活) + - `ConversionConfig.FAABaseURL` 欄位刪除(visionA 不再直接打 FAA) + - `OIDCConfig.ServiceClientID` / `ServiceClientSecret` 欄位刪除 + - `Enabled()` 簡化:只判 `ConverterBaseURL != "" && ConverterAPIKey != ""` +- 改 `cmd/api-server/main.go`:移除 wire `MCTokenClient`、移除傳 FAA 相關 config +- 改 `.env.stage.example` / `.env.dev.example`: + - 移除 `VISIONA_OIDC_SERVICE_CLIENT_ID` / `_SECRET` / `VISIONA_OIDC_TENANT_ID` / `VISIONA_FAA_BASE_URL` + - 保留 `VISIONA_CONVERTER_BASE_URL` / `VISIONA_CONVERTER_API_KEY` +- 改對應 unit / integration test:移除 mc_token / faa_client_test 中對 service token / delegated token 的測試;補 `converter_result_client_test.go` + +### Phase 0.9 / Phase 1+ follow-up + +- [ ] converter API key rotate runbook(範圍縮限至 converter,沿用 ADR-015 v2.0 規劃) +- [ ] 觀察 converter result endpoint 在 Phase 1 量大時的效能 / 頻寬 cost +- [ ] 若量大需回 302 redirect 模式 → 重新評估方案 D(visionA HMAC token + FAA 加第三條 auth path) +- [ ] 若 Phase 2+ 要回 OAuth 框架 → 重新評估方案 E(協調 MC / FAA team 補完 delegated token 鏈) + +--- + +## 合規性 + +- [x] 與使用者確認:本 ADR 採方案 A(converter 加 GET result endpoint)+ 撤回 visionA → FAA / MC 所有 server-to-server 路徑(2026-05-16) +- [x] 與 jimchen 確認(同時為 visionA + converter 維護者):converter scheduler 加新 endpoint 由 jimchen 處理 +- [x] 對 MC source 完整 grep 驗證:MC 沒有 issue / validate delegated download token 的 endpoint(致命發現 1 + 2 + 4) +- [x] 對 FAA source 驗證:`MemberCenterDelegatedDownloadTokenValidator.cs` 確實 assume MC 有 validation endpoint、且 FAA download endpoint 強制只接 delegated token(致命發現 4) +- [x] 對 ADR-014 / ADR-015 v2.0 對齊:本 ADR 部分 supersede 兩者(ADR-014 §2 整段 + ADR-015 v2.0 §2 整段) +- [x] 對 ADR-013 對齊:user login 的 OIDC public PKCE client 不受影響 +- [x] 與 converter Phase 1 對齊:promote 流程不變、converter → FAA 路徑(OAuth + `files:upload.write`)不變 +- [ ] ~~與 MC team 協調補 delegated token endpoint~~(撤回,本 ADR 不動 MC) +- [ ] ~~與 warrenchen 協調 FAA dual-auth 改造~~(撤回,本 ADR 不動 FAA) +- [ ] DevOps:確認 converter 新 endpoint 上線時序(先 converter ship → 再 visionA ship);rotate runbook follow-up + +--- + +## 相關文件 + +- 部分 supersedes: + - [`adr-014-conversion-integration.md`](./adr-014-conversion-integration.md)(§2 download 整段 + §3 加到模型庫的 FAA pull 部分 + §5 FAA service token cache 部分 + §7 retry 矩陣 FAA/MC row) + - [`adr-015-server-to-server-api-key.md`](./adr-015-server-to-server-api-key.md) v2.0(§2 visionA → FAA 整段) +- 不影響: + - [`adr-013-public-client.md`](./adr-013-public-client.md)(user login 部分) + - [`adr-014-conversion-integration.md`](./adr-014-conversion-integration.md) §1(upload streaming proxy)、§4(模組劃分)、§6(user_id trust boundary) + - [`adr-015-server-to-server-api-key.md`](./adr-015-server-to-server-api-key.md) v2.0 §1(visionA → converter API key) +- 詳細實作(本 ADR 同步更新): + - `conversion.md` v0.6(§1 整體 flow、§2 模組設計、§3 服務間認證、§4.1 download handler、§6 錯誤碼、§9 retry 矩陣、§10 安全考量、變更影響清單) + - `api/api-conversion.md` v0.6(檔頭 Auth metadata、§3 promote 與 §4 download 錯誤碼、錯誤碼總覽、版本記錄) + - `oidc-tdd.md` v0.4(Metadata 區範圍說明、§13.1 環境變數表、§13.1.1 stage env 範例、§13.1.3 server-to-server 雙線設計改寫) +- Source code 證據: + - MC controller 列表:`/Users/jimchen/member_center/src/MemberCenter.Api/Controllers/*.cs`(8 個檔、無 file/download/token issuance endpoint) + - FAA delegated token validator:`/Users/jimchen/file_access_agent/src/FileAccessAgent.Infrastructure/Services/MemberCenterDelegatedDownloadTokenValidator.cs` line 27-72 + - FAA download endpoint:`/Users/jimchen/file_access_agent/src/FileAccessAgent.Api/Program.cs` line 184-254(無 `RequireAuthorization()`) + - converter promote → FAA PUT 已實作:`/Users/jimchen/kneron_model_converter/apps/task-scheduler/src/fileAccessAgent/client.js` + - converter v1 routes:`/Users/jimchen/kneron_model_converter/apps/task-scheduler/src/routes/v1/jobs.js`(既有 endpoint:POST jobs / GET jobs / GET jobs/:id / POST jobs/:id/promote;POST jobs/:id/download-tokens 預留 501;DELETE jobs/:id 預留 501) +- 觸發背景:`.autoflow/progress.md` 2026-05-16「致命發現 → 拍板選 A → ADR-016」段落 + +--- + +## 版本記錄 + +| 日期 | 版本 | 變更 | +|------|------|------| +| 2026-05-16 | 1.0 | 初版 — 撤回 visionA → FAA delegated download token 鏈(從 ADR-014 §2 起即為 broken design),改走 converter 新增 `GET /api/v1/jobs/{id}/result` + visionA stream 中轉。視 ADR-014 §2 / ADR-015 v2.0 §2 為部分 supersede;user login(ADR-013)/ upload streaming(ADR-014 §1)/ converter API key(ADR-015 §1)全部不變。涉及 visionA + converter 兩個 repo 改造;不動 MC、不動 FAA、不動 warrenchen。 | diff --git a/docs/autoflow/04-architecture/api/api-conversion.md b/docs/autoflow/04-architecture/api/api-conversion.md index f98959a..361a466 100644 --- a/docs/autoflow/04-architecture/api/api-conversion.md +++ b/docs/autoflow/04-architecture/api/api-conversion.md @@ -1,9 +1,10 @@ -# API — Conversion(轉檔功能,Phase 0.8 / Phase 0.8b) +# API — Conversion(轉檔功能,Phase 0.8 / Phase 0.8b v0.6) > **base URL**:`https://stage-9527.innovedus.com:9527/`(stage) / `http://localhost:3721`(dev) > **Auth(user → visionA)**:OIDC cookie session(`visiona_session`),參見 `oidc-tdd.md` — 與 Phase 0.8 完全一致,未變 -> **服務間認證(visionA → converter / FAA)**:**Phase 0.8b 已改為 pre-shared API key**(取代 OAuth client_credentials)— 對 frontend 透明,不影響本 API 契約;詳見 `conversion.md` §3、[`adr/adr-015-server-to-server-api-key.md`](../adr/adr-015-server-to-server-api-key.md) -> **同層**:`api/api-spec.md`(總覽)、`conversion.md`(內部設計)、`adr/adr-014-conversion-integration.md`(仍有效部分)、`adr/adr-015-server-to-server-api-key.md`(Phase 0.8b 認證機制) +> **服務間認證(visionA → converter)**:**Phase 0.8b v1.0 改為 pre-shared API key**(取代 OAuth client_credentials)— 對 frontend 透明;v0.6 新增同一把 key 也用於 download 路徑(converter `GET /api/v1/jobs/{id}/result`)。詳見 `conversion.md` §3.1 + [ADR-015 §1](../adr/adr-015-server-to-server-api-key.md) +> **服務間認證(visionA → FAA / MC)**:**Phase 0.8b v0.6 整段撤回**(v1.x 加的 API key 撤回 / v2.0 改回 MC service token + delegated download token 也撤回)— visionA 端不再有任何 visionA → FAA / visionA → MC 路徑。download 改走 converter 中轉(converter 從自己的 MinIO stream NEF 回 visionA)。詳見 [ADR-016](../adr/adr-016-download-via-converter.md) + `conversion.md` §3 +> **同層**:`api/api-spec.md`(總覽)、`conversion.md`(內部設計)、`adr/adr-014-conversion-integration.md`(**§2 被 ADR-016 supersede**;§1 upload / §3 半自動分流原則 / §4 模組劃分 / §6 user_id trust boundary 仍有效)、`adr/adr-015-server-to-server-api-key.md` v2.1(**§2 被 ADR-016 supersede**;§1 visionA → converter API key 仍有效)、`adr/adr-016-download-via-converter.md`(v0.6 上位) > **角色**:給 visionA-frontend 實作時的 API 契約 --- @@ -73,7 +74,7 @@ multipart fields(**注意:不要帶 user_id,backend 會從 cookie 灌**) | 409 | `active_job_exists` | visionA pre-check / converter | 顯示「你已有進行中任務」+ details.job | | 413 | `payload_too_large` | converter | 提示檔案大小限制 | | 502 | `converter_unavailable` | visionA | 提示「轉檔服務暫時無法使用」+ 重試按鈕 | -| 502 | `converter_auth_failed` | visionA(Phase 0.8b 新增)| 同上文字 — frontend 看不出差別;SRE 從 log 排查 API key 同步 | +| 502 | `converter_auth_failed` | visionA(Phase 0.8b v1.0 新增;v2.0 維持)| 同上文字 — frontend 看不出差別;SRE 從 log 排查 converter API key 同步 | | 503 | `service_busy` | converter | 提示稍後重試 | --- @@ -182,22 +183,30 @@ Content-Type: application/json | HTTP | code | 處理 | |------|------|-----| | 403 | `forbidden` | 不是該 user 的 job | -| 404 | `not_found` | job_id 不存在 | +| 404 | `not_found` | job_id 不存在(visionA ownership 找不到、或 converter 端 `GET result` 回 404 `result_not_found`) | | 409 | `job_not_completed` | job 還沒 completed,不能 promote | -| 502 | `converter_unavailable` | promote 失敗,可重試 | +| 410 | `result_expired` | v0.6 新增:job completed 但 converter MinIO 內 NEF 已過 7 天 expires_at 被 GC,無法 promote | +| 502 | `converter_unavailable` | promote 失敗 / converter `GET result` 5xx,可重試 | | 502 | `converter_auth_failed` | converter API key 不同步(運維事件)| -| 502 | `faa_unavailable` | FAA pull 失敗,可重試 | -| 502 | `faa_auth_failed` | FAA API key 不同步(運維事件)| **冪等性**:對同一 `job_id` 重複呼叫;若已建過 model record,回 200 + 既有 model 詳情(不重新建)。 +> **v0.6 註**:撤回 v0.5 的 `mc_token_unavailable` / `download_token_failed` / `faa_unavailable` 三個 code(v0.5 規劃要回收給 FAA 線用)。visionA 端 v0.6 不再有 visionA → MC / visionA → FAA 直接呼叫;promote 內部 NEF pull 改走 `converter.GetResult`,失敗模式收斂為 `converter_unavailable` / `converter_auth_failed` / `result_not_found` / `result_expired`。converter → FAA(promote 時 converter 內部 PUT)是 converter 自己的事、與 visionA 無關(converter 端失敗會在 promote response 中體現為 5xx)。 + --- ## 4. `GET /api/conversion/{job_id}/download` -「下載」 — **Phase 0.8b:visionA-backend server-side stream proxy**(從 FAA pull NEF stream 後中轉回 browser)。Phase 0.8 原本的「302 redirect to FAA delegated URL」設計因服務間認證改 API key 而退回 proxy 模式,詳見 [ADR-015](../adr/adr-015-server-to-server-api-key.md) §7、`conversion.md` §4.1。 +「下載」 — **Phase 0.8b v0.6:visionA-backend server-side stream proxy from converter `GET /api/v1/jobs/{id}/result`**。流程演進: -對 frontend 而言**呼叫方式完全一致**(`` / `window.location.href`),但 response 從「302 Location」變成「200 + NEF binary stream + Content-Disposition: attachment」。 +- **Phase 0.8 (ADR-014)**:「302 redirect to FAA delegated URL」(browser 直連 FAA) +- **Phase 0.8b v0.4 (ADR-015 v1.x)**:server-side stream proxy(從 FAA pull NEF),token 用 visionA API key +- **Phase 0.8b v0.5 (ADR-015 v2.0)**:保留 server-side stream proxy;token 來源改回 MC delegated download token(**對 MC source 驗證後確認設計 fictional、未實際 e2e 跑通**) +- **Phase 0.8b v0.6 (ADR-016)**:保留 server-side stream proxy;**stream 來源從 FAA 改 converter `GET /api/v1/jobs/{id}/result`**;visionA 端不再經 MC / FAA + +詳見 [ADR-016](../adr/adr-016-download-via-converter.md)、`conversion.md` §3 / §4.1。 + +對 frontend 而言**呼叫方式完全一致**(`` / `window.location.href`),response 仍是「200 + NEF binary stream + Content-Disposition: attachment」(與 v0.4 / v0.5 一致;只是 visionA backend 內部抓 stream 的下游 endpoint 改變,frontend 無感)。 ### Request @@ -246,12 +255,11 @@ Frontend **不需處理 token、不需處理 redirect**;`Content-Disposition: |------|------|-----| | 401 | `unauthorized` | 沒登入;redirect /login(前端攔截)| | 403 | `forbidden` | 不是該 user 的 job | -| 404 | `not_found` | job_id 不存在 / 已過期 | +| 404 | `not_found` | job_id 不存在 / 已過期(visionA ownership 找不到,或 converter `GET result` 回 404 `result_not_found`)| | 409 | `job_not_completed` | job 還沒 completed,不能下載 | -| 502 | `converter_unavailable` | promote 失敗(首次下載且尚未 promote 過時可能發生)| +| 410 | `result_expired` | v0.6 新增:job completed 但 converter MinIO 內 NEF 已過 7 天 expires_at 被 GC;frontend 顯示「轉檔結果已過期,請重新轉檔」並提供重新轉檔 CTA | +| 502 | `converter_unavailable` | promote 失敗(首次下載且尚未 promote 過時可能發生)/ converter `GET result` 5xx | | 502 | `converter_auth_failed` | converter API key 不同步(運維事件,frontend 不需區分)| -| 502 | `faa_unavailable` | FAA pull 失敗 | -| 502 | `faa_auth_failed` | FAA API key 不同步(運維事件,frontend 不需區分)| **錯誤回應格式**:依 `Accept` header: - `Accept: application/json` → `{success:false, error:{code, message}}` @@ -259,8 +267,11 @@ Frontend **不需處理 token、不需處理 redirect**;`Content-Disposition: **注意**: - 每次「下載」按鈕都直接打 `/download` endpoint,不要前端 cache 任何中間狀態 -- Phase 0.8b 退回 server-side proxy 後,visionA backend 變 streaming bottleneck — Phase 1 量大評估升級(ADR-015 §7 選項 B) -- 不會與 `promote-to-models` 衝突;兩者內部都會 ensurePromoted(冪等),兩條路徑都拿同一個 target_object_key +- Phase 0.8b(v0.4 / v0.5 / v0.6)server-side proxy 模式下,visionA backend 變 streaming bottleneck — Phase 1+ 量大評估升級(converter Phase 2 download-tokens 讓 browser 直連 converter;或 FAA HMAC token) +- 不會與 `promote-to-models` 衝突;兩者內部都會 ensurePromoted(冪等),兩條路徑都從 converter `GET result` 拉同一份 NEF stream +- v0.6 後 `promote-to-models` 也走 `converter.GetResult`(與 download 共用同一條 path;不再有 delegated token / FAA pull 任何概念) + +> **v0.6 註**:撤回 v0.5 加的 `mc_token_unavailable` / `download_token_failed` / `faa_unavailable` 三個 code。對 frontend 文字仍維持一致(`轉檔服務暫時無法使用`),用 `converter_unavailable` 統一表達;`result_expired`(410)是新增的明確 case,給 frontend 「過期」精確提示。 --- @@ -318,7 +329,7 @@ Frontend 在「轉檔」入口的 `/conversion` 頁載入時打這個 endpoint 對齊 `conversion.md` §6。前端 i18n key 統一 `conversion.error.`。 -> **Phase 0.8b 變更**:移除 4 個 MC 相關錯誤碼(`download_token_failed` / `mc_token_unavailable` / `idp_unavailable` / `idp_misconfigured`)— 服務間認證取消 MC 依賴。新增 2 個 `*_auth_failed` 錯誤碼對應 API key 不同步的運維事件(frontend 不需區分,UX 文字仍是「服務暫時無法使用」)。 +> **Phase 0.8b v0.6 變更**:撤回 v0.5「回收 MC 相關 code」決定。visionA 端不再有任何 visionA → MC / visionA → FAA 直接呼叫,`mc_token_unavailable` / `download_token_failed` / `faa_unavailable` 三個 code 全部移除。新增 `result_not_found`(404)與 `result_expired`(410)對應 converter `GET result` endpoint 的新失敗模式。 | code | HTTP | i18n key | 預設訊息(zh-TW) | |------|------|----------|------------------| @@ -326,25 +337,42 @@ Frontend 在「轉檔」入口的 `/conversion` 頁載入時打這個 endpoint | `unauthorized` | 401 | `common.error.unauthorized` | 請先登入 | | `forbidden` | 403 | `conversion.error.forbidden` | 你無權存取此任務 | | `not_found` | 404 | `conversion.error.not_found` | 任務不存在 | +| `result_not_found` | 404 | `conversion.error.not_found` | 任務不存在(v0.6 新增:converter `GET result` 回 404;i18n 與 `not_found` 共用文字、SRE 從 log 區分)| | `active_job_exists` | 409 | `conversion.error.active_job` | 你目前已有進行中的轉檔任務 | | `job_not_completed` | 409 | `conversion.error.not_completed` | 任務尚未完成(`promote-to-models` 與 `download` 共用) | +| `result_expired` | 410 | `conversion.error.result_expired` | 轉檔結果已過期,請重新轉檔(v0.6 新增:converter `GET result` 回 410;frontend 顯示重新轉檔 CTA)| | `payload_too_large` | 413 | `conversion.error.too_large` | 檔案超過大小限制 | | `converter_unavailable` | 502 | `conversion.error.converter_down` | 轉檔服務暫時無法使用 | -| `converter_auth_failed` | 502 | `conversion.error.converter_down` | 轉檔服務暫時無法使用(Phase 0.8b 新增 — frontend 文字同 converter_unavailable)| -| `faa_unavailable` | 502 | `conversion.error.faa_down` | 檔案存取服務暫時無法使用 | -| `faa_auth_failed` | 502 | `conversion.error.faa_down` | 檔案存取服務暫時無法使用(Phase 0.8b 新增)| +| `converter_auth_failed` | 502 | `conversion.error.converter_down` | 轉檔服務暫時無法使用(v1.0 新增;v2.0 / v0.6 維持 — frontend 文字同 converter_unavailable)| | `service_busy` | 503 | `conversion.error.busy` | 系統繁忙,請稍後再試 | -**Phase 0.8b 移除(不會再出現的舊 code)**: +**Phase 0.8b v0.6 撤回(v0.5 加的、v0.6 移除)**: -| 已移除 | 原 HTTP | 原語意 | -|------|---------|------| -| `idp_misconfigured` | 500 | MC token endpoint 4xx | -| `idp_unavailable` | 503 | MC token endpoint 5xx | -| `download_token_failed` | 502 | MC delegated token 4xx | -| `mc_token_unavailable` | 502 | MC 持續失敗 | +| 已撤回 | 原 HTTP | 原語意(v0.5)| v0.6 替代 | +|------|---------|-------|---| +| `mc_token_unavailable` | 502 | MC `/oauth/token` 4xx / 5xx | 不需要(visionA 端不再打 MC)| +| `download_token_failed` | 502 | MC `/file-access/download-tokens` 4xx / 5xx | 不需要(visionA 端不再 issue delegated token)| +| `faa_unavailable` | 502 | FAA pull 失敗 | 不需要(visionA 端不再直接打 FAA;converter `GET result` 失敗統一收斂進 `converter_unavailable`)| -frontend i18n 字典可保留 `conversion.error.idp_down` / `conversion.error.token_failed` 兩個 key 暫不刪除(防舊版 client 拿到舊 error code 時還能翻譯),但新版 backend 已不再回這 4 個 code。 +**Phase 0.8b v0.4 → v0.5 → v0.6 演進摘要**: + +| code | v0.4 狀態 | v0.5 狀態 | **v0.6 狀態** | +|------|---------|---------|---| +| `converter_auth_failed` | 新增 | 維持 | **維持**(同一把 key 也用於 GetResult)| +| `converter_unavailable` | 維持 | 維持 | **維持**(含 `GET result` 5xx 收斂)| +| `result_not_found` | — | — | **新增**(converter `GET result` 404)| +| `result_expired` | — | — | **新增**(converter `GET result` 410)| +| `faa_auth_failed` | 新增 | 撤回 | 維持撤回 | +| `faa_unavailable` | 使用中 | 使用中 | **撤回** | +| `mc_token_unavailable` | 移除 | 回收 | **撤回** | +| `download_token_failed` | 移除 | 回收 | **撤回** | +| `idp_misconfigured` | 移除 | 維持移除 | 維持移除 | +| `idp_unavailable` | 移除 | 維持移除 | 維持移除 | + +frontend i18n 字典: +- v0.6 後 `conversion.error.faa_down` i18n key 可保留但不再被任何 code 引用(向下相容;舊版 backend 不會發 `faa_*` / `mc_*` 三個 code) +- **新增 i18n key**:`conversion.error.result_expired` —「轉檔結果已過期,請重新轉檔」(給 410 用、與其他 502 文字明確區分) +- `result_not_found` 與 `not_found` 共用 `conversion.error.not_found` i18n key(user 角度兩者等價:任務不存在) --- @@ -356,3 +384,5 @@ frontend i18n 字典可保留 `conversion.error.idp_down` / `conversion.error.to | 2026-04-30 | 0.2 | §4 download endpoint 從 `POST /{job}/download-token`(回 JSON `{download_url, expires_at}`)改為 `GET /{job}/download`(HTTP 302 redirect),仿 FAA TestSite `DownloadFileDirect` pattern;token 不過 frontend JS、不需 FAA CORS;`job_not_completed` HTTP code 從 400 改為 409 + 補 `mc_token_unavailable` | | 2026-04-30 | 0.3 | Phase 0.8 三方交叉審閱回饋整合:Job response shape 補 `expires_at` / `source_filename` / `target_chip`(議題 #7);`/api/conversion/active` 行為文件化 lazy rebuild 機制(議題 #2 重啟恢復);`promote-to-models` request body 對齊 Design 單欄位(議題 #4,`description` 留 Phase 1) | | 2026-05-11 | 0.4 | **Phase 0.8b** 對應 [ADR-015](../adr/adr-015-server-to-server-api-key.md):(1) Header / 文件 metadata 標示服務間認證改 pre-shared API key(對 frontend 透明);(2) §4 download endpoint response 從「302 Location」改為「200 + NEF binary + `Content-Disposition: attachment`」— frontend 呼叫方式(`` / `window.location.href`)完全一致;(3) 錯誤碼總覽:移除 4 個 MC 相關 code(`idp_misconfigured` / `idp_unavailable` / `download_token_failed` / `mc_token_unavailable`),新增 2 個 `*_auth_failed`(`converter_auth_failed` / `faa_auth_failed`)對應 API key 不同步的運維事件 | +| 2026-05-16 | 0.5 | **對應 ADR-015 v2.0 範圍縮限**:撤回 v0.4「visionA → FAA 改 API key」;FAA 線回到 ADR-014 §2 原設計(MC service token + delegated download token);visionA → converter API key 路線(v0.4)**維持**。主要變更:(1) Header metadata 區分 converter 線 / FAA 線兩條 server-to-server 認證;(2) §3 promote-to-models 與 §4 download endpoint 錯誤碼回收 `mc_token_unavailable` / `download_token_failed`,撤回 v0.4 加的 `faa_auth_failed`;(3) 錯誤碼總覽表更新對應;(4) §4 download endpoint 描述更新「v0.4 server-side proxy + v0.5 token 來源改回 delegated download token」演進。**Frontend 行為對 user 完全透明**:response shape / call pattern / 錯誤文字一律不變;只是 visionA backend 內部 token 來源演進。 | +| 2026-05-16 | 0.6 | **對應 [ADR-016](../adr/adr-016-download-via-converter.md)**:撤回 v0.5「visionA → FAA 改回 MC service token + delegated download token」**全部規劃**。原因:對 MC source 全 grep 驗證後確認 MC 沒有 `POST /file-access/download-tokens` endpoint、也沒有 FAA `IDelegatedDownloadTokenValidator` assume 的 introspection endpoint—— v0.5 設計是 fictional。**v0.6 新設計**:visionA download 改走 converter 新增的 `GET /api/v1/jobs/{id}/result` + visionA stream 中轉;visionA 端不再有任何 visionA → FAA / visionA → MC 路徑、server-to-server 認證收斂為單條 visionA → converter(API key)。主要變更:(1) Header metadata 區段改寫「服務間認證(visionA → FAA / MC)」段落,整段撤回;(2) §3 promote-to-models 錯誤表移除 `mc_token_unavailable` / `download_token_failed` / `faa_unavailable`,新增 `result_expired`(410);(3) §4 download endpoint 描述更新 v0.6 演進、錯誤表同樣移除 v0.5 加的 MC / FAA 三個 code、新增 `result_expired`;(4) 錯誤碼總覽表更新 — 新增 `result_not_found`(404) + `result_expired`(410)兩個 code,撤回 `faa_unavailable` / `mc_token_unavailable` / `download_token_failed`;(5) v0.4 → v0.5 → v0.6 演進摘要表加 v0.6 column;(6) i18n 字典補 `conversion.error.result_expired` 新 key。**Frontend 行為對 user 仍完全透明**(response shape / call pattern 不變;只是 visionA backend 內部 stream 來源從 FAA 改為 converter)。**新增 frontend 需處理**:410 `result_expired` 顯示「請重新轉檔」CTA(i18n key `conversion.error.result_expired`)。 | diff --git a/docs/autoflow/04-architecture/conversion.md b/docs/autoflow/04-architecture/conversion.md index ebf2b3f..f101f40 100644 --- a/docs/autoflow/04-architecture/conversion.md +++ b/docs/autoflow/04-architecture/conversion.md @@ -1,11 +1,11 @@ # Conversion — 轉檔功能整合(Phase 0.8 / Phase 0.8b) > **角色**:visionA-backend 端的「轉檔」實作 spec(內部模組設計 + API + flow)。 -> **上位文件**:[`adr/adr-015-server-to-server-api-key.md`](./adr/adr-015-server-to-server-api-key.md)(Phase 0.8b 認證機制 — 部分 supersede ADR-014)、[`adr/adr-014-conversion-integration.md`](./adr/adr-014-conversion-integration.md)(仍有效:upload streaming、download 302 設計、模組劃分等)、`TDD.md`、`security.md` +> **上位文件**:[`adr/adr-016-download-via-converter.md`](./adr/adr-016-download-via-converter.md)(**v0.6 新增**:visionA download 改走 converter 中轉、撤回所有 visionA → MC / FAA 鏈)、[`adr/adr-015-server-to-server-api-key.md`](./adr/adr-015-server-to-server-api-key.md) **v2.1**(visionA → converter API key 維持;§2 visionA → FAA 整段被 ADR-016 supersede)、[`adr/adr-014-conversion-integration.md`](./adr/adr-014-conversion-integration.md)(**§2 download 整段被 ADR-016 supersede**;§1 upload streaming / §3 半自動分流原則 / §4 模組劃分 / §6 user_id trust boundary 仍有效)、`TDD.md`、`security.md` > **同層文件**:`api/api-conversion.md`(對 frontend 的 API 規格細節) > **作者**:Architect Agent -> **狀態**:Phase 0.8b 修訂(v0.4)— OAuth client_credentials 改 pre-shared API key -> **最後更新**:2026-05-11 +> **狀態**:Phase 0.8b v0.6 修訂 — visionA → converter 走 API key(不變);**visionA → FAA / MC 兩條鏈完全撤回**;download 改走 converter `GET /api/v1/jobs/{id}/result` 中轉 +> **最後更新**:2026-05-16 --- @@ -26,13 +26,17 @@ ## 1. 整體 flow(端對端) -> **Phase 0.8b 變更**:服務間認證從「打 MC 換 OAuth service token + JWKS 驗簽 + scope」改為「visionA 帶 `Authorization: Bearer ` 直接打 converter / FAA」。詳見 §3 與 ADR-015。 +> **Phase 0.8b v0.6 變更**:visionA 端 server-to-server 鏈路**收斂為單條**(只剩 visionA → converter): +> - visionA → converter:`Authorization: Bearer `(ADR-015 §1,不變) +> - visionA → FAA / MC:**完全撤回**(v0.5 加回的鏈是 broken design;對 MC source 全 grep 驗證後確認 MC 沒有 delegated download token endpoint) +> - download:visionA → converter `GET /api/v1/jobs/{id}/result`,converter 從 MinIO stream NEF 回 visionA,再 io.CopyN 中轉給 browser(ADR-016) +> - 詳見 §3 與 [ADR-016](./adr/adr-016-download-via-converter.md) ```mermaid sequenceDiagram participant B as Browser participant V as visionA-backend - participant C as Converter + participant C as Converter (incl. MinIO) participant F as FAA Note over B,F: Stage 1 — Init job(streaming upload) @@ -54,65 +58,85 @@ sequenceDiagram V-->>B: 整形後 status end - Note over B,F: Stage 3a — User 選「加到模型庫」 - B->>V: POST /api/conversion/{job_id}/promote-to-models + Note over B,F: Stage 3 — Promote(converter 內部 push FAA;與 visionA 無關) + B->>V: POST /api/conversion/{job_id}/promote-to-models (or download trigger) V->>C: POST /api/v1/jobs/{id}/promote
Authorization: Bearer - C->>F: PUT /files/{key} (NEF — converter 內部認證,與 visionA 無關) - C-->>V: {target_object_key} - V->>F: GET /files/{key}
Authorization: Bearer - F->>F: middleware: ConstantTimeCompare(key, FAA_API_KEY) - F-->>V: NEF stream + C->>F: PUT /files/{target_object_key} (NEF — converter 自己的 OAuth + files:upload.write scope;與 visionA 完全無關) + C-->>V: {target_object_key}
(NEF 同時保留在 converter MinIO 7d expires_at) + + Note over B,F: Stage 3a — User 選「加到模型庫」 + V->>C: GET /api/v1/jobs/{id}/result
Authorization: Bearer + C-->>V: 200 NEF binary stream (from converter MinIO) V->>V: /api/models/init → /api/models/finalize
(Source=converted, SourceJobID=job_id) V-->>B: 201 {model_id} - Note over B,F: Stage 3b — User 選「下載」(Phase 0.8b: server-side proxy;非 302 redirect) + Note over B,F: Stage 3b — User 選「下載」(Phase 0.8b v0.6: server-side stream proxy from converter) B->>V: GET /api/conversion/{job_id}/download V->>V: AuthMiddleware → user_id + ownership 檢查 - V->>C: POST /api/v1/jobs/{id}/promote (若還沒 promote)
Authorization: Bearer + V->>C: POST /api/v1/jobs/{id}/promote (若還沒 promote — 冪等) C-->>V: {target_object_key} - V->>F: GET /files/{key}
Authorization: Bearer - F-->>V: NEF stream - V-->>B: stream NEF(visionA backend 中轉,token 結構性不過 browser) + V->>C: GET /api/v1/jobs/{id}/result
Authorization: Bearer + C-->>V: 200 NEF binary stream + Content-Length + Content-Disposition + V-->>B: stream NEF(visionA backend io.CopyN 中轉、size cap 1 GiB) ``` **critical path 說明**: - visionA-backend 在 upload / download / promote 任一階段都先做 OIDC AuthMiddleware(既有)+ ownership 檢查 - promote 動作是冪等的(converter 端對同一 job 重複 promote 接受),visionA-backend 內部以 `job_id ↔ promoted_object_key` 記錄避免重複呼叫 -- 加到模型庫流程:promote → FAA pull → `/api/models/init` 取得 model_id + presigned PUT URL → visionA-backend 自己 PUT 到 storage → `/api/models/finalize`(這條路徑要重用既有 handler 邏輯,不繞過) -- **Phase 0.8b 認證鏈簡化**:不再有 visionA ↔ MC 鏈路;不再有「token cache miss / scope mismatch / JWKS 不可達」失敗模式。converter / FAA 端 middleware 各自只比對單一字串。 +- 加到模型庫流程(v0.6):promote → **converter.GetResult 拉 NEF stream**(不是直接打 FAA)→ `/api/models/init` 取得 model_id + presigned PUT URL → visionA-backend 自己 PUT 到 storage → `/api/models/finalize`(這條路徑要重用既有 handler 邏輯,不繞過) +- download flow(v0.6):promote 冪等 → **converter.GetResult 拉 NEF stream** → io.CopyN 中轉給 browser(size cap 1 GiB) +- **Phase 0.8b v0.6 認證鏈說明**: + - visionA 端只有一條:visionA → converter(API key、constant-time compare) + - converter → FAA:converter 自己用 OAuth client_credentials + `files:upload.write` scope(既有實作 `apps/task-scheduler/src/fileAccessAgent/client.js`、Phase 1 已上線;與 visionA 無關) + - visionA → MC:**完全不存在**(user login 的 OIDC public PKCE client 是另一條完全獨立的鏈,不在本文件範圍) + - visionA → FAA:**完全不存在** -**Phase 0.8b 與 ADR-014 的差異說明**: +**Phase 0.8b v0.6 與 ADR-014 / ADR-015 v1.x / v2.0 的差異說明**: -| 面向 | ADR-014(OAuth client_credentials) | Phase 0.8b(API key)| -|------|--------------------------------|------------------| -| visionA → converter 認證 | `Authorization: Bearer ` | `Authorization: Bearer ` | -| visionA → FAA 認證 | `Authorization: Bearer ` | `Authorization: Bearer ` | -| download 流程 | server-side 302 redirect → browser 直連 FAA(拿 MC delegated token) | server-side proxy(visionA backend 中轉 stream)| -| visionA → MC service token 路徑 | 啟動 lazy init MCTokenClient + cache | **完全移除** | -| converter / FAA middleware | 驗 JWKS 簽章 + 驗 scope + 驗 tenant | 比對 env 字串(constant-time)| +| 面向 | ADR-014(OAuth client_credentials 兩條線)| ADR-015 v1.x(兩條線都 API key)| ADR-015 v2.0(converter API key + FAA OAuth + delegated)| **ADR-016 / v0.6(visionA 端只剩 converter)**| +|------|--------------------------------|------------------|---|---| +| visionA → converter 認證 | `Authorization: Bearer ` | `Authorization: Bearer ` | 同 v1.x | 同 v1.x / v2.0(不變)| +| visionA → FAA(write / metadata / delete)| `Authorization: Bearer ` + scope | `Authorization: Bearer ` | 回到 ADR-014(service token) | **不存在**(visionA 端不再直接打 FAA)| +| visionA → FAA(download `GET /files/{key}`)| `Authorization: Bearer ` | `Authorization: Bearer ` | 回到 ADR-014(delegated token) | **不存在**(visionA 端不再直接打 FAA)| +| download 從哪取 NEF | FAA `GET /files/{key}` | 同上 | 同上(fictional — delegated token endpoint MC 沒有)| **converter `GET /api/v1/jobs/{id}/result`**(從 converter MinIO stream)| +| download 在 browser 端流程 | 302 redirect | server-side proxy | server-side proxy(同 v0.4)| server-side proxy(同 v0.4 / v0.5,不變)| +| visionA → MC service token 路徑 | 啟動 lazy init MCTokenClient + cache | 完全移除 | 部分復活 | **完全移除**(撤回 v2.0 復活;mc_token_client.go 整檔砍除)| +| converter middleware | 驗 JWKS + scope + tenant | 比對 env 字串(constant-time)| 同 v1.x | 同 v1.x(不變)| +| FAA middleware | 驗 JWKS + scope + tenant + delegated token | API key 比對 env 字串 | 回到 ADR-014(dual-auth) | 不適用(visionA 端不再呼叫 FAA;converter → FAA 仍走 ADR-014 OAuth 路徑、但 converter 自己管)| -> 為什麼 download 不繼續走 302 redirect:API key 模式下沒有 MC 簽 short-TTL delegated token;visionA 自己簽 HMAC token 給 browser 的方案留給 Phase 1+(見 ADR-015 §7 選項 B)。 +> **為什麼 v0.6 把 download 拉到 converter 中轉**: +> +> 1. **v2.0 設計的 delegated token 鏈是 fictional**:對 MC source(`/Users/jimchen/member_center/src/MemberCenter.Api/Controllers/*.cs` 8 個 controller)全 grep 確認 MC 沒有 `POST /file-access/download-tokens` endpoint、也沒有 FAA `MemberCenterDelegatedDownloadTokenValidator` assume 的 introspection endpoint。整條鏈從 2026-05-02 ADR-014 寫定起即為 broken、只是因 visionA 從未實際 e2e 跑通 download 而沒被發現 +> 2. **使用者硬約束「不動 MC、不動 FAA」**:補 MC endpoint 需 MC team 設計 + onboard scope;補 FAA endpoint 需 warrenchen 改公司共用 repo;跨人協調 cost 高(5/9 撞 scope 沒註冊已驗證) +> 3. **converter 是 jimchen 自己 repo**:加 `GET /api/v1/jobs/{id}/result` 對 coordination cost 低 +> 4. **failure mode 收斂**:visionA 端 fail path 從 5 條(visionA → MC 4xx/5xx、MC token cache、MC delegated token issue、FAA service token validate、FAA delegated token validate)收斂為 3 條(converter 401 / 4xx / 5xx) +> 5. **既有 stream proxy 結構保留**:v0.4 / v0.5 的 server-side stream proxy + size cap + context cancellation 完全沿用,只是 stream 來源從 FAA 改 converter +> +> 詳見 [ADR-016 §決策 + 替代方案](./adr/adr-016-download-via-converter.md)。 --- ## 2. 模組設計 — `internal/conversion/` -> **Phase 0.8b 變更**:移除 `mc_token_client.go` 整個檔案。converter / FAA client 直接從 config 讀預設 API key。 +> **Phase 0.8b v0.6 變更**:撤回 v0.5「mc_token_client 部分復活、faa_client 改回 service token + delegated token」設計。download 改走 converter `GET /api/v1/jobs/{id}/result`、stream 來源從 FAA 改 converter。 ``` internal/conversion/ -├── conversion.go # Service interface + 對外暴露的 type -├── converter_client.go # converter scheduler API client(直接帶 VISIONA_CONVERTER_API_KEY) -├── faa_client.go # FAA API client(pull NEF;直接帶 VISIONA_FAA_API_KEY) -├── flow.go # 整體 flow 協調 -├── types.go # request / response struct -└── errors.go # error code 定義 +├── conversion.go # Service interface + 對外暴露的 type +├── converter_client.go # converter scheduler API client(init / poll / promote / GetResult — 帶 VISIONA_CONVERTER_API_KEY) +├── (faa_client.go 刪除 / 改名) # v0.6:visionA 端不再直接打 FAA;改名為 converter_result_client.go(或併入 converter_client.go),唯一職責是打 converter GET result endpoint +├── (mc_token_client.go 刪除) # v0.6:撤回 v0.5「部分復活」決定;visionA 端不再有任何 visionA → MC server-to-server 路徑 +├── flow.go # 整體 flow 協調(download / PromoteToModels 都走 converter.GetResult) +├── types.go # request / response struct +└── errors.go # error code 定義 ``` -**Phase 0.8b 移除**: -- ❌ `mc_token_client.go`(~440 行 — 整檔砍)— 不再需要與 MC 換 service token / delegated download token -- ❌ `MCTokenClient` 在 flow / converter_client / faa_client 上的所有 reference +**Phase 0.8b v0.6 模組變更摘要(相對於 v0.5)**: +- ✅ converter_client.go:維持 v0.5(API key 直接 set header);**新增 `GetResult(ctx, jobID)` method** 用於拉 NEF binary stream +- ❌ faa_client.go:**整檔刪除 / 改名**(v0.5 加的「`DownloadWithDelegated` + `tokens *MCTokenClient` 欄位」全部移除;唯一還需要的 stream proxy 結構併入 `converter_client.go` 的 `GetResult` 或新建 `converter_result_client.go`) +- ❌ mc_token_client.go:**整檔刪除**(撤回 v0.5「部分復活」;commit `86b7175` 已砍掉,**維持砍除狀態**,不需復活;ServiceToken cache + IssueDelegatedDownload 兩個 method 都不再需要) +- ↩ flow.go:撤回 v0.5「DownloadStream / PromoteToModels 走 IssueDelegatedDownload + DownloadWithDelegated」;改成「呼叫 `converter.GetResult(ctx, jobID)`」;`tokens *MCTokenClient` 欄位刪除 ### 2.1 `conversion.go` — 對外 interface @@ -140,13 +164,16 @@ type Service interface { // 回傳新建的 model_id。 PromoteToModels(ctx context.Context, userID, jobID string) (modelID string, err error) - // DownloadStream — 「下載」流程(Phase 0.8b:server-side proxy): + // DownloadStream — 「下載」流程(Phase 0.8b v0.6:server-side stream proxy + converter `GET /api/v1/jobs/{id}/result`): // 1. ownership 檢查 - // 2. promote (若需要) - // 3. 從 FAA pull NEF stream - // 4. handler 直接 io.Copy stream 給 client - // 不再產生 302 redirect URL(API key 模式下無 MC delegated token)。 - // 詳見 ADR-015 §7 — Phase 1+ 量大時再評估「visionA 自簽 HMAC token + 302」升級路徑。 + // 2. ensurePromoted(冪等 cache 對 converter;NEF 確認已在 converter MinIO + FAA) + // 3. converter.GetResult(ctx, jobID) — 直接打 converter GET result endpoint + // Authorization: Bearer (同其他 converter API method) + // converter response 200 + NEF binary stream + Content-Length + Content-Disposition + // 4. handler 直接 io.CopyN stream 給 client(size cap 1 GiB) + // 不產生 302 redirect URL(server-side proxy 在 T4 已實作,v0.4 / v0.5 / v0.6 沿用;不退回 302)。 + // 不再經過 visionA → MC / visionA → FAA 任何路徑(v0.6 整段撤回;詳見 ADR-016)。 + // Phase 1+ 量大時可評估方案 D(visionA 自簽 HMAC + FAA 加第三條 auth path + 回 302)。 DownloadStream(ctx context.Context, userID, jobID string) (stream io.ReadCloser, meta *DownloadMetadata, err error) // ActiveJob 查 user 當前是否有 active job(給 frontend pre-check 用)。 @@ -177,19 +204,20 @@ type Job struct { ErrorMessage string `json:"error_message,omitempty"` } -// Phase 0.8b:DownloadGrant 移除(不再有 MC delegated token 換取流程)。 -// Download 走 server-side proxy;token 結構性不過 frontend。 +// Phase 0.8b v0.6:撤回 v0.5 DownloadGrant struct(不再需要 delegated token 持有結構)。 +// visionA → converter 一條鏈、沒有 token issue 過程,flow.go 直接呼叫 converter.GetResult +// 拿 stream + DownloadMetadata 即可。 // DownloadMetadata — DownloadStream 回傳的中介資料(沿用 §2.3 既定的型別)。 -// (定義在 faa_client.go,避免重複) +// (定義在 converter_result_client.go / converter_client.go,避免重複) ``` -### 2.2 `converter_client.go` +### 2.2 `converter_client.go`(v2.0:維持 v1.x — API key 不變) ```go type ConverterClient struct { baseURL string - apiKey string // Phase 0.8b:pre-shared API key(VISIONA_CONVERTER_API_KEY) + apiKey string // Phase 0.8b v1.x+v2.0:pre-shared API key(VISIONA_CONVERTER_API_KEY) httpClient *http.Client } @@ -207,53 +235,59 @@ func (c *ConverterClient) PromoteJob(ctx context.Context, jobID string) (targetO func (c *ConverterClient) ListActiveJobsByUser(ctx context.Context, userID string) (*Job, error) ``` -每個方法內部(Phase 0.8b 簡化): +每個方法內部(v1.x + v2.0 簡化): 1. `req.Header.Set("Authorization", "Bearer "+c.apiKey)` — 直接帶 pre-shared API key,**不查 cache、不打 MC、不重簽** 2. 帶 `X-Request-Id`(從 ctx 取,沿用 visionA-backend 既有 request_id 中介層) 3. response 5xx / network error 走 retry(§9);401/403 不 retry(API key 錯不會自己變對) -### 2.3 `faa_client.go` +### 2.3 ~~`faa_client.go`~~(v0.6:整檔刪除 / 改名為 `converter_result_client.go`) + +> **v0.6 撤回 v0.5 設計**:v0.5 規劃的「`tokens *MCTokenClient` 欄位 + `DownloadWithDelegated(ctx, delegatedToken, objectKey)`」整段刪除。visionA 端不再有 `FAAClient` 概念、不再有任何 `internal/conversion/` 內對 FAA 的呼叫。 +> +> 取而代之:原 `faa_client.go` 的 stream proxy 結構(`io.ReadCloser` + `DownloadMetadata`)改名為 `converter_result_client.go`(或併入 `converter_client.go` 作為其 method),唯一職責是打 converter `GET /api/v1/jobs/{id}/result` 拉 NEF binary stream。 ```go -type FAAClient struct { - baseURL string - apiKey string // Phase 0.8b:pre-shared API key(VISIONA_FAA_API_KEY) - httpClient *http.Client -} +// 新檔案(或併入 converter_client.go): -// Download server-to-server 拉檔(給「加到模型庫」+「下載」兩個流程共用)。 -// 帶 Authorization: Bearer 。 -func (c *FAAClient) Download(ctx context.Context, objectKey string) (io.ReadCloser, *DownloadMetadata, error) +// GetResult — 對 converter GET /api/v1/jobs/{id}/result 拉 NEF binary stream。 +// 帶 Authorization: Bearer (同其他 converter API method)。 +// converter response 200 + Content-Length + Content-Disposition + body stream。 +// 對應 converter 端 endpoint spec 詳見 ADR-016 §1。 +func (c *ConverterClient) GetResult(ctx context.Context, jobID string) (io.ReadCloser, *DownloadMetadata, error) type DownloadMetadata struct { - SizeBytes int64 - ContentType string - Checksum string // optional + SizeBytes int64 // 從 converter response Content-Length 解析 + ContentType string // 固定 application/octet-stream + Filename string // 從 converter response Content-Disposition 解析;visionA 端可選擇覆寫(見 §4.1 註) } ``` -**Phase 0.8b**:不再需要 `DownloadGrant`(無 MC delegated token);download flow 用同一個 `FAAClient.Download()` 拉 stream 後由 handler 中轉給 client。 +**v0.6 重要設計約束**: +- 「加到模型庫」flow 與「下載」flow **共用同一個 `GetResult`**——兩條 path 都從 converter MinIO 拉 NEF。visionA 端完全不需理解 FAA 的存在。 +- size cap:visionA backend handler 端用 `io.CopyN(w, stream, 1 GiB)` 保護;converter 端不另外設 cap(converter MinIO 容量為準)。 +- failure mapping: + - converter 401 → `converter_auth_failed`(運維事件,API key 不同步) + - converter 404 → `result_not_found`(job_id 不存在 / 已過 7 天 expires_at) + - converter 410 → `result_expired`(job completed 但 NEF 已被 converter MinIO GC) + - converter 409 → `job_not_completed`(job 尚未 completed,理論上 visionA 端 ensurePromoted 前已確認、不應發生) + - converter 5xx / network → `converter_unavailable` -### 2.4 ~~`mc_token_client.go`~~(Phase 0.8b 移除) +### 2.4 ~~`mc_token_client.go`~~(v0.6:整檔刪除、撤回 v0.5 部分復活) -> **整個檔案在 Phase 0.8b 移除**。詳見 [ADR-015](./adr/adr-015-server-to-server-api-key.md)。 +> **v0.6 撤回 v0.5「部分復活」決定**。對 MC source 全 grep 驗證後確認 MC **沒有** `POST /file-access/download-tokens` endpoint、也**沒有** FAA `IDelegatedDownloadTokenValidator` assume 的 introspection endpoint——v0.5 規劃的 `IssueDelegatedDownload` method 是 fictional、永遠 issue 不到 delegated token。 > -> 原本職責:跟 MC 換 service token(client_credentials grant)+ 換 delegated download token。 +> **v0.6 處理**:mc_token_client.go 在 commit `86b7175` 已被砍除,**維持砍除狀態**、不需復活。對應的 test、config 欄位(`OIDCConfig.ServiceClientID/Secret`、`ConversionConfig.TenantID`)、env(`VISIONA_OIDC_SERVICE_CLIENT_ID/_SECRET`、`VISIONA_OIDC_TENANT_ID`、`VISIONA_FAA_BASE_URL`)全部撤回。 > -> Phase 0.8b 後: -> - 服務間認證直接帶 `Authorization: Bearer ` — 不需 cache、不需 refresh、不需 retry MC -> - download flow 退回 server-side proxy(visionA backend 中轉 stream),不再有 delegated token 概念 -> -> **Tenant 概念取消**:visionA → converter / FAA 不再帶 tenant_id;converter 端的 user_id 仍由 visionA 灌入(§7 trust boundary 不變)。 +> visionA 端**不再有任何 visionA → MC server-to-server 路徑**。user login 的 OIDC(PKCE / cookie session / JWKS 驗 id_token)是另一條完全獨立的鏈、不在本文件範圍(詳見 `oidc-tdd.md`)。 ### 2.5 `flow.go` — 流程協調 ```go type Flow struct { - converter *ConverterClient - faa *FAAClient - // Phase 0.8b:不再需要 tokens *MCTokenClient + converter *ConverterClient // 含 init / poll / promote / GetResult method + // (faa *FAAClient — v0.6 刪除) + // (tokens *MCTokenClient — v0.6 刪除,撤回 v0.5 復活) models model.Repository // 沿用既有 model store storage storage.Store // 沿用既有 LocalFS / S3 ownership ownershipStore // job_id → user_id mapping (in-memory map) @@ -261,19 +295,33 @@ type Flow struct { statusCache *jobStatusCache // 1-2s short cache,避免 frontend polling 直接打爆 converter } -// 主要 method 對應 Service interface。 -// PromoteToModels 內部: -// 1. ownership.Check(userID, jobID) -// 2. promotedKey, err := flow.ensurePromoted(ctx, jobID) // 冪等:若已 promote 過用 cache,否則打 converter -// 3. reader, meta, err := faa.Download(ctx, promotedKey) -// 4. modelID, putURL, _ := callModelsInit(...) // 直接呼叫 internal/api 同 package 既有 helper(不走 HTTP) -// 5. PUT 到 storage(或直接 io.Copy 到 storage.Put) -// 6. callModelsFinalize(...) -// 7. 在 model record 補 Source="converted" + SourceJobID=jobID -// 8. 回 modelID +// DownloadStream(v0.6 流程): +// 1. ownership.Check(userID, jobID) +// 2. _, _ := flow.ensurePromoted(ctx, jobID) // 冪等 cache,確保 converter 端 promote 完成(NEF 已在 MinIO + FAA) +// 3. stream, meta, _ := flow.converter.GetResult(ctx, jobID) +// 內部:GET {ConverterBaseURL}/api/v1/jobs/{jobID}/result +// Authorization: Bearer +// converter response 200 + NEF binary stream + Content-Length + Content-Disposition +// 4. meta.Filename = defaultDownloadFilename(cj) // visionA 自行構造(§4.1 註)覆寫 converter 給的 filename +// 5. return stream, meta, nil +// +// PromoteToModels 內部(v0.6 修正): +// 1. ownership.Check(userID, jobID) +// 2. _, _ := flow.ensurePromoted(ctx, jobID) +// 3. reader, meta, _ := flow.converter.GetResult(ctx, jobID) +// ← v0.6:與 DownloadStream 共用同一 method、不需 delegated token +// 4. modelID, putURL, _ := callModelsInit(...) // 直接呼叫 internal/api 同 package 既有 helper +// 5. PUT 到 storage(或直接 io.Copy 到 storage.Put) +// 6. callModelsFinalize(...) +// 7. 在 model record 補 Source="converted" + SourceJobID=jobID +// 8. 回 modelID + +// 主要 method 對應 Service interface(v0.6 流程已在 struct 上方註解寫出;此處保留結構說明) ``` -**冪等性**:`flow.ensurePromoted(jobID)` 內部用 `sync.Map` 記 `job_id → target_object_key`;同 job 第二次 promote 直接回 cache,不打 converter。 +**冪等性**:`flow.ensurePromoted(jobID)` 內部用 `sync.Map` 記 `job_id → target_object_key`;同 job 第二次 promote 直接回 cache,不打 converter。`target_object_key` 在 v0.6 仍由 promote response 拿到(給 log / debug 用)、但 visionA 不再用它直接打 FAA(converter `GetResult` 內部知道哪個 object 屬於哪個 jobID)。 + +**為什麼移除 delegated token 邏輯**:v0.5 規劃「IssueDelegatedDownload + DownloadWithDelegated」依賴 MC 有對應 endpoint 才 work;對 MC source 驗證後確認該 endpoint **從未存在**——v0.5 的設計是 fictional、永遠跑不通。v0.6 把整條鏈撤回、改走 converter 中轉(converter 自己用 OAuth 推 FAA、後續 download 從 converter MinIO 拉)。詳見 [ADR-016](./adr/adr-016-download-via-converter.md)。 ### 2.6 `ownership` store(in-memory) @@ -350,102 +398,213 @@ func (f *Flow) ActiveJob(ctx context.Context, userID string) (*conversion.Job, e --- -## 3. 服務間認證(API key)— 取代 OAuth client_credentials +## 3. 服務間認證(v0.6:visionA 端只剩單條 visionA → converter) -> **Phase 0.8b 變更**:本節為新增;對應 [ADR-015](./adr/adr-015-server-to-server-api-key.md)。 +> **Phase 0.8b v0.6 變更**:本節重寫;對應 [ADR-016](./adr/adr-016-download-via-converter.md)。 > -> **歷史**:ADR-014 §5 原本設計為「visionA → MC `POST /oauth/token` 換 service token + cache + 帶 JWT 給 converter / FAA」。Phase 0.8 stage e2e 卡關(MC scope 沒註冊、converter image 舊、跨 repo 配合複雜)後,使用者決策改用 pre-shared API key。 +> **歷史**: +> - **v0.4 (2026-05-11)**:本節將兩條線都改為 pre-shared API key(撤回 ADR-014 §5 OAuth 設計) +> - **v0.5 (2026-05-16)**:本節再修,只 converter 線維持 API key,FAA 線回到 ADR-014 §2 原設計(service token + delegated download token) +> - **v0.6 (2026-05-16)**:對 MC source 驗證後確認 v0.5 設計的 delegated token 鏈是 fictional(MC 沒有對應 endpoint);本節再次整段改寫——visionA → FAA / MC 鏈**完全撤回**,download 改走 converter `GET /api/v1/jobs/{id}/result`,visionA 端只剩 visionA → converter 一條 server-to-server 認證鏈 -### 3.1 取得流程 +### 3.1 visionA → converter(API key)— v1.x 設計 + v2.0 / v2.1 / v0.6 維持 + +對應 [ADR-015 §1](./adr/adr-015-server-to-server-api-key.md)(v1.x / v2.0 / v2.1 都不變)。**v0.6 新增**:同一把 API key 也用於新增的 `GET /api/v1/jobs/{id}/result` endpoint(ADR-016 §1)。 + +#### 3.1.1 取得流程 ``` visionA-backend 啟動 ↓ 讀 cfg.Conversion.ConverterAPIKey(env VISIONA_CONVERTER_API_KEY) -讀 cfg.Conversion.FAAAPIKey(env VISIONA_FAA_API_KEY) ↓ [轉檔請求進來] ↓ -converter_client / faa_client 發 request 時: +converter_client 發 request 時: req.Header.Set("Authorization", "Bearer "+apiKey) ↓ -converter / FAA middleware: +converter middleware: - parse Authorization header → 取 token - subtle.ConstantTimeCompare(token, envKey) - match → 放行;mismatch → 401 + log(不附原因) ``` -**沒有 token cache、沒有 refresh、沒有 retry MC、沒有 scope 驗證**。整條鏈路是「visionA → 下游」一步。 +**沒有 token cache、沒有 refresh、沒有 retry MC、沒有 scope 驗證**。整條鏈路是「visionA → converter」一步。 -### 3.2 Config 對齊 - -`visionA-backend/internal/config/config.go` 變更: - -```go -type ConversionConfig struct { - ConverterBaseURL string `env:"VISIONA_CONVERTER_BASE_URL"` - FAABaseURL string `env:"VISIONA_FAA_BASE_URL"` - ConverterAPIKey string `env:"VISIONA_CONVERTER_API_KEY"` // Phase 0.8b 新增 - FAAAPIKey string `env:"VISIONA_FAA_API_KEY"` // Phase 0.8b 新增 - // Phase 0.8b 廢棄欄位(為 backward compat 保留 struct field 但 conversion 不再使用): - // TenantID string `env:"VISIONA_OIDC_TENANT_ID"` -} - -func (c ConversionConfig) Enabled() bool { - return c.ConverterBaseURL != "" && - c.FAABaseURL != "" && - c.ConverterAPIKey != "" && - c.FAAAPIKey != "" -} -``` - -`OIDCConfig.ServiceClientID` / `ServiceClientSecret` 兩個欄位 Phase 0.8b **conversion 不再依賴**;如其他模組未使用即可從 struct 移除。env 端從 `.env*.example` 移除以免誤導。 - -新增的 stage env: - -```bash -# .env.stage -VISIONA_CONVERTER_BASE_URL=http://192.168.0.130:9501 -VISIONA_FAA_BASE_URL=http://192.168.0.130:5081 -VISIONA_CONVERTER_API_KEY= -VISIONA_FAA_API_KEY= - -# Phase 0.8b 移除: -# VISIONA_OIDC_SERVICE_CLIENT_ID -# VISIONA_OIDC_SERVICE_CLIENT_SECRET -# VISIONA_OIDC_TENANT_ID(conversion 不依賴;其他模組未發現使用) -``` - -### 3.3 啟動時驗證 +#### 3.1.2 啟動時驗證 api-server 啟動時 log 一行(**不可 log key 本身**): ``` -[INFO] conversion config: converter=http://192.168.0.130:9501 (api_key_set=true) faa=http://192.168.0.130:5081 (api_key_set=true) +[INFO] conversion config: converter=http://192.168.0.130:9501 (api_key_set=true) ``` -若 `Enabled() == false` → conversion 模組整個 disabled(與 Phase 0.8 「partial deploy」相容,sidebar tab 仍會顯示但會回 503 `service_busy`)。 - -### 3.4 Key 產生 / 部署 / Rotate +#### 3.1.3 Key 產生 / 部署 / Rotate | 項目 | 規格 | |------|------| | 長度 | 64 字元 hex(256 bit 熵) — `openssl rand -hex 32` | | 環境隔離 | dev / stage / prod 各自獨立的 key,**不重用** | -| 兩個下游 | converter / FAA 各自一把,**不共用** | | 儲存(dev)| `.env.dev`(gitignore) | | 儲存(stage)| stage host `.env.stage`(不進 git) | | 儲存(prod)| AWS Secrets Manager / Vault | | Rotate | runbook Phase 0.9 補;流程:產新 key → 雙方同步 env → restart → 驗證 → 拔舊 key | | Log policy | 永遠不印 key 全文;可印 `api_key_set=true/false` 或前 8 字元 prefix | -### 3.5 Trust boundary 對齊 +### 3.2 ~~visionA → FAA~~(v0.6 整段撤回;改走 ADR-016 converter 中轉) -API key 證明的是「caller 是 visionA」(machine 身份);user_id 的真實性由 visionA 內部的 OIDC cookie session 保證(user 身份)。兩條獨立鏈: +> **v0.6 整段撤回說明**:v0.5 在本節描述的「visionA → MC(issue service token + delegated download token)→ FAA」鏈路**是 fictional**——對 MC source 全 grep 驗證後確認 MC 沒有 `POST /file-access/download-tokens` endpoint、也沒有 FAA `IDelegatedDownloadTokenValidator` assume 的 introspection endpoint。 +> +> **v0.6 採用**:visionA 端不再有任何 visionA → FAA / visionA → MC server-to-server 路徑。download / 加到模型庫 兩條 path 的 NEF 取得改走 visionA → converter `GET /api/v1/jobs/{id}/result`(用同一把 `VISIONA_CONVERTER_API_KEY`),converter 從自己的 MinIO stream NEF 回 visionA。 +> +> 詳見 [ADR-016 §1 / §2](./adr/adr-016-download-via-converter.md)。 +> +> **v0.5 本節原內容**(FAA dual-auth 設計、MC service client 配置、tenant_id claim 驗證等)**僅作歷史保留**——對應的 source code 已於 commit `86b7175` 移除(faa_client.go / mc_token_client.go 整檔砍除),v0.5 規劃的「mc_token_client 部分復活」決定也撤回(不需復活)。 -- **machine auth**:visionA → converter / FAA 用 API key +#### ~~3.2.1 FAA dual-auth 設計~~(v0.6 撤回 — 僅作歷史保留) + +~~對應 [ADR-015 v2.0 §2](./adr/adr-015-server-to-server-api-key.md)~~(v0.6 整段撤回)。**v0.6 設計請看 [ADR-016](./adr/adr-016-download-via-converter.md)**。 + +#### ~~3.2.1 FAA dual-auth 設計~~(v0.6 撤回 — 僅作歷史保留說明為什麼 v0.5 路徑走不通) + +FAA `/Users/jimchen/file_access_agent/src/FileAccessAgent.Api/Program.cs` 既有設計(**Phase 0.8 / v2.0 都不動 FAA repo**): + +| FAA endpoint | line range | auth 機制 | visionA 端 token type | +|--------------|-----------|----------|----------------------| +| `GET /files/metadata/{**objectKey}` | 80-111 | `.RequireAuthorization()` + `EnsureJwtScopeAndTenant`(scope `files:metadata.read` + tenant_id 驗)| MC service token | +| `HEAD /files/{**objectKey}` | 113-148 | 同上 | MC service token | +| `PUT /files/{**objectKey}` | 150-182 | `.RequireAuthorization()` + scope `files:upload.write` + tenant 驗 | MC service token | +| **`GET /files/{**objectKey}`**(**下載**)| **184-254** | **無 `.RequireAuthorization()`;用 `IDelegatedDownloadTokenValidator.ValidateAsync(...)` 驗(active + tenant_id + object_key + method)**| **MC delegated download token** | +| `DELETE /files/{**objectKey}` | 256-287 | `.RequireAuthorization()` + scope `files:delete` + tenant 驗 | MC service token | + +→ **FAA `GET /files/{key}` 不接 service token,必須用 delegated download token**。 +→ visionA Phase 0.8 flow 只用 `GET /files/{key}`(加到模型庫 pull + 下載 stream proxy 兩條 path 都打這個 endpoint),所以**兩條 path 都走 delegated download token 路徑**。 +→ 其他 FAA endpoint(PUT / metadata / HEAD / DELETE)保留給 Phase 1+ 擴充用(如 visionA 主動清理 FAA 上的孤兒檔),到時候才走 service token 路徑。 + +#### ~~3.2.2 visionA 端流程~~(v0.6 撤回 — 整段不再執行) + +``` +visionA-backend 啟動 + ↓ +讀 cfg.OIDC.ServiceClientID / ServiceClientSecret(env VISIONA_OIDC_SERVICE_CLIENT_ID / _SECRET) +讀 cfg.OIDC.IssuerURL(env VISIONA_OIDC_ISSUER_URL — 同 user login 的 issuer) +讀 cfg.Conversion.TenantID(env VISIONA_OIDC_TENANT_ID) +讀 cfg.Conversion.FAABaseURL(env VISIONA_FAA_BASE_URL) + ↓ +[Download 請求進來] + ↓ +flow.DownloadStream / flow.PromoteToModels: + ↓ +mcTokenClient.ServiceToken(ctx) + cache hit? + Yes → return cached token(exp - 15s 內) + No → POST {issuer}/oauth/token + grant_type=client_credentials + client_id= + client_secret= + scope=files:upload.write files:metadata.read files:delete files:download.delegate + → cache + return token + ↓ +mcTokenClient.IssueDelegatedDownload(ctx, objectKey, "GET", 5*time.Minute) + POST {issuer}/file-access/download-tokens + Authorization: Bearer + Body: { object_key, method, ttl_seconds, tenant_id } + → DownloadGrant { token, expires_at, object_key, method } + ↓ +faaClient.DownloadWithDelegated(ctx, grant.Token, objectKey) + GET {faaBaseURL}/files/{objectKey} + Authorization: Bearer + → io.ReadCloser + metadata + ↓ +(flow.go 內把 stream + filename + size 包成 DownloadMetadata 回 handler) +``` + +#### ~~3.2.3 Config 對齊~~(v0.6 撤回 — visionA 端不需 ServiceClient* / TenantID / FAABaseURL) + +`visionA-backend/internal/config/config.go` 變更(v2.0 修訂): + +```go +type ConversionConfig struct { + ConverterBaseURL string `env:"VISIONA_CONVERTER_BASE_URL"` + FAABaseURL string `env:"VISIONA_FAA_BASE_URL"` + ConverterAPIKey string `env:"VISIONA_CONVERTER_API_KEY"` // v1.0 新增;v2.0 維持 + // FAAAPIKey 撤回(v1.0 加的,v2.0 移除) + TenantID string `env:"VISIONA_OIDC_TENANT_ID"` // v1.x 廢棄;v2.0 重新啟用(FAA 線需要) +} + +// OIDCConfig.ServiceClientID / ServiceClientSecret 兩欄位 v2.0 重新啟用(v1.x 廢棄) +type OIDCConfig struct { + // ... user login 相關欄位(不變)... + ServiceClientID string `env:"VISIONA_OIDC_SERVICE_CLIENT_ID"` + ServiceClientSecret string `env:"VISIONA_OIDC_SERVICE_CLIENT_SECRET"` +} + +func (c ConversionConfig) Enabled() bool { + return c.ConverterBaseURL != "" && + c.FAABaseURL != "" && + c.ConverterAPIKey != "" && + c.TenantID != "" + // OIDCConfig.ServiceClientID / Secret 是否設好由 main.go 啟動時組合判斷 +} +``` + +新增的 stage env(v2.0 修訂): + +```bash +# .env.stage +# === user login(不變,沿用 oidc-tdd.md §13.1.1)=== +VISIONA_OIDC_ISSUER_URL=https://stage-9527.innovedus.com:7850/ +# ... user login 其他 env ... + +# === Phase 0.8b v2.0 — converter 線 API key === +VISIONA_CONVERTER_BASE_URL=http://192.168.0.130:9501 +VISIONA_CONVERTER_API_KEY= + +# === Phase 0.8b v2.0 — FAA 線 MC service token + delegated download token === +VISIONA_FAA_BASE_URL=https://stage-9527.innovedus.com:5081 +VISIONA_OIDC_SERVICE_CLIENT_ID=4242ba63099d4f318dd3f143d27ef4c5 +VISIONA_OIDC_SERVICE_CLIENT_SECRET= +VISIONA_OIDC_TENANT_ID=732270c0-449c-489c-bfad-321e9bf89b3d +# Service scopes(由 MC service client 註冊時對應): +# files:upload.write files:metadata.read files:delete files:download.delegate + +# === Phase 0.8b v2.0 撤回 === +# VISIONA_FAA_API_KEY 撤回(v1.0 加的,v2.0 移除) +``` + +> ⚠️ **secret 絕不寫進 git / 文件**:上方 `VISIONA_OIDC_SERVICE_CLIENT_SECRET` 真實值僅由使用者放 stage host 的 `.env.stage` 與部署 secret store;文件 / git 一律用 placeholder。 + +#### ~~3.2.4 啟動時驗證~~(v0.6 撤回 — visionA 端啟動不再 log FAA s2s config) + +api-server 啟動時 log 一行(**不可 log secret / token**): + +``` +[INFO] FAA s2s config: faa=https://stage-9527.innovedus.com:5081 service_client_set=true tenant_id_set=true +``` + +若 OIDC ServiceClientID / Secret / Conversion TenantID / FAABaseURL 任一缺失 → conversion 模組 disabled(同 Phase 0.8「partial deploy」相容性)。 + +#### ~~3.2.5 MC scope 與 FAA endpoint 對應~~(v0.6 撤回 — visionA 端不再 issue MC service token,scope 配置由 converter 自己 / 不影響 visionA) + +確認 MC service client `4242ba63099d4f318dd3f143d27ef4c5` 註冊時對應的 4 個 scope 完整覆蓋 FAA endpoint: + +| FAA endpoint | 需要的 scope(在 service token 或 delegated token 上)| service client 是否備好 | +|--------------|--------------------------------------------------|---------------------| +| `PUT /files/...` | `files:upload.write` | ✅ | +| `GET /files/metadata/...` + `HEAD` | `files:metadata.read` | ✅ | +| `DELETE /files/...` | `files:delete` | ✅ | +| `GET /files/{key}` 下載 | `files:download.delegate`(service token 用來向 MC 換 delegated download token;FAA 端最終驗的是 delegated token 本身,不是 scope)| ✅ | + +→ Phase 0.8b v2.0 範圍內 visionA 只觸發「`GET /files/{key}` 下載」這條 path(加到模型庫 + 下載),所以 stage e2e 主要驗證 `files:download.delegate` 走通。其他 3 個 scope 為 Phase 1+ 預留。 + +**待 verify(合規性追蹤)**:stage redeploy 前實測 `POST /oauth/token` 拿到含 4 scope 的 access_token,並用該 token 對 MC `POST /file-access/download-tokens` 成功 issue delegated token。 + +### 3.3 Trust boundary 對齊(v0.6) + +- **machine auth(visionA 端唯一一條)**:visionA → converter 用 pre-shared API key(init / poll / promote / **GetResult**) +- **machine auth(不在 visionA 範圍)**:converter → FAA 用 OAuth client_credentials + `files:upload.write` scope(converter 自己管 `apps/task-scheduler/src/fileAccessAgent/client.js`、Phase 1 已上線) - **user auth**:browser → visionA 用 OIDC cookie session(既有,未變) -- visionA 是橋樑:從 OIDC sub 解出 user_id → 透過 multipart body / API path 灌進對下游的請求 +- visionA 是橋樑:從 OIDC sub 解出 user_id → 透過 multipart body 灌進對 converter 的請求(init);對 download 路徑而言,visionA 端的 API key 證明「caller 是 visionA」、ownership store 確認 user_id 與 jobID 的綁定(converter 不重複驗 user-job 關係,因為 visionA 已驗) 詳見 §7。 @@ -460,7 +619,7 @@ API key 證明的是「caller 是 visionA」(machine 身份);user_id 的 | `POST` | `/api/conversion/init` | OIDC cookie | 上傳 + 建 job(multipart streaming) | | `GET` | `/api/conversion/{job_id}` | OIDC cookie | 查 job 狀態 | | `POST` | `/api/conversion/{job_id}/promote-to-models` | OIDC cookie | 「加到模型庫」 | -| `GET` | `/api/conversion/{job_id}/download` | OIDC cookie | 「下載」— server-side HTTP 302 redirect 到 FAA delegated URL | +| `GET` | `/api/conversion/{job_id}/download` | OIDC cookie | 「下載」— v0.6:server-side stream proxy;visionA backend 中轉 NEF binary(source 從 converter `GET /api/v1/jobs/{id}/result` 拉,使用 `VISIONA_CONVERTER_API_KEY`;不再經 FAA / MC) | | `GET` | `/api/conversion/active` | OIDC cookie | 查當前 user 有無 active job(frontend pre-check);in-memory miss 時 fallback 對 converter lazy rebuild(§2.6.1) | > **不對外暴露但內部使用的 converter endpoint**:`GET /api/v1/jobs?user_id=&status=in_progress`(§2.6.1 lazy rebuild)。Phase 0.8 frontend 看不到「歷史列表」UI,但後端會用此內部 endpoint 做韌性處理。 @@ -474,9 +633,13 @@ API key 證明的是「caller 是 visionA」(machine 身份);user_id 的 - response 用既有 `WriteSuccess` / `WriteError` helper - request_id 透傳給 converter(`X-Request-Id` header) -### 4.1 `GET /api/conversion/{job_id}/download` — Phase 0.8b:server-side stream proxy handler +### 4.1 `GET /api/conversion/{job_id}/download` — Phase 0.8b v0.6:server-side stream proxy from converter -> **變更**:Phase 0.8(ADR-014 v1.1)原本是 `c.Redirect(302, FAA_URL_with_delegated_token)`;Phase 0.8b API key 模式下無 MC delegated token,改為 visionA backend 中轉 stream。 +> **演進**: +> - **Phase 0.8(ADR-014 v1.1)**:`c.Redirect(302, FAA_URL_with_delegated_token)` +> - **Phase 0.8b v0.4 (ADR-015 v1.x)**:改為 server-side stream proxy;token 來源用 visionA API key(v0.5 撤回) +> - **Phase 0.8b v0.5 (ADR-015 v2.0)**:server-side stream proxy 保留;token 來源改回 MC delegated download token(**但對 MC source 驗證後確認此設計 fictional、未實際 e2e 跑通**) +> - **Phase 0.8b v0.6 (ADR-016)**:server-side stream proxy 保留;stream 來源**從 FAA 改 converter `GET /api/v1/jobs/{id}/result`**;visionA 端不再經 MC / FAA ```go // GET /api/conversion/{job_id}/download @@ -485,7 +648,13 @@ func conversionDownloadHandler(deps Deps) gin.HandlerFunc { uc, _ := UserContextFrom(c) // AuthMiddleware 已驗 jobID := c.Param("job_id") - // service 內部完成:ownership 檢查 → ensurePromoted → 從 FAA pull stream + // service 內部完成(v0.6 流程): + // 1. ownership 檢查(visionA in-memory store) + // 2. ensurePromoted(對 converter 冪等 promote,確保 converter MinIO 內有 NEF) + // 3. converter.GetResult(ctx, jobID) + // GET {ConverterBaseURL}/api/v1/jobs/{jobID}/result + // Authorization: Bearer + // → 200 NEF binary stream + Content-Length + Content-Disposition stream, meta, err := deps.Conversion.DownloadStream(c.Request.Context(), uc.UserID, jobID) if err != nil { writeConversionError(c, err) // §6 錯誤碼分類 @@ -493,21 +662,24 @@ func conversionDownloadHandler(deps Deps) gin.HandlerFunc { } defer stream.Close() - // streaming proxy 給 client(io.Copy;不暫存 disk / RAM 全 buffer) + // streaming proxy 給 client(io.CopyN;不暫存 disk / RAM 全 buffer) c.Header("Content-Type", meta.ContentType) if meta.SizeBytes > 0 { c.Header("Content-Length", strconv.FormatInt(meta.SizeBytes, 10)) } // 鼓勵 browser 觸發 save dialog - // 注意:meta.Filename **不是** FAA metadata 直接給的(FAA 端的 object_key 是 - // `models//.nef` 對 user 不友善),而是 visionA backend 在 service 層 - // 由 `defaultDownloadFilename(cj)` 從 conversion job metadata 構造,規則: + // 注意:meta.Filename **不是** converter 直接給的 raw object_key(converter 端的 + // object_key 是 `models//.nef` 對 user 不友善);converter response 的 + // Content-Disposition 雖含 filename 建議值,但 visionA backend 仍在 service 層用 + // `defaultDownloadFilename(cj)` 從 conversion job metadata 重新構造,規則: // `_.nef`(例:`yolov5s_kl720.nef`), // 對齊 wireframe success card 顯示範例(`yolov5s.onnx → yolov5s_kl720.nef`)。 c.Header("Content-Disposition", `attachment; filename="`+sanitizeFilename(meta.Filename)+`"`) c.Header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") c.Status(http.StatusOK) - io.Copy(c.Writer, stream) + // 注意:用 io.CopyN(c.Writer, stream, sizeCap) 帶 1 GiB 上限保護 visionA backend 不被超大檔吃記憶體 + // sizeCap = 1 << 30(1 GiB)— 對 Phase 0.8 NEF(通常 < 100MB)寬鬆但有上限 + io.CopyN(c.Writer, stream, 1<<30) } } ``` @@ -518,7 +690,7 @@ func conversionDownloadHandler(deps Deps) gin.HandlerFunc { - GET semantically 對應「拿一個資源」,符合「下載這個 job 的結果」語意 - 既有 OIDC AuthMiddleware 會自然處理 GET 上的 cookie session,無 CSRF 風險(沒有狀態變更,promote 是冪等的) -**Frontend 使用範例**(與 Phase 0.8 一致,無需改動): +**Frontend 使用範例**(與 Phase 0.8 / v0.4 一致,無需改動): ```html @@ -532,17 +704,23 @@ func conversionDownloadHandler(deps Deps) gin.HandlerFunc { window.location.href = `/api/conversion/${jobId}/download`; ``` -**安全性面比較(Phase 0.8 → Phase 0.8b)**: +**安全性面比較(Phase 0.8 → v0.4 → v0.5 → v0.6)**: -| 面向 | Phase 0.8(302 redirect + MC delegated token)| Phase 0.8b(server-side stream proxy)| -|------|----|----| -| Token 在 frontend JS / URL bar | △ 短暫(Location header 流經 browser,nav 完即消失) | ✓ 結構性不存在(無 token 概念)| -| 要 FAA CORS | ✓ 不需要(navigation 不適用 CORS)| ✓ 同 — visionA 為 same-origin,FAA 直連在 server-side | -| 跨 internet 流量(同 NEF 多次下載)| ✓ 直連 FAA、N× 流量算 FAA 出 | ✗ 每次都繞 visionA backend,N× 流量算 visionA 出 | -| visionA backend 是否變 streaming bottleneck | ✓ 不是 | ✗ 是 — Phase 0.8 MVP user 量小可接受;Phase 1 量大需改 ADR-015 §7 選項 B | -| 認證鏈簡化 | ✗ 需要 MC scope `files:download.delegate` | ✓ 一把 API key 解決 | +| 面向 | Phase 0.8(302 + MC delegated token)| v0.4(server-side proxy + visionA API key 撤回)| v0.5(server-side proxy + MC delegated token;**fictional 從未跑通**)| **v0.6(server-side proxy + visionA → converter API key)** | +|------|----|----|---|---| +| Token 在 frontend JS / URL bar | △ 短暫(Location header 流經 browser) | ✓ 結構性不存在 | ✓ 結構性不存在 | ✓ 結構性不存在(API key 只在 server-side 流動)| +| 要 FAA CORS | ✓ 不需要 | ✓ 不需要 | ✓ 不需要 | ✓ 不需要(visionA 端不直接打 FAA、CORS 完全不適用) | +| 跨 internet 流量(同 NEF 多次下載)| ✓ 直連 FAA | ✗ 每次繞 visionA | ✗ 每次繞 visionA | ✗ 每次繞 visionA(同 v0.4 / v0.5,未改變;但 source 從 FAA 換 converter MinIO)| +| visionA backend 是否變 streaming bottleneck | ✓ 不是 | ✗ 是 | ✗ 是 | ✗ 是(同 v0.4 / v0.5,Phase 0.8b MVP 接受;Phase 1+ 升級見 ADR-016 後果 §負面影響)| +| 認證鏈複雜度(visionA → 下游)| MC service token + MC delegated token | 一把 API key | MC service token + MC delegated token(fictional)| 一把 API key(同 v0.4,但這次是真的 work)| +| Token TTL | 5 min(MC 簽)| ∞(API key long-lived)| 5 min(MC 簽,但 endpoint 不存在所以 issue 不到)| ∞(API key long-lived;rotate by runbook)| +| Token 洩漏的 blast radius | 5 min 內可下載該 object_key | 永遠可打 FAA 任何 endpoint | — | 永遠可打 converter 任何 endpoint(jimchen 自己管 rotate;converter 不存其他 user 資料、攻擊面限於 converter 自己)| -**Phase 1 升級路徑**:如量大需回 302 redirect 模式,採 ADR-015 §7 選項 B(visionA 自己簽 short-TTL HMAC token,FAA middleware 多支援「visionA HMAC」路徑)。 +**為什麼 v0.6 仍對齊 v0.4 / v0.5 的 server-side proxy 而非退回 302**:見 §1 整體 flow 變更說明。 + +**Phase 1+ 升級路徑**:如量大需回 302 redirect 模式(讓 browser 直連 converter 或 FAA),有兩個方向: +- 方向 A(converter Phase 2):converter 補上 `POST /api/v1/jobs/:id/download-tokens`(既有預留 501),給 browser 直連 converter;ADR-016 與此路徑相容 +- 方向 B(FAA HMAC):visionA 自己簽 short-TTL HMAC token + FAA middleware 加第三條 auth path(同 ADR-015 v2.0 §7 選項 B);但需要 warrenchen 改公司共用 FAA repo --- @@ -785,38 +963,47 @@ converter multer 偵測 incomplete multipart → 拒絕收 job(不會建 job_i ## 6. 錯誤碼 mapping + i18n key -> **Phase 0.8b 變更**:移除所有「MC token」相關錯誤碼(`idp_misconfigured` / `idp_unavailable` / `download_token_failed` / `mc_token_unavailable`)— 服務間認證已不再經 MC。新增「API key 驗證失敗」錯誤碼(visionA 端不直接面對,但下游若回 401 要處理)。 +> **Phase 0.8b v0.6 變更**:撤回 v0.5「回收 MC 兩個 code」決定。visionA 端不再有 MC / FAA 直接呼叫、`mc_token_unavailable` / `download_token_failed` 兩個 code 移除。新增 converter result endpoint 的 `result_not_found` / `result_expired` 兩個 code。 -| Converter / FAA error | visionA error code | HTTP | i18n key | user-friendly 訊息(zh-TW) | +| Converter error | visionA error code | HTTP | i18n key | user-friendly 訊息(zh-TW) | |----------|--------------------|------|----------|--------------------------| | converter `validation_error` | `validation_failed` | 400 | `conversion.error.validation` | 上傳的檔案不符合要求(請查看詳細欄位錯誤) | | converter `invalid_multipart` | `validation_failed` | 400 | `conversion.error.invalid_multipart` | 上傳格式錯誤,請重新嘗試 | | converter `user_has_active_job` | `active_job_exists` | 409 | `conversion.error.active_job` | 你目前已有進行中的轉檔任務 | | converter `file_too_large` | `payload_too_large` | 413 | `conversion.error.too_large` | 檔案超過大小限制 | | converter `service_busy` | `service_busy` | 503 | `conversion.error.busy` | 系統繁忙,請稍後再試 | -| converter `storage_unavailable` | `converter_unavailable` | 502 | `conversion.error.converter_down` | 轉檔服務暫時無法使用 | +| converter `storage_unavailable`(MinIO 不可達)| `converter_unavailable` | 502 | `conversion.error.converter_down` | 轉檔服務暫時無法使用 | | converter 5xx / network | `converter_unavailable` | 502 | `conversion.error.converter_down` | 同上 | | **converter 401(API key 不對 / 過期 / rotate 未同步)** | `converter_auth_failed` | 502 | `conversion.error.converter_down` | 轉檔服務暫時無法使用(內部 log 區分 auth_failed vs 5xx)| -| FAA 5xx / network | `faa_unavailable` | 502 | `conversion.error.faa_down` | 檔案存取服務暫時無法使用 | -| **FAA 401(API key 不對 / 過期 / rotate 未同步)** | `faa_auth_failed` | 502 | `conversion.error.faa_down` | 檔案存取服務暫時無法使用(內部 log 區分 auth_failed vs 5xx)| -| job 不屬於當前 user | `forbidden` | 403 | `conversion.error.forbidden` | 你無權存取此轉檔任務 | -| job_id 不存在 | `not_found` | 404 | `conversion.error.not_found` | 轉檔任務不存在 | +| **converter 404 `result_not_found`**(v0.6 新增;`GET /api/v1/jobs/{id}/result` job 不存在)| `not_found` | 404 | `conversion.error.not_found` | 轉檔任務不存在 | +| **converter 410 `result_expired`**(v0.6 新增;job completed 但 NEF 已被 converter MinIO GC、超 7 天 expires_at)| `result_expired` | 410 | `conversion.error.result_expired` | 轉檔結果已過期,請重新轉檔 | +| job 不屬於當前 user(visionA 端 ownership 檢查)| `forbidden` | 403 | `conversion.error.forbidden` | 你無權存取此轉檔任務 | +| job_id 不存在(visionA ownership store) | `not_found` | 404 | `conversion.error.not_found` | 轉檔任務不存在 | | job 還沒 completed | `job_not_completed` | 409 | `conversion.error.not_completed` | 任務尚未完成,請等轉檔完成再下載 | -**Phase 0.8b 移除的錯誤碼**(與 MC token 相關,認證路徑取消後不會發生): +**v0.6 變更摘要**(相對於 v0.5): -| 已移除 | 原來語意 | Phase 0.8b 替代 | -|------|----------|-----| -| `idp_misconfigured`(500) | MC token endpoint 回 4xx(scope 沒註冊 / client 設錯)| — | -| `idp_unavailable`(503) | MC token endpoint 5xx | — | -| `download_token_failed`(502) | MC delegated token 4xx | — | -| `mc_token_unavailable`(502) | MC 持續失敗 | — | +| code | v0.5 狀態 | v0.6 狀態 | 說明 | +|------|---------|---------|------| +| `converter_auth_failed` | 維持 | **維持** | converter API key 仍使用(init / poll / promote / GetResult 共用同一把)| +| `converter_unavailable` | 維持 | **維持** | converter 5xx / network | +| `result_not_found` | — | **新增** | converter `GET /api/v1/jobs/{id}/result` 回 404 | +| `result_expired` | — | **新增** | converter `GET /api/v1/jobs/{id}/result` 回 410(job 過期)| +| `faa_unavailable` | 使用中 | **撤回** | visionA 端不再直接打 FAA | +| `mc_token_unavailable` | 回收 | **撤回** | visionA 端不再打 MC | +| `download_token_failed` | 回收 | **撤回** | visionA 端不再 issue delegated token | +| ~~`faa_auth_failed`~~ | v0.5 撤回 | 維持撤回 | (v0.4 短暫存在)| +| ~~`idp_misconfigured`~~ / ~~`idp_unavailable`~~ | 維持移除 | 維持移除 | — | -下游 401 對待原則: +下游錯誤對待原則(v0.6): -- 對 visionA 端而言,下游 401 是「**部署設定錯誤**」(API key 不對齊)— 跟「使用者沒登入」(visionA → frontend 401)完全不同層次 -- visionA 從 `converter_client` / `faa_client` 收到 401 → log error(含 request_id,方便 SRE 排查)→ 回 frontend `502 converter_auth_failed` / `faa_auth_failed`,不要對 frontend 暴露「API key 不對」這個內部細節 -- 401 不 retry(同 §9)— rotate 流程不同步是運維事件,需人工介入 +- **converter 401**(API key 不對齊)→ `converter_auth_failed`,內部 log 標 reason 給 SRE +- **converter 404**(job_id 不存在 / 已被 GC)→ `result_not_found`,frontend 顯示「轉檔任務不存在」 +- **converter 410**(job completed 但 NEF 已過 7 天 expires_at 被 GC)→ `result_expired`,frontend 顯示「轉檔結果已過期,請重新轉檔」並提供重新轉檔 CTA +- **converter 4xx 其他** → 透傳 + log +- **converter 5xx / network** → `converter_unavailable`,retry 後仍失敗才回 frontend +- 對 frontend 不暴露內部細節(API key 不對 / converter MinIO 問題 / 其他下游差異)—— 統一 user-friendly 文字 `轉檔服務暫時無法使用`(除 410 `result_expired` 給更精確的「過期」訊息) +- 401 / 4xx 不 retry(同 §9)—— 都是運維事件或 user 端問題,需人工介入 / 重新轉檔 i18n key 命名:`conversion.error.`,前端 i18n 字典在 `visionA-frontend/messages/{zh-TW,en}.json` 補。 @@ -883,20 +1070,25 @@ frontend 用 `
` 觸發時,若失敗 browser 會把錯誤頁顯示在 ### 9.1 retry 規則 -> **Phase 0.8b 變更**:移除 MC 兩 row;下游 401/403(API key 不對)一律不 retry。 +> **Phase 0.8b v0.6 變更**:撤回 v0.5「回收 MC 兩 row」決定。visionA 端不再有 MC / FAA 直接呼叫、相關 row 全部移除。新增 converter `GET /jobs/{id}/result` row。 | 操作 | 401 / 403 | 4xx 其他 | 5xx | network / timeout | max retry | 退避 | |------|-----------|---------|-----|------------------|-----------|------| -| Converter `POST /jobs` | **不重試**(auth_failed)| 透傳 | retry | retry | 2 | 1s, 2s | +| Converter `POST /jobs` | **不重試**(converter_auth_failed)| 透傳 | retry | retry | 2 | 1s, 2s | | Converter `GET /jobs/{id}` | **不重試** | 透傳 | retry | retry | 3 | 0.5s, 1s, 2s | | Converter `POST /jobs/{id}/cancel`(內部 cleanup,Phase 1+)| 不重試 | 不重試 | 不重試 | 不重試 | 0 | best-effort(失敗只 log);**Phase 0.8 converter 未實作此 endpoint,靠 socket close 兜底**(§5.3.2)| | Converter `GET /jobs?user_id=&status=in_progress`(lazy rebuild)| **不重試** | 透傳 | retry | retry | 1 | 0.5s | | Converter `POST /promote` | **不重試** | 透傳 | retry | retry | 2 | 1s, 2s | -| FAA `GET /files/{key}`(s2s download / s2s pull) | **不重試** | 透傳 | retry | retry | 2 | 1s, 2s | +| **Converter `GET /jobs/{id}/result`**(v0.6 新增;下載 NEF stream)| **不重試**(401 → converter_auth_failed;404 → result_not_found;410 → result_expired)| 透傳 | retry | retry | 2 | 1s, 2s | 每次 retry 之間檢查 `ctx.Done()`;ctx cancel → 立即 return ctx.Err()。 -**401 不重試的理由**:API key 是 long-lived secret,rotate 同步是運維事件、不是瞬時抖動。401 通常意味「visionA env 與下游 env 不同步」,retry 100 次也不會自己變對,反而浪費 latency 並掩蓋運維事件。直接回 502 `*_auth_failed` 讓 SRE 看到。 +**401 / 403 不重試的理由**: + +- **converter 401**:API key 是 long-lived secret,rotate 同步是運維事件、不是瞬時抖動。401 通常意味「visionA env 與下游 env 不同步」,retry 100 次也不會自己變對。直接回 502 `converter_auth_failed` 讓 SRE 看到。 +- **converter 404 / 410**:job_id 不存在(已 GC)或結果過期,是 user 端狀態問題;retry 不會讓 NEF 重新出現。 + +> **v0.5 §9.1.1「MC service token cache miss / FAA delegated token 過期」整段撤回**(v0.6 visionA 端不再有 MC / FAA 直接互動,失敗恢復路徑簡化為「converter 5xx retry max 2 次」單條規則)。 ### 9.2 graceful degradation @@ -905,10 +1097,12 @@ frontend 用 `` 觸發時,若失敗 browser 會把錯誤頁顯示在 | converter 完全不可達(持續 5xx) | `502 converter_unavailable`,UI 提示「轉檔服務暫時無法使用,請稍後再試」 | | converter 回 401(API key 不同步)| `502 converter_auth_failed`,UI 同上文字;SRE 從 log 看到 auth_failed 計數異常 → 檢查 env | | 完成後 promote 失敗(converter 5xx) | job 留在 completed 狀態(FAA 上沒檔但 visionA 知道),UI 給 user 「重試 promote」按鈕(重打 promote-to-models / download) | -| FAA pull 失敗 — 加到模型庫流程(5xx)| model record 不寫入;UI 提示重試 | -| FAA pull 失敗 — 下載流程(5xx)| visionA backend 中轉時 503 / 502,UI 提示重試(Phase 0.8b 兩條 download path 都共用 visionA → FAA pull)| -| FAA 回 401(API key 不同步)| `502 faa_auth_failed`,UI 文字「檔案存取服務暫時無法使用」;SRE 從 log 排查 | -| visionA-backend 重啟 | in-memory ownership 與 promoted_key cache 全失,frontend 進 /conversion 時 `/active` lazy rebuild(§2.6.1);rebuild 不到的 job 由 converter 7 天 expire 自然兜底 | +| converter `GET /jobs/{id}/result` 回 404 `result_not_found`(v0.6 新增)| `404 result_not_found`,UI 顯示「轉檔任務不存在」 | +| converter `GET /jobs/{id}/result` 回 410 `result_expired`(v0.6 新增)| `410 result_expired`,UI 顯示「轉檔結果已過期,請重新轉檔」並提供重新轉檔 CTA | +| converter `GET /jobs/{id}/result` 5xx(converter MinIO 故障 / converter 自身 down)| `502 converter_unavailable`,UI 提示重試;SRE 從 log 看 5xx body 排查 | +| visionA-backend 重啟 | in-memory ownership 與 promoted_key cache 全失,frontend 進 /conversion 時 `/active` lazy rebuild(§2.6.1);rebuild 不到的 job 由 converter 7 天 expire 自然兜底;**v0.6 不再有 MC service token cache(已刪除),cold start 沒有對應 latency**| + +> **v0.5「FAA pull 失敗 / FAA 401 / 403 / MC token 失敗 / MC delegated token 失敗」整段 row 撤回**(v0.6 visionA 端不再有對應路徑)。 ### 9.3 同 user active job 衝突 @@ -932,46 +1126,51 @@ frontend 用 `` 觸發時,若失敗 browser 會把錯誤頁顯示在 詳見 §7。任何繞過此原則的設計都必須先過 ADR review。 -### 10.2 ~~Delegated download token TTL~~(Phase 0.8b 移除) +### ~~10.2 Delegated download token TTL~~(v0.6 整段撤回 — visionA 端不再有 delegated token) -> Phase 0.8b 不再有 delegated download token;download 走 server-side stream proxy。原段落(5 分鐘 TTL、`VISIONA_FAA_DELEGATED_TTL_SECONDS` env)刪除。 -> -> Phase 1+ 若量大改 ADR-015 §7 選項 B(visionA 自簽 HMAC token),那時再回設 TTL 規格。 +> v0.4 移除(API key 模式下無 delegated token);v0.5 撤回 v0.4 並回收(FAA 線回到 MC delegated download token);**v0.6 再次整段撤回**——對 MC source 驗證後確認 MC 沒有 issue delegated token 的 endpoint,v0.5 設計是 fictional。visionA 端 v0.6 起完全沒有 delegated token 概念。 -### 10.3 Pre-shared API key 保護(取代 Service token 保護) +### 10.3 Pre-shared API key 保護(v0.6 仍縮限至 converter) -- `VISIONA_CONVERTER_API_KEY` / `VISIONA_FAA_API_KEY` 不可進 git(既有 `.gitignore` 含 `.env*`,配合 `!.env*.example`) +- `VISIONA_CONVERTER_API_KEY` 不可進 git(既有 `.gitignore` 含 `.env*`,配合 `!.env*.example`) - 部署用 AWS Secrets Manager / k8s Secret 注入 - log 永遠不印 key 全文;可印 `api_key_set=true` 或前 8 字元 prefix(debug 用) -- 若 key 洩漏:產新 key → 雙方同步 env → restart visionA / converter(或 FAA)→ 驗證 → 拔舊 key(runbook Phase 0.9 補) -- 已洩漏的 stage service client secret `RciRUyi...` 改 API key 後直接作廢,無 rotate 動作 +- 若 key 洩漏:產新 key → visionA + converter 同步 env → restart → 驗證 → 拔舊 key(runbook Phase 0.9 補) +- v0.4 加的 `VISIONA_FAA_API_KEY` 在 v0.5 / v0.6 維持撤回 +- **v0.6 新增**:同一把 `VISIONA_CONVERTER_API_KEY` 也用於 download 路徑(converter `GET /api/v1/jobs/{id}/result`),不需新增 secret -### 10.4 Object key 不暴露給 frontend JS +### ~~10.4 MC Service Token + Delegated Download Token 保護~~(v0.6 整段撤回 — visionA 端不再有 MC service token / delegated token) -- Phase 0.8b:visionA-backend 透過 server-side stream proxy 把 NEF stream 中轉回 browser,**FAA URL / object_key 都不出現在任何 frontend response** -- frontend JS 對 object_key / 內部 FAA 路徑完全沒有 reference +> v0.5 新增本節給 FAA 線;**v0.6 整段撤回**——visionA 端不再有 `VISIONA_OIDC_SERVICE_CLIENT_ID` / `_SECRET` / `VISIONA_OIDC_TENANT_ID` 三個 env,不再 issue MC service token、不再 issue delegated download token。user login 的 OIDC(public PKCE)是另一條完全獨立的鏈、不在本節範圍(詳見 `oidc-tdd.md`)。 + +### 10.5 Object key 不暴露給 frontend JS + +- Phase 0.8b v0.6:visionA-backend 透過 server-side stream proxy 把 NEF stream 中轉回 browser,**converter MinIO object_key / converter API key 都不出現在任何 frontend response** +- frontend JS 對 object_key / 內部 converter 路徑完全沒有 reference - 防快取:handler 設 `Cache-Control: no-store, no-cache, must-revalidate`,避免 browser cache NEF stream -- **不需 FAA CORS**:visionA → FAA 是 server-side 同進程內 outbound HTTP call,不適用 CORS(CORS 只管 browser JS fetch / XHR) +- **不需 CORS(converter 端或 FAA 端)**:visionA → converter 是 server-side 同進程內 outbound HTTP call,不適用 CORS(CORS 只管 browser JS fetch / XHR);browser 完全不知道 converter / FAA 的存在 - visionA backend 是 attack surface:任何能拿到 visionA cookie session 的 attacker 都能下載自己 user_id 的 NEF — 但這本來就是 user 自己的檔,無 escalation -### 10.4b Phase 0.8 → Phase 0.8b 安全面遷移摘要 +### 10.6 Phase 0.8 → v0.4 → v0.5 → v0.6 安全面遷移摘要 -| 面向 | Phase 0.8(302 + delegated token)| Phase 0.8b(server-side proxy)| -|------|----|----| -| Token 結構是否存在 | 是(MC issue,5 分鐘 TTL)| 否 | -| 攻擊者攔截 visionA → browser response 拿到 token | 短期可用 5 分鐘 | 結構性無 token | -| Frontend XSS 影響範圍 | 短 TTL token | 無 token 可竊 | -| Server compromise(visionA backend 被攻破)| 攻擊者可簽任意 MC delegated token | 攻擊者拿到 API key 後可任意打 converter / FAA | -| Defense in depth | Token TTL + scope 限制 | API key + visionA OIDC 上游 user auth | -| 結論 | 兩者都安全可接受;Phase 0.8b 取捨「實作簡化 + bottleneck」換「無 token in browser 的更乾淨模型」| +| 面向 | Phase 0.8(302 + delegated token)| v0.4(server-side proxy + visionA API key)| v0.5(server-side proxy + delegated token;**fictional 從未跑通**)| **v0.6(server-side proxy + visionA → converter API key)**| +|------|----|----|---|---| +| Token 結構是否存在於 visionA ↔ 下游鏈 | 是(MC issue,5 分鐘 TTL,過 browser)| 是(visionA API key,long-lived,server-side only)| 是(MC issue,但實際 issue 不到)| 是(visionA API key,long-lived,server-side only)| +| 攻擊者攔截 visionA → browser response 拿到 token | 短期可用 5 分鐘 | 結構性無 token | — | 結構性無 token(API key 不過 browser)| +| Frontend XSS 影響範圍 | 短 TTL token | 無 token 可竊 | — | 無 token 可竊 | +| Server compromise(visionA backend 被攻破)| 攻擊者可簽任意 MC delegated token(限於 service client 的 scope)| 攻擊者拿到 visionA API key 後可任意打 FAA 所有 endpoint | — | 攻擊者拿到 `VISIONA_CONVERTER_API_KEY` 後可任意打 converter 所有 endpoint;但 converter **不存其他 user / 其他產品線資料**(只存進行中 / 完成的轉檔 job、7 天 GC),blast radius 比 v0.4 直接打 FAA 小 | +| MC 是否為依賴 | 是(issue token)| 否 | 是(issue service token + delegated token)| 否(visionA 端 server-to-server 不依賴 MC;user login 仍依賴 MC OIDC,但與本表無關)| +| FAA 是否為依賴 | 是(直接打)| 是(直接打)| 是(直接打)| **否**(visionA 端只打 converter;converter → FAA 是 converter 自己的事)| +| Defense in depth | Token TTL + scope 限制 + tenant 限制 | API key + visionA OIDC 上游 user auth | — | API key + visionA OIDC 上游 user auth + ownership store + converter MinIO 7 天 GC(自然 retention) | +| 結論 | Phase 0.8 安全靠 MC token TTL;v0.4 移除 token 結構但 API key long-lived;v0.5 設計上應更安全但實際 fictional;**v0.6 與 v0.4 等價安全模型,但 blast radius 限於 converter(非 FAA)+ 失敗模式收斂為單條鏈,整體 SRE 可運維性最佳**| -### 10.5 Race condition +### 10.7 Race condition - 同 user 同時兩 tab init → 第一個成功寫 ownership / converter 接受;第二個 pre-check 通過但 converter 409 -- 兩 tab 同時 promote-to-models → 第一個寫 model record 成功;第二個重複呼叫 ensurePromoted(cache hit)→ FAA pull 兩次(接受的取捨;FAA 端冪等)→ models repo 寫入時可能撞 model_id 衝突 — 改用 model_id 在 finalize 前 SELECT 檢查 -- 兩 tab 同時 download → visionA backend 各自獨立 FAA pull(無 cache);兩條 stream 同時跑、兩條都成功(FAA 端冪等讀)— Phase 0.8b 可接受,量大時再加 server-side stream cache +- 兩 tab 同時 promote-to-models → 第一個寫 model record 成功;第二個重複呼叫 ensurePromoted(cache hit)→ **converter.GetResult 拉 NEF 兩次**(接受的取捨;converter MinIO 端冪等讀)→ models repo 寫入時可能撞 model_id 衝突 — 改用 model_id 在 finalize 前 SELECT 檢查 +- 兩 tab 同時 download → visionA backend 各自獨立 converter.GetResult(無 cache);兩條 stream 同時跑、兩條都成功(converter MinIO 端冪等讀)— Phase 0.8b 可接受,量大時再加 server-side stream cache 或方向 A(converter Phase 2 download-tokens 讓 browser 直連 converter) -### 10.6 DoS 防護(最小集,Phase 1 強化) +### 10.8 DoS 防護(最小集,Phase 1 強化) - 同 user 1 個 active job 的限制本身就是 DoS 防護(user 不能 init 1000 個 job) - visionA-backend conversion endpoint 不額外 rate limit(Phase 1 補;對齊 `security.md` §4) @@ -983,26 +1182,73 @@ frontend 用 `` 觸發時,若失敗 browser 會把錯誤頁顯示在 實作此 spec 會動到的檔案(給 Backend Agent 參考;Backend 自己拆任務): -**Phase 0.8b 變更(在 Phase 0.8 已上的 code 上面動)**: +**Phase 0.8b v0.6 變更(相對於 v0.5 規劃但未上的 code,backend agent 下次任務範圍)**: -- 砍:`visionA-backend/internal/conversion/mc_token_client.go`(~440 行整檔刪除) -- 砍:`visionA-backend/internal/conversion/mc_token_client_test.go`(對應 test) -- 改:`visionA-backend/internal/conversion/converter_client.go` — 移除 `tokens *MCTokenClient` 欄位,改 `apiKey string`;每個 method 內 `Authorization: Bearer ` 直接 set -- 改:`visionA-backend/internal/conversion/faa_client.go` — 同上模式 -- 改:`visionA-backend/internal/conversion/flow.go` — 移除 `tokens` 欄位;download path 從 `DownloadRedirectURL` 改為 `DownloadStream`(從 FAA pull stream 回給 caller) -- 改:`visionA-backend/internal/conversion/conversion.go` — `Service` interface `DownloadRedirectURL` 改為 `DownloadStream(...) (io.ReadCloser, *DownloadMetadata, error)` -- 改:`visionA-backend/internal/api/conversion.go` — `conversionDownloadHandler` 從 `c.Redirect(302, ...)` 改為 `io.Copy(c.Writer, stream)` + 設好 Content-Type / Content-Disposition / Cache-Control +> v0.4 階段已上線的 commit `86b7175` 把 converter / FAA 兩條線都改為 API key(並把 mc_token_client.go 整檔砍除)。v0.5 規劃要把 FAA 線回退、mc_token_client 部分復活(**但這部分尚未進實作**)。v0.6 撤回 v0.5 規劃,改為:commit `86b7175` 的 mc_token_client 砍除狀態**維持**;faa_client.go 改名為 converter_result_client.go(或併入 converter_client.go)承接新 `GetResult` method;config.go / .env 撤回 v0.5 規劃要加回的 OIDC ServiceClient* / TenantID / FAABaseURL。 +> +> 同時:**converter 跨 repo 加新 endpoint**(jimchen 自己處理)。 + +**v0.6 跨 repo:converter scheduler(jimchen 在 `/Users/jimchen/kneron_model_converter/apps/task-scheduler/`)**: + +- 新增 `src/routes/v1/result.js`(或加進 `jobs.js`):`GET /api/v1/jobs/:id/result` handler,套用既有 `requireReadAuth` middleware(API key 比對) +- handler 內:job status / expires_at 檢查 → MinIO get object stream → `pipeline(minioStream, res)` + 設 headers +- 新增 integration test:`src/routes/v1/__tests__/result.integration.test.js` +- 更新 `openapi.yaml`(加 `GET /api/v1/jobs/{id}/result` path 規格,含 200 / 401 / 404 / 409 / 410 / 500 / 502 / 503 response) +- 更新 `README.md`(API 清單加新 endpoint) + +**v0.6 從 v0.5 規劃撤回的 source code 變更**(visionA backend;v0.5 規劃要做、v0.6 不做): + +- **不做**:`visionA-backend/internal/conversion/mc_token_client.go` 部分復活(v0.5 規劃 → v0.6 撤回;commit `86b7175` 已砍除狀態維持) +- **不做**:`visionA-backend/internal/conversion/mc_token_client_test.go` 復活 +- **不做**:`visionA-backend/internal/conversion/faa_client.go` 加 `tokens *MCTokenClient` 欄位 + `DownloadWithDelegated` method(v0.5 規劃 → v0.6 撤回;改成下方「新增 GetResult」) +- **不做**:`visionA-backend/internal/conversion/flow.go` 加 `tokens *MCTokenClient` 欄位 + `IssueDelegatedDownload` 步驟(v0.5 規劃 → v0.6 撤回;改成下方「直接呼叫 converter.GetResult」) +- **不做**:`OIDCConfig.ServiceClientID` / `ServiceClientSecret` 重新啟用(v0.5 規劃 → v0.6 撤回) +- **不做**:`ConversionConfig.TenantID` 重新啟用(v0.5 規劃 → v0.6 撤回) +- **不做**:`.env.stage.example` / `.env.dev.example` 加回 `VISIONA_OIDC_SERVICE_CLIENT_ID` / `_SECRET` / `VISIONA_OIDC_TENANT_ID`(v0.5 規劃 → v0.6 撤回) + +**v0.6 visionA backend 實際要做的 source code 變更**: + +- 改 / 改名:`visionA-backend/internal/conversion/faa_client.go` → **改名為 `converter_result_client.go`**(或併入 `converter_client.go` 作為新 method),唯一職責是打 converter `GET /api/v1/jobs/{id}/result` 拉 NEF stream +- 改:`visionA-backend/internal/conversion/converter_client.go` — + - 維持既有 init / poll / promote method 不變 + - **新增 method**:`GetResult(ctx context.Context, jobID string) (io.ReadCloser, *DownloadMetadata, error)`,內部 `GET {baseURL}/api/v1/jobs/{jobID}/result` + `Authorization: Bearer `,解析 response Content-Length / Content-Disposition / body stream;error mapping 對應 ADR-016 §1.3 +- 改:`visionA-backend/internal/conversion/flow.go` — + - 移除 `tokens *MCTokenClient` 欄位(如尚未從 commit `86b7175` 完全清掉) + - 移除 `faa *FAAClient` 欄位(如尚未清掉) + - `DownloadStream` 內部:`ensurePromoted` 之後直接呼叫 `flow.converter.GetResult(ctx, jobID)` + - `PromoteToModels` 內部:同樣改呼叫 `flow.converter.GetResult(ctx, jobID)`(與 DownloadStream 共用同條 path) + - filename 處理:拿到 stream 後用 `defaultDownloadFilename(cj)` 覆寫 converter 給的 filename(不變、規則同 v0.5) +- 改:`visionA-backend/internal/conversion/conversion.go` — + - `Service.DownloadStream` 簽名不變 + - 刪除 v0.5 註解中的 `DownloadGrant` struct(不再需要) - 改:`visionA-backend/internal/config/config.go` — - - `ConversionConfig`:新增 `ConverterAPIKey` / `FAAAPIKey` 兩欄位 - - `ConversionConfig.Enabled()` 加入兩個 API key 非空檢查 - - `OIDCConfig.ServiceClientID` / `ServiceClientSecret`:conversion 不再依賴;如其他模組未使用即從 struct 移除(檢查 grep) - - `ConversionConfig.TenantID`:conversion 不再依賴;如其他模組未使用即移除 -- 改:`visionA-backend/cmd/api-server/main.go` — wire conversion.Flow 時不再傳 MCTokenClient;改傳兩個 API key -- 改:`.env.stage.example` / `.env.dev.example` — 移除 `VISIONA_OIDC_SERVICE_CLIENT_ID` / `_SECRET` / `VISIONA_OIDC_TENANT_ID`;新增 `VISIONA_CONVERTER_API_KEY` / `VISIONA_FAA_API_KEY` -- 改:對應的 unit test / integration test — 移除 MC mock;改用 fake converter / FAA server,驗 `Authorization: Bearer ` header 正確帶上 -- 不動:`internal/model/*`(schema 不變) -- 不動:`internal/api/models.go`(既有 init/finalize 不動,flow.PromoteToModels 內部呼叫 helper) -- 不動:OIDC user login 相關全部(`internal/oidc/`、`internal/usersession/`、`/api/auth/*` handlers) + - `ConversionConfig.FAAAPIKey` 維持移除(v0.5 撤回過、v0.6 維持) + - **新增移除**:`ConversionConfig.FAABaseURL`(v0.5 規劃要保留 / v0.6 移除) + - `ConversionConfig.TenantID` 維持移除(v0.5 規劃要加回 / v0.6 撤回) + - `OIDCConfig.ServiceClientID` / `ServiceClientSecret` 維持移除(v0.5 規劃要加回 / v0.6 撤回) + - `ConversionConfig.Enabled()` 簡化:只判 `ConverterBaseURL != "" && ConverterAPIKey != ""` +- 改:`visionA-backend/cmd/api-server/main.go` — wire `conversion.Flow` 時不傳 `MCTokenClient`、不傳 `FAAClient`、不傳 FAA / Tenant config +- 改:`.env.stage.example` / `.env.dev.example` — + - 維持移除 `VISIONA_FAA_API_KEY`(v0.4 加 / v0.5 撤回 / v0.6 維持撤回) + - 維持移除 `VISIONA_OIDC_SERVICE_CLIENT_ID` / `_SECRET` / `VISIONA_OIDC_TENANT_ID` + - **新增移除**:`VISIONA_FAA_BASE_URL`(v0.5 規劃要保留 / v0.6 移除) + - 保留 `VISIONA_CONVERTER_BASE_URL` / `VISIONA_CONVERTER_API_KEY` +- 改:對應 unit / integration test — `converter_client_test.go` 加 `GetResult` test(含 401 / 404 / 410 / 5xx mapping);刪除 `faa_client_test.go`(v0.5 規劃要保留 / v0.6 撤回) + +**v0.6 維持 v0.5 / v0.4 既有的 source code 變更**(converter API key 線不變): + +- 維持:`visionA-backend/internal/conversion/converter_client.go` 仍用 `apiKey string` + `Authorization: Bearer `(init / poll / promote method,沒變) +- 維持:`Service` interface `DownloadStream(...) (io.ReadCloser, *DownloadMetadata, error)`(v0.4 改的、v0.5 / v0.6 不退回 DownloadRedirectURL) +- 維持:`visionA-backend/internal/api/conversion.go` `conversionDownloadHandler` 仍用 `io.CopyN(c.Writer, stream, 1<<30)`(v0.4 改的、v0.5 / v0.6 不退回 302) +- 維持:`ConversionConfig.ConverterAPIKey` 欄位 + `Enabled()` 中對 ConverterAPIKey 的檢查 + +**不動(v0.4 / v0.5 / v0.6 都不影響)**: + +- `internal/model/*`(schema 不變) +- `internal/api/models.go`(既有 init/finalize 不動,flow.PromoteToModels 內部呼叫 helper) +- OIDC user login 相關全部(`internal/oidc/`、`internal/usersession/`、`/api/auth/*` handlers) + +> ⚠️ **本次(2026-05-16 / v0.6)的範圍只動共享文件**(本文 conversion.md / api-conversion.md / oidc-tdd.md / adr-014 / adr-015 / adr-016)。source code 改造由 backend agent 下次任務處理(visionA backend 撤回 v0.5 規劃 + 加 converter.GetResult method + 改 flow.go + 改 config / .env;converter 端跨 repo 加新 endpoint 由 jimchen 自行處理)。 --- @@ -1015,3 +1261,5 @@ frontend 用 `` 觸發時,若失敗 browser 會把錯誤頁顯示在 | 2026-04-30 | 0.3 | Phase 0.8 三方交叉審閱回饋整合:§2.6.1 補「visionA-backend 重啟後 lazy rebuild ownership」(議題 #2,A4 方案);§2.6.2 補 expires_at 來源(議題 #7);§4.3.1 streaming proxy 進度語意明確化(議題 #6,採選項 A:等 converter 201 才回 200);§4.3.2 補 cancel cleanup 鏈與 best-effort cancel converter(議題 #5) | | 2026-05-11 | 0.4 | **Phase 0.8b**:服務間認證從 OAuth `client_credentials` 改為 pre-shared API key(對應 [ADR-015](./adr/adr-015-server-to-server-api-key.md))。主要變更:(1) §1 端對端 sequence 拿掉 MC node;(2) §2 砍 `mc_token_client.go` 整個檔;(3) §3 新增「服務間認證(API key)」章節(原 §5 OAuth 章節整段刪除,章節編號 4→5);(4) §4.1 `/download` handler 從 `c.Redirect(302)` 改 server-side stream proxy(Service interface `DownloadRedirectURL` → `DownloadStream`);(5) §6 錯誤碼 mapping 移除 MC 4 個 code、新增 `converter_auth_failed` / `faa_auth_failed`;(6) §9.1 retry 矩陣移除 MC 2 row、所有下游 401/403 不重試;(7) §10.2 刪除 delegated token TTL、§10.3 改為 pre-shared API key 保護、§10.4 改為 server-side stream proxy 安全模型;(8) 變更影響清單列出 backend agent 後續實作要動的 .go 檔。OIDC user login 完全不動。 | | 2026-05-15 | 0.4.1 | 修 §4.1 `/download` handler `Content-Disposition` filename 來源描述歧義(T4 Reviewer M-3)— 原註釋「filename 來自 promote 結果」可被誤讀為「FAA promote response 直接給 filename」;改為明確標示「visionA backend 在 service 層由 `defaultDownloadFilename(cj)` 從 conversion job metadata 構造(規則 `_.nef`),對齊 wireframe success card 顯示範例」、並補充「FAA 端的 object_key 是 `models//.nef` 對 user 不友善」的對比說明。純文字釐清、無實作行為變更。 | +| 2026-05-16 | 0.5 | **對應 ADR-015 v2.0 範圍縮限**:撤回 v0.4「visionA → FAA 改 API key」決定、FAA 線回到 ADR-014 §2 原設計(MC service token + delegated download token);visionA → converter API key 路線(v0.4)**維持**。主要變更:(1) §1 整體 flow sequence 加回 MC node、download path 改回「MC issue delegated token → visionA 帶 delegated token 打 FAA」;(2) §2 模組設計 — mc_token_client.go 部分復活(保留 service token cache + IssueDelegatedDownload 邏輯)、faa_client.go 改 `DownloadWithDelegated(ctx, delegatedToken, objectKey)`、flow.go 加回 `tokens *MCTokenClient` 欄位、DownloadStream / PromoteToModels 流程加回 IssueDelegatedDownload 步驟;(3) §3 拆成 §3.1 visionA → converter(API key)+ §3.2 visionA → FAA(service token + delegated download token),§3.2 詳述 FAA dual-auth 設計與為什麼 download endpoint 強制用 delegated token;(4) §4.1 download handler 流程改回「ensurePromoted → IssueDelegatedDownload → DownloadWithDelegated」(保留 server-side stream proxy 不退回 302);(5) §6 錯誤碼回收 `mc_token_unavailable` / `download_token_failed` 兩個 code,撤回 v0.4 加的 `faa_auth_failed`;(6) §9 retry 矩陣回收 MC 兩 row、FAA row 改回 service token + delegated;(7) §10 安全考量 — §10.2 delegated token TTL 回收、§10.3 API key 保護縮限至 converter、新增 §10.4 MC service token + delegated download token 保護、§10.6 三方對比加 v0.5 column;(8) 變更影響清單列出 backend agent 下次任務範圍(從 v0.4 回退 FAA 線 + mc_token_client 部分復活)。**本次純文件修訂、source code 改造留給 backend agent 下次任務**。OIDC user login 完全不動。 | +| 2026-05-16 | 0.6 | **對應 [ADR-016](./adr/adr-016-download-via-converter.md)**:撤回 v0.5「visionA → FAA 線回到 MC service token + delegated download token」**全部規劃**。原因:對 MC source 全 grep 驗證後確認 MC **沒有** `POST /file-access/download-tokens` endpoint、也沒有 FAA `MemberCenterDelegatedDownloadTokenValidator` assume 的 introspection endpoint—— ADR-014 §2 與 ADR-015 v2.0 §2 的 delegated token 鏈是 fictional(從 2026-05-02 寫定起未曾 e2e 跑通)。**v0.6 新設計**:visionA download 改走 **converter 新增的 `GET /api/v1/jobs/{id}/result` + visionA stream 中轉**;visionA 端不再有任何 visionA → MC / visionA → FAA 路徑、server-to-server 認證收斂為單條 visionA → converter(API key);promote 仍走 visionA → converter `POST /promote`(converter 內部 PUT FAA 與 visionA 無關,不變)。主要變更:(1) §1 整體 flow sequence 移除 MC node、download path 改成「converter.GetResult」;(2) §2 模組設計 — mc_token_client.go 維持砍除(撤回 v0.5 部分復活)、faa_client.go 改名為 converter_result_client.go(或併入 converter_client.go),新增 `GetResult` method、flow.go 移除 `tokens *MCTokenClient` 欄位、DownloadStream / PromoteToModels 都改走 converter.GetResult;(3) §3 整段重寫 — §3.1 visionA → converter API key(不變、新增同 key 用於 GetResult endpoint)+ §3.2 visionA → FAA 整段撤回(§3.2.1~§3.2.5 全部標 v0.6 撤回);(4) §4.1 download handler 流程改成「ensurePromoted → converter.GetResult」(保留 server-side stream proxy 不退回 302);(5) §6 錯誤碼撤回 `faa_unavailable` / `mc_token_unavailable` / `download_token_failed` 三個 code、新增 `result_not_found` / `result_expired` 兩個 code;(6) §9 retry 矩陣移除 MC 兩 row、FAA row 全部撤回、新增 Converter GetResult row;(7) §10 安全考量 — §10.2 delegated token TTL 整段撤回、§10.3 API key 保護維持縮限至 converter 同時新增「同一把用於 GetResult」說明、§10.4 MC service token + delegated download token 保護整段撤回、§10.6 加 v0.6 column 對比、§10.7 race condition 與 §10.8 DoS(重編號)更新;(8) 變更影響清單列出 backend agent 下次任務範圍(從 v0.5 規劃撤回 + 新增 converter.GetResult + 跨 repo converter scheduler 加 endpoint)。**本次純文件修訂、source code 改造留給 backend agent 下次任務 + converter 跨 repo 由 jimchen 自行處理**。OIDC user login 完全不動。 | diff --git a/docs/autoflow/04-architecture/oidc-tdd.md b/docs/autoflow/04-architecture/oidc-tdd.md index 9e721aa..9e5f153 100644 --- a/docs/autoflow/04-architecture/oidc-tdd.md +++ b/docs/autoflow/04-architecture/oidc-tdd.md @@ -2,17 +2,18 @@ ## Metadata - **作者**:Architect Agent -- **狀態**:Phase 0.8b 修訂(service client / server-to-server 改 API key;user login 部分不變) -- **最後更新**:2026-05-11 +- **狀態**:Phase 0.8b v0.4 修訂(converter 線改 API key;FAA 線**完全撤回**改走 ADR-016 converter 中轉;user login 部分不變) +- **最後更新**:2026-05-16 - **文件角色**:Phase 0.6 把 visionA-backend 的 `StaticAuthProvider` 替換為 OIDC 接 Innovedus Member Center -- **上位文件**:`TDD.md`、`security.md`、`adr/adr-005-no-db-auth-in-prototype.md`、`adr/adr-010-oidc-bff.md`、`adr/adr-013-public-client.md`、[`adr/adr-015-server-to-server-api-key.md`](./adr/adr-015-server-to-server-api-key.md)(Phase 0.8b 服務間認證部分 supersede ADR-014 §5/§6 service token 段落;user login 部分不受影響) +- **上位文件**:`TDD.md`、`security.md`、`adr/adr-005-no-db-auth-in-prototype.md`、`adr/adr-010-oidc-bff.md`、`adr/adr-013-public-client.md`、[`adr/adr-015-server-to-server-api-key.md`](./adr/adr-015-server-to-server-api-key.md) **v2.1**(converter 線改 API key 維持;§2 visionA → FAA 整段被 ADR-016 supersede)、[`adr/adr-016-download-via-converter.md`](./adr/adr-016-download-via-converter.md)(v0.6/v0.4 新增上位:visionA → FAA / MC 路徑完全撤回、改走 converter 中轉) - **下位文件**:`adr/adr-010-oidc-bff.md`(本文件 §16) - **讀者**:Backend / Frontend / DevOps / Testing Agents -> **Phase 0.8b 範圍說明(重要)**: +> **Phase 0.8b v0.4 範圍說明(重要)**: > > - **user login(browser → visionA backend)**:完全不變。仍走 PKCE-only public client、ADR-013 描述的 redirect flow、cookie session、JWKS 驗 id_token,本文件 §1-§12、§14-§17 全部仍有效。 -> - **server-to-server(visionA backend → converter / FAA)**:Phase 0.8b 改用 pre-shared API key 取代原本的 OAuth `client_credentials` grant。詳見 ADR-015;本文件 §13.1 「Service Client 預留欄位」段落隨之更新(**改為標示廢棄、不再啟用**)。 +> - **server-to-server(visionA backend → converter)**:v1.0 改用 pre-shared API key 取代 OAuth `client_credentials` grant;v2.0 / v0.4 維持。詳見 ADR-015 §1。 +> - **server-to-server(visionA backend → FAA / MC)**:v1.x 改 API key 已於 v2.0 撤回;v2.0 規劃回到 ADR-014 §2 原設計(MC service token + delegated download token)— **本文件 v0.4 整段再次撤回**。對 MC source 驗證後確認該 endpoint 從未存在;visionA download 改走 [ADR-016](./adr/adr-016-download-via-converter.md)(converter 新增 `GET /api/v1/jobs/{id}/result` + visionA stream 中轉)。本文件 §13.1 「Service Client」相關欄位(v1.x 標廢棄 → v2.0 規劃啟用)**v0.4 維持廢棄**。 --- @@ -1455,10 +1456,12 @@ VISIONA_PAIRING_TOKEN=vAc_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa | `VISIONA_OIDC_CLIENT_SECRET` | — | **選填**(A1 改造後)| 為空時走 **public PKCE-only mode**;非空時走 confidential mode(ADR-013)| | `VISIONA_OIDC_REDIRECT_URL` | — | ✅ | dev: `http://localhost:3721/api/auth/callback`;stage: `https://stage-9527.innovedus.com:9527/api/auth/callback`;prod: `https://api.visiona.cloud/api/auth/callback` | | `VISIONA_OIDC_SCOPES` | `openid email profile` | — | 空格分隔 | -| ~~`VISIONA_OIDC_SERVICE_CLIENT_ID`~~ | — | **Phase 0.8b 廢棄** | ~~client_credentials grant 用的 confidential client ID~~。Phase 0.8 短暫啟用後,Phase 0.8b 改用 API key(ADR-015)取代;env 從 `.env.stage.example` 移除 | -| ~~`VISIONA_OIDC_SERVICE_CLIENT_SECRET`~~ | — | **Phase 0.8b 廢棄** | 同上廢棄;stage 上已洩漏的 secret 直接作廢、不 rotate | -| `VISIONA_CONVERTER_API_KEY` | — | **Phase 0.8b 新增** | visionA → converter 服務間認證 pre-shared API key(64 字元 hex),詳見 ADR-015 與 `conversion.md` §3 | -| `VISIONA_FAA_API_KEY` | — | **Phase 0.8b 新增** | visionA → FAA 服務間認證 pre-shared API key(64 字元 hex),詳見 ADR-015 與 `conversion.md` §3 | +| ~~`VISIONA_OIDC_SERVICE_CLIENT_ID`~~ | — | **Phase 0.8b v0.4 再次廢棄**(v1.x 廢棄;v2.0 規劃啟用;v0.4 / ADR-016 撤回 v2.0 啟用)| ~~client_credentials grant 用的 confidential client ID — FAA 線~~。visionA 端不再有 visionA → MC server-to-server 路徑;download 改走 [ADR-016](./adr/adr-016-download-via-converter.md) converter 中轉 | +| ~~`VISIONA_OIDC_SERVICE_CLIENT_SECRET`~~ | — | **Phase 0.8b v0.4 再次廢棄**(同上)| ~~對應 client_credentials grant 的 secret~~。**舊洩漏值** `RciRUyiCkbd60ikkZGkfQ2xV4r02VW3/j0ASKV/DD/E=` 仍作廢;v0.4 後 visionA 端不再持有 service client secret | +| ~~`VISIONA_OIDC_TENANT_ID`~~ | — | **Phase 0.8b v0.4 再次廢棄**(同上)| ~~MC service client 註冊時對應的 tenant~~。visionA 端不再需要 tenant_id claim(FAA 端的 tenant 驗由 converter 自己管,與 visionA 無關)| +| `VISIONA_CONVERTER_API_KEY` | — | **Phase 0.8b v1.0 新增;v2.0 / v0.4 維持** | visionA → converter 服務間認證 pre-shared API key(64 字元 hex);**v0.4 起也用於 download 路徑**(converter `GET /api/v1/jobs/{id}/result`)。詳見 ADR-015 §1、ADR-016 §1、`conversion.md` §3.1 | +| ~~`VISIONA_FAA_API_KEY`~~ | — | **Phase 0.8b v2.0 撤回**(v1.0 加 / v2.0 移除 / v0.4 維持移除)| — | +| ~~`VISIONA_FAA_BASE_URL`~~ | — | **Phase 0.8b v0.4 再次廢棄**(既有 / v2.0 維持 / v0.4 移除)| ~~visionA → FAA 的 base URL~~。visionA 端不再直接打 FAA | | `VISIONA_FRONTEND_URL` | — | ✅ | dev: `http://localhost:3000`;stage: `https://stage-9527.innovedus.com:9527`;prod: `https://app.visiona.cloud` | | `VISIONA_SESSION_SECRET` | — | ✅ | 至少 32 byte 隨機字串,HMAC cookie 簽章。產法:`openssl rand -hex 32` | | `VISIONA_SESSION_COOKIE_NAME` | `visiona_session` | — | — | @@ -1484,7 +1487,7 @@ VISIONA_SESSION_COOKIE_SECURE=false # Service client 不設(Phase 0.7 不接 MC API) ``` -**stage 環境(public PKCE-only client + Phase 0.8b API key 服務間認證)—— Innovedus stage MC 配給的真實 client:** +**stage 環境(public PKCE-only client + Phase 0.8b v0.4 單線 server-to-server 認證設計)—— Innovedus stage MC 配給的真實 client:** ```bash # .env.stage @@ -1497,18 +1500,27 @@ VISIONA_FRONTEND_URL=https://stage-9527.innovedus.com:9527 VISIONA_SESSION_SECRET= VISIONA_SESSION_COOKIE_SECURE=true -# === Phase 0.8b 服務間認證(API key,取代 OAuth service token)=== +# === Phase 0.8b v0.4 — visionA 端唯一一條 server-to-server 鏈:visionA → converter(API key)=== +# 同一把 key 用於 init / poll / promote / GET result endpoint VISIONA_CONVERTER_BASE_URL=http://192.168.0.130:9501 -VISIONA_FAA_BASE_URL=http://192.168.0.130:5081 VISIONA_CONVERTER_API_KEY= -VISIONA_FAA_API_KEY= -# === Phase 0.8b 移除(不再使用)=== -# VISIONA_OIDC_SERVICE_CLIENT_ID=...(已廢棄;ADR-015) -# VISIONA_OIDC_SERVICE_CLIENT_SECRET=...(已廢棄;stage 上已洩漏的值直接作廢、不 rotate) -# VISIONA_OIDC_TENANT_ID=...(conversion 不再依賴;其他模組未發現使用) +# === Phase 0.8b v0.4 撤回(v2.0 規劃要設、v0.4 / ADR-016 撤回)=== +# visionA 端不再需要 MC service client(visionA → MC server-to-server 路徑完全移除) +# VISIONA_OIDC_SERVICE_CLIENT_ID=... +# VISIONA_OIDC_SERVICE_CLIENT_SECRET=... +# VISIONA_OIDC_TENANT_ID=... +# visionA 端不再直接打 FAA(download 走 converter 中轉) +# VISIONA_FAA_BASE_URL=... +# VISIONA_FAA_API_KEY=...(v1.0 加 / v2.0 撤回 / v0.4 維持撤回) ``` +> ⚠️ **secret 處理規則(v0.4)**: +> +> - visionA 端不再持有任何 MC service client secret(撤回 v2.0 規劃) +> - v1.x 在對話中外洩的舊 secret `RciRUyiCkbd60ikkZGkfQ2xV4r02VW3/j0ASKV/DD/E=`(舊 client `23605e14...` 的)仍作廢、不會被 visionA 重新引用 +> - v2.0 提供的 `4242ba63099d4f318dd3f143d27ef4c5` service client + 對應 secret 在 v0.4 不再被 visionA 使用;若 MC team / 其他產品線需要可獨立持有,與 visionA 無關 + **prod 環境(依 IT 配置):** prod MC client 是 public 還是 confidential 由 Innovedus IT 在註冊 OAuth client 時決定。visionA-backend 兩者都支援,env 設 / 不設 `VISIONA_OIDC_CLIENT_SECRET` 即可,**不需重 build**(同一份 binary)。 @@ -1526,23 +1538,34 @@ api-server 啟動時應 log 一行(**不可 log secret 本身**): 判斷依據:`OIDCConfig.ClientSecret == ""`。 這條 log 是排查「為什麼 token exchange 401」的第一步 — IdP 註冊的 client 類型必須與 visionA-backend 啟動時的 mode 對齊(兩端錯配會 401 unauthorized client)。 -#### 13.1.3 Phase 0.8b — Server-to-server 認證從 MC OIDC 解耦 +#### 13.1.3 Phase 0.8b v0.4 — Server-to-server 認證單線設計(visionA 端只剩 visionA → converter) -> Phase 0.6-0.8 階段保留的「Service Client」概念在 Phase 0.8b 全面廢棄。詳見 [ADR-015](./adr/adr-015-server-to-server-api-key.md) 與 `conversion.md` §3。 +> v1.x「Phase 0.8b 全面廢棄 Service Client 概念」→ v2.0 部分撤回(FAA 線復活)→ **v0.4 / ADR-016 再次撤回 v2.0**(FAA 線完全移除)。 > -> 摘要: +> 詳見 [ADR-016](./adr/adr-016-download-via-converter.md)、`conversion.md` §3、ADR-015 v2.1。 > -> - **不再透過 MC**:visionA → converter / FAA 不再走 `POST /oauth/token` 換 service token + JWKS 驗 + scope 驗 的鏈路 -> - **改用 pre-shared API key**:每個下游各自獨立的 64-hex API key(`VISIONA_CONVERTER_API_KEY` / `VISIONA_FAA_API_KEY`) -> - **header 格式不變**:仍是 `Authorization: Bearer `,只是 token 來源從「MC 簽的 JWT」變成「visionA env 內的 pre-shared secret」 -> - **converter / FAA 端 middleware 同步改寫**:constant-time compare env 字串,不再驗 JWKS / scope / tenant +> **v0.4 摘要**(visionA 端只剩一條 server-to-server 線): > -> **為什麼把這個段落放在 OIDC TDD**:原本 ADR-014 §5 把「service client / client_credentials grant」與「user login OIDC」放同一條 OIDC 整合線;Phase 0.8b 後這兩條線完全脫鉤: +> **visionA → converter(與 user login 解耦)**: +> - **不透過 MC**:不走 `POST /oauth/token` 換 service token + JWKS 驗 + scope 驗的鏈路 +> - **改用 pre-shared API key**:`VISIONA_CONVERTER_API_KEY`(64-hex) +> - **header 格式**:`Authorization: Bearer ` +> - **converter 端 middleware**:constant-time compare env 字串,不驗 JWKS / scope / tenant +> - **endpoints**:`POST /api/v1/jobs`(init)、`GET /api/v1/jobs/:id`(poll)、`POST /api/v1/jobs/:id/promote`(promote)、**`GET /api/v1/jobs/:id/result`(v0.4 新增;download)** > -> - user login:仍是 OIDC(PKCE / cookie session / JWKS) — 本文件 §1-§12 全部適用 -> - server-to-server:不再是 OIDC、不再屬於本文件範圍 — 看 `conversion.md` §3 與 ADR-015 +> **visionA → FAA / MC(v0.4 整段撤回)**: +> - v1.x 改 API key 已撤回(v2.0);v2.0 規劃要回到 MC service token + delegated download token 路徑 → **v0.4 整段再次撤回** +> - **原因(致命發現 2026-05-16)**:對 MC source 全 grep 驗證後確認 MC 沒有 `POST /file-access/download-tokens` endpoint、也沒有 FAA `IDelegatedDownloadTokenValidator` assume 的 introspection endpoint—— v2.0 規劃的路徑是 fictional、從未實際 e2e 跑通 +> - **v0.4 處理**:visionA 端不再有任何 visionA → MC / visionA → FAA server-to-server 路徑;download 改走 converter 中轉(converter 自己用 OAuth client_credentials + `files:upload.write` scope 推 FAA、後續 download 從 converter MinIO stream 回 visionA,converter → FAA 鏈路 Phase 1 已上線、與 visionA 無關) +> - **MC 不再是 visionA 的 server-to-server 依賴**;MC 仍是 visionA user login 的 OIDC IdP(公開 PKCE client、本文件 §1-§12),但兩條線完全獨立 > -> 此小節保留作為「OIDC 路徑 → API key 路徑」的指引,避免讀者讀到本文件 §13.1 看到舊 service client env 還以為要啟用。 +> **為什麼 v0.4 撤回 v2.0**:v2.0 規劃時 architect agent 沒實際讀 MC source、沿用 ADR-014 §2 的 assume;對 source 驗證後發現 ADR-014 §2 從 2026-05-02 寫定起就是 broken design(MC 沒有對應 endpoint),整條鏈從未 e2e 跑通。v0.4 改採 ADR-016(converter 加新 endpoint),visionA + converter 都是 jimchen 自己維護的 repo、可單方控制,不必動 MC / FAA / warrenchen。 +> +> - **user login**:仍是 OIDC(PKCE / cookie session / JWKS)— 本文件 §1-§12 全部適用 +> - **server-to-server visionA → converter**:不是 OIDC、不屬於本文件範圍 — 看 `conversion.md` §3.1 與 ADR-015 §1 +> - **server-to-server visionA → FAA**:**不存在**(v0.4 撤回 v2.0 規劃;不再使用本文件 §13.1 列出的 OIDC ServiceClient* / TenantID 三個 env) +> +> **本小節保留的用途(v0.4 修訂)**:說明 v1.x 廢棄 → v2.0 復活 → v0.4 再次廢棄的完整時序,並標清「OIDC ServiceClient* 三個 env 在 v0.4 後維持廢棄」,避免讀者誤啟用。 ### 13.2 visionA-frontend 新增 @@ -1845,3 +1868,5 @@ OF1 (與 OF2 平行) |------|------|------| | 2026-04-26 | 0.1 | Architect Agent 初稿(Phase 0.6 OIDC 接入 TDD 增補) | | 2026-05-11 | 0.2 | **Phase 0.8b** 對應 [ADR-015](./adr/adr-015-server-to-server-api-key.md):(1) Metadata 區補 Phase 0.8b 範圍說明(user login 不變、server-to-server 改 API key);(2) §13.1 環境變數表把 `VISIONA_OIDC_SERVICE_CLIENT_ID` / `_SECRET` 兩 row 標廢棄、新增 `VISIONA_CONVERTER_API_KEY` / `VISIONA_FAA_API_KEY` 兩 row;(3) §13.1.1 stage env 範例移除 service client 區、新增 API key 區;(4) 新增 §13.1.3 說明 server-to-server 與 user login OIDC 已脫鉤,引導讀者去 `conversion.md` §3 與 ADR-015。本文件其他章節(§1-§12、§14-§17)關於 user login 部分全部不變。 | +| 2026-05-16 | 0.3 | **對應 ADR-015 v2.0 範圍縮限**:撤回 v0.2 「server-to-server 全部改 API key、service client env 全部廢棄」決定。FAA 線回到 ADR-014 §2 原設計(MC service token + delegated download token),需要 MC OIDC service client;converter 線維持 v0.2 的 API key 路線。主要變更:(1) Metadata 區更新 v2.0 範圍說明 — 區分 converter 線(API key)與 FAA 線(仍 OAuth);(2) §13.1 環境變數表 — `VISIONA_OIDC_SERVICE_CLIENT_ID` / `VISIONA_OIDC_SERVICE_CLIENT_SECRET` / `VISIONA_OIDC_TENANT_ID` 三 row 從廢棄改回「v2.0 重新啟用」、補 stage 對應值(`4242ba63...` / `732270c0-...`),`VISIONA_FAA_API_KEY` row 標撤回,`VISIONA_FAA_BASE_URL` row 補 stage 值;(3) §13.1.1 stage env 範例加回 service client + tenant_id(用 placeholder)、保留 converter API key、撤回 `VISIONA_FAA_API_KEY`、補 secret 處理規則注意事項;(4) §13.1.3 改為「v2.0 雙線設計」說明 — converter 線解耦 / FAA 線未解耦、引導讀者看 `conversion.md` §3.1 vs §3.2 / ADR-015 v2.0 §1 vs §2 / ADR-014 §2。本文件 user login 章節(§1-§12、§14-§17)全部不變。 | +| 2026-05-16 | 0.4 | **對應 [ADR-016](./adr/adr-016-download-via-converter.md)**:再次撤回 v0.3「server-to-server FAA 線回到 MC service token + delegated download token」全部規劃。原因:對 MC source 全 grep 驗證後確認 MC **沒有** `POST /file-access/download-tokens` endpoint、也沒有 FAA `IDelegatedDownloadTokenValidator` assume 的 introspection endpoint—— ADR-014 §2 / ADR-015 v2.0 §2 的 delegated token 鏈從 2026-05-02 起即為 broken design、從未 e2e 跑通。**v0.4 新設計**:visionA download 改走 converter 新增的 `GET /api/v1/jobs/{id}/result` + visionA stream 中轉;visionA 端 server-to-server 鏈路收斂為單條 visionA → converter(API key)。主要變更:(1) Metadata 區更新 v0.4 範圍說明 — visionA → FAA / MC 整段撤回;(2) 上位文件改為 ADR-015 v2.1 + ADR-016;(3) §13.1 環境變數表 — `VISIONA_OIDC_SERVICE_CLIENT_ID` / `_SECRET` / `VISIONA_OIDC_TENANT_ID` 三 row 從 v0.3「重新啟用」改回「再次廢棄」、`VISIONA_FAA_BASE_URL` row 從「使用中」改為「再次廢棄」、`VISIONA_CONVERTER_API_KEY` row 補「v0.4 起也用於 download 路徑」說明;(4) §13.1.1 stage env 範例移除 service client + tenant_id + FAA URL(v0.3 加回的全部撤回)、加註「visionA 端不再持有 MC service client secret」、secret 處理規則整段改寫;(5) §13.1.3 改寫為「v0.4 單線設計」說明 — visionA → FAA / MC 全部撤回,加完整時序(v1.x 廢棄 → v2.0 復活 → v0.4 再次廢棄)、引導讀者看 conversion.md §3 / ADR-016 / ADR-015 v2.1;(6) 加完整撤回理由與「為什麼 v0.4 撤回 v2.0」說明。本文件 user login 章節(§1-§12、§14-§17)全部不變、Phase 0.6 OIDC 接 MC 的設計完全不受影響。 |