# ADR-015:visionA → converter 採 pre-shared API key 認證(取代 OAuth client_credentials)— 範圍縮限至 visionA → converter ## 狀態 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 模式」中 **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) ### Phase 0.8 OAuth client_credentials 鏈路失敗事件(2026-05-09 ~ 11) ADR-014 原本設計 visionA backend → converter / FAA 的 server-to-server 認證走 Member Center(MC)的 OAuth `client_credentials` grant: 1. visionA backend 啟動時讀 `VISIONA_OIDC_SERVICE_CLIENT_ID` / `SECRET` 2. 第一次需要時打 MC `POST /oauth/token` 換 service token(scope `converter:job.write/read`、`files:download.read/delegate`) 3. 帶 `Authorization: Bearer ` 打 converter / FAA 4. converter / FAA 端 middleware 驗 JWKS 簽章 + 驗 scope + 驗 tenant Phase 0.8 stage 部署實際跑到才發現**整條鏈路有 4 個串行 blocker**: | # | Blocker | 影響 | |---|---------|------| | 1 | MC stage 沒註冊 `converter:job.read/write` 兩個 scope(progress.md 5/2 `assume 都有` 證實是錯的)| `POST /oauth/token` 直接 400 `invalid_scope` | | 2 | stage 上 converter image 是 5 週前舊版,沒 OAuth middleware、沒 `/api/v1/jobs` endpoint | 即使 MC 補 scope,converter 仍無法接受 service token | | 3 | converter 缺 `MEMBER_CENTER_*` env 設定 | converter 無法 init OIDC middleware | | 4 | FAA stage 也可能要 OAuth 整合(warrenchen 維護)| 不確定狀態,需跨人協調 | 要修齊這 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** 的場景**完全不符合上面任一個**: - **1:1 trust 關係**(visionA 是 converter 唯一的 server-to-server caller,沒有第三方) - **使用者同時維護 visionA + converter**(jimchen),可單方拍板改 middleware - **全部 internal trust**(不是給外部 dev 用,沒有 untrusted client) - **無需 scope 細分**(converter 只關心「是否為 visionA」,單一布林) 而成本: - **複雜度**:MC 端要 onboard scope、issuer JWKS 要可達、token cache 要寫對、TTL 邊界要處理、4 個 5xx / 4xx 失敗模式要 retry & graceful degrade - **鏈路長度**:visionA → MC → cache → converter,任一節點掛掉都不能轉檔 - **可觀測性負擔**:要追的 metrics 包含 token cache hit rate、MC 失敗率、scope 對齊狀態 ### 為什麼 v2.0 撤回 FAA 改 API key(FAA 線適用相反邏輯) 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) ### 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(v1.0 採用 / v2.0 維持) ``` visionA backend 啟動時 ↓ 讀 env VISIONA_CONVERTER_API_KEY ↓ [轉檔請求進來] ↓ 打 converter: Authorization: Bearer ``` converter 端 middleware: ``` 讀 env CONVERTER_API_KEY ↓ [收到請求] ↓ parse Authorization header → 取 token ↓ subtle.ConstantTimeCompare(token, CONVERTER_API_KEY) ↓ match → 放行;mismatch → 401 ``` ### 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 啟動時 ↓ 讀 OIDCConfig.ServiceClientID / ServiceClientSecret + ConversionConfig.TenantID ↓ [需要打 FAA,例如「加到模型庫」server-to-server pull、或「下載」proxy] ↓ 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 ``` **為什麼 v2.0 設計「download 線必須是 delegated token」而非「service token」**: FAA 端的 dual-auth 設計(已實作於 `/Users/jimchen/file_access_agent/src/FileAccessAgent.Api/Program.cs`)強制如此: | 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 | **v1.x 中的 FAA key row(`VISIONA_FAA_API_KEY` / `FAA_API_KEY`)撤回**——FAA 改回 MC service token 路徑、不需要 pre-shared API key。 理由(converter 維持):每條 trust boundary 各自獨立。converter 線 1:1 trust,雙方都由 jimchen 維護,rotate 對齊成本可控。 ### 3.5 Reference Middleware Implementation(v2.0 縮限至 converter 端) 本節提供 converter 端 middleware 的可直接照抄 reference snippet。 #### 3.5.1 converter 端(Go — net/http 標準 middleware pattern) 採 `net/http` 標準 middleware pattern,可直接套用 `chi` / `gorilla/mux` / 原生 `http.ServeMux`。`subtle.ConstantTimeCompare` 是 Go 標準庫提供的 constant-time 比較函式(避免 timing attack)。 ```go // internal/middleware/apikey.go package middleware import ( "crypto/subtle" "errors" "log/slog" "net/http" "strings" ) // ErrAPIKeyNotConfigured 啟動時 server 端 API key 未設定 — 應在 main() init 時 fail-fast、 // 不要等到第一個 request 才發現 var ErrAPIKeyNotConfigured = errors.New("CONVERTER_API_KEY env not set") // NewAPIKeyAuth 回傳一個驗證 Authorization: Bearer 的 middleware。 // 若 expectedKey 為空字串,會直接 panic(啟動 fail-fast)— 避免「未設定 = 全部放行」的災難。 func NewAPIKeyAuth(expectedKey string, logger *slog.Logger) func(http.Handler) http.Handler { if expectedKey == "" { // 啟動時偵測 — 不允許 server 在沒有 key 的狀態下啟動 panic(ErrAPIKeyNotConfigured) } expectedBytes := []byte(expectedKey) return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { authHeader := r.Header.Get("Authorization") // missing Authorization header if authHeader == "" { logger.Warn("api key auth failed", "reason", "missing_authorization_header", "path", r.URL.Path, "remote", r.RemoteAddr) writeUnauthorized(w) return } // 不是 Bearer prefix(必須有 "Bearer " 前綴 + 空格) const prefix = "Bearer " if !strings.HasPrefix(authHeader, prefix) { logger.Warn("api key auth failed", "reason", "missing_bearer_prefix", "path", r.URL.Path, "remote", r.RemoteAddr) writeUnauthorized(w) return } token := strings.TrimSpace(authHeader[len(prefix):]) // token 為空("Bearer " 後面什麼都沒有) if token == "" { logger.Warn("api key auth failed", "reason", "empty_token", "path", r.URL.Path, "remote", r.RemoteAddr) writeUnauthorized(w) return } // constant-time compare — 即使長度不同 ConstantTimeCompare 也會回 0,但為了 // 提早 short-circuit、先檢查長度可避免 hash 不必要的工作(長度本身不是 secret) if subtle.ConstantTimeCompare([]byte(token), expectedBytes) != 1 { logger.Warn("api key auth failed", "reason", "token_mismatch", "path", r.URL.Path, "remote", r.RemoteAddr) // 注意:log 絕對不印 token 本身 writeUnauthorized(w) return } // 通過 — 放行到 next handler next.ServeHTTP(w, r) }) } } // writeUnauthorized 統一回 401 — 不洩漏「key 對 / 不對」/ 「missing / mismatch」的差異 func writeUnauthorized(w http.ResponseWriter) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) } ``` main 端使用範例: ```go // cmd/converter/main.go(節錄) expectedKey := os.Getenv("CONVERTER_API_KEY") authMiddleware := middleware.NewAPIKeyAuth(expectedKey, logger) // 套用到所有 /api/v1/* routes mux.Handle("/api/v1/", authMiddleware(apiHandler)) ``` #### 3.5.2 ~~FAA 端(C# ASP.NET Core middleware)~~(v2.0 撤回 — 整段刪除) > **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 協調) #### 3.5.3 部署檢查清單(v2.0 縮限至 converter 端) 不分 client 端 / server 端,部署前 converter 兩側必須逐項確認: | # | 檢查項 | 為什麼 | |---|--------|--------| | 1 | env 已設定且非空(啟動 fail-fast)| 避免「未設定 = 全部放行」災難;server 應在啟動時 panic / throw、不要等到第一個 request 才發現 | | 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) | | 6 | 每環境(dev / stage / prod)獨立 key | 嚴格分環境產 key(`openssl rand -hex 32` 各 64 字元 hex),不重用 | | 7 | key 不進 git | `.gitignore` 嚴格 ignore `.env*`;CI / CD secret 從 Secrets Manager / Vault 注入 | | 8 | 健康檢查 endpoint 是否要 bypass auth | 通常 `/healthz` / `/readyz` 應 bypass(讓 LB / k8s 可探測),但業務 endpoint 全部要 auth | > **FAA 端不需要本清單**——v2.0 起 FAA 端使用既有 OAuth + delegated token 機制,無新增 API key middleware。FAA 端的 OAuth / delegated token 部署檢查請參考 ADR-014 §2 與 `conversion.md` §3.2。 ### 4. 不再有 scope 概念(converter 線適用;FAA 線 v2.0 撤回) **converter 線**:OAuth `client_credentials` 設計中兩個 converter scope(`converter:job.write/read`)取消。 - 單一 API key 就是「visionA 有權打 converter」的完整證明 - 不再有「同一個 client 但拿不同 scope」的細粒度區分(在 1:1 trust 中本來就沒意義) - converter 端 middleware 也不需要驗 scope **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` 驗。 ### 5. tenant 概念 **converter 線**:visionA → converter 不再帶 tenant_id。converter 端的 user_id 從 multipart body `user_id` field 拿(仍由 visionA 從 OIDC sub 灌入,這條 trust boundary 不變)。converter 端不需要 tenant 概念。 **FAA 線(v2.0 修正 — 從 v1.x「不再有 tenant」改為「FAA 線需要 tenant」)**: - 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 撤回廢棄) ### 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 // 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 改判定: func (c ConversionConfig) Enabled() bool { return c.ConverterBaseURL != "" && c.FAABaseURL != "" && c.ConverterAPIKey != "" && // FAA 線判定 OIDCConfig 有 ServiceClientID / Secret + ConversionConfig.TenantID // — 由 main.go 啟動時組合判斷,這裡只判 conversion 自己的欄位 c.TenantID != "" } ``` ### 7. Delegated download token 路徑的處理(v2.0 撤回 v1.x 的撤回,回到 ADR-014 §2 設計) ADR-014 §2 原設計 download 流程: ``` browser → visionA /download → MC issue delegated token → 302 → browser → FAA?access_token=... ``` **v1.x 的選項 A(短期 server-side proxy with API key)撤回**:v1.0 / v1.1 在本節原本提案「visionA backend 直接用 `Authorization: Bearer ` 拉 FAA、stream 回 browser」整段全部撤回。 **v2.0 採用的設計(保留 server-side stream proxy 不退回 302、但 token 來源改回 delegated download token)**: ``` browser → visionA /download → visionA backend ↓ ownership 檢查 ↓ ensurePromoted(拿 target_object_key) ↓ 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 ``` **為什麼保留 server-side stream proxy(不退回 ADR-014 §2 的 302 redirect)**: - 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」的路徑: ``` browser → visionA /download → visionA 用 HMAC_KEY 簽 short-TTL token ↓ 302 → browser ↓ browser → FAA?access_token= ↓ FAA middleware:JWT (s2s) OR delegated (current) OR HMAC (browser direct) 三選一 ``` 此升級路徑與本 ADR v2.0 決策無衝突,記入 Phase 1 follow-up(同 v1.x 規劃)。 ### 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(converter)/ service token(FAA)證明的是「caller 是 visionA」,user_id 的真實性由 visionA 內部的 OIDC 機制保證 — 兩條獨立鏈 ### 9. 部署層的 env 注入(v2.1 修訂) Phase 0.8b v2.1 採用(visionA 端 server-to-server secret 只剩 converter API key 一把): | 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 產生方式: - 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 原方案 — converter / FAA 兩條都走) | 評估 | 內容 | |------|------| | 優點 | 標準化、跨團隊可重用、短 TTL token、scope 細粒度可控 | | 缺點 | 需要 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) | 評估 | 內容 | |------|------| | 優點 | 不需傳遞 secret in plaintext(憑證綁定)、cert rotation 機制成熟 | | 缺點 | converter / FAA 都要支援 mTLS、需要 CA 管理、ingress(nginx / Caddy / ALB)也要配合 client cert termination、stage 環境部署成本高 | | 排除原因 | 對 1:1 trust 過度設計;公司 stage 環境 ingress(host nginx)未對外開放 mTLS 配置;維運成本不成比例 | ### 方案 C:API key + IP allowlist 雙層防護 | 評估 | 內容 | |------|------| | 優點 | 即使 API key 洩漏,攻擊者也需從特定 IP 才能用 | | 缺點 | visionA 上 AWS 後 IP 不固定(NAT gateway / ALB 對外多 IP);converter / FAA 在公司內網 IP allowlist 維護成本高;對「不是內網」的 prod 場景幾乎沒用 | | 排除原因 | Phase 0.8 stage 仍在公司內網(visionA stage 在 192.168.0.x),加 IP allowlist 在 stage 可行但對 prod 沒有延展性;不採用 | ### 方案 D:共用一把 API key(不分 converter / FAA) | 評估 | 內容 | |------|------| | 優點 | env 少一個、部署設定簡單 | | 缺點 | 一處洩漏兩處連坐;converter rotate 必須同步 FAA;違反「每條 trust boundary 各自獨立」原則 | | 排除原因 | 在 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) ### 正面影響 - **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...` 取代 ### 負面影響(接受的取捨) - **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 過期) ### 風險 | 風險 | 緩解 | |------|------| | 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] 與使用者確認: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;範圍縮限至 converter API key) - [ ] 已洩漏的 stage service client secret `RciRUyi...` 自動作廢(不需 MC rotate;v2.0 由新 client `4242ba63...` 取代) ## 配套產出(給後續 Phase) ### Phase 0.8b 範圍內(v2.0 修訂) - 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 - [ ] 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)(**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 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(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 都維持不變)**。 |