docs(autoflow): ADR-016 — visionA download 改走 converter GetResult,撤回 FAA delegated token 鏈

致命發現(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) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-05-16 12:30:46 +08:00
parent 86b7175649
commit dab13ed984
6 changed files with 1384 additions and 607 deletions

View File

@ -1,7 +1,13 @@
# ADR-014visionA 端轉檔功能架構Phase 0.8 # ADR-014visionA 端轉檔功能架構Phase 0.8
## 狀態 ## 狀態
Accepted — 2026-04-30 Accepted — 2026-04-30 / **§2 download flow 部分 supersede — 2026-05-16ADR-016**
> **2026-05-16 更新**§2「Download — FAA delegated tokenbrowser 直連 / 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 仍有效的段落**§1upload streaming proxy、§3半自動分流的「加到模型庫」原則但 server-side pull 的 FAA 部分改走 converter、§4模組劃分、§5service token cache 僅 converter 部分已被 ADR-015 §1 supersedeFAA 部分被 ADR-016 supersede、§6user_id trust boundary**核心原則完全不變**、§7FAA / MC 相關 row 被 ADR-016 supersedeconverter row 維持、§8active job 衝突處理不變)。
## 上位 / 同層 ADR ## 上位 / 同層 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-onlyservice client 仍為 confidential - 沿用:[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-onlyservice client 仍為 confidential
@ -53,6 +59,20 @@ Browser ──multipart──► visionA backend ──multipart streaming──
### 2. Download — 多次性 → FAA delegated tokenserver-side 302 redirect → browser 直連 FAA ### 2. Download — 多次性 → FAA delegated tokenserver-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 tokenvisionA 即使能拿 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 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.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-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。 |

View File

@ -1,12 +1,22 @@
# ADR-015visionA → converter / FAA 採 pre-shared API key 認證(取代 OAuth client_credentials # ADR-015visionA → 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 §2MC 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 ## 上位 / 同層 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、模組劃分仍有效。 - **部分 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 rowconverter 線不再經 MC
- **不影響**[ADR-013](./adr-013-public-client.md) — user login 的 OIDC public PKCE client 仍照舊。本 ADR 只動「server-to-server」這條線「user login」這條線完全不變。 - **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 - 沿用:[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) ## 背景 (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 整合」明顯過度設計。 要修齊這 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 機制適合的場景: OAuth `client_credentials` + JWKS + scope 機制適合的場景:
- **多個 client 對同一個 resource server**(如 SaaS 平台對外開放 API、第三方 dev 串接),需要區分不同 client 權限、需要短 TTL token 限制 blast radius、需要 scope 細粒度 - **多個 client 對同一個 resource server**(如 SaaS 平台對外開放 API、第三方 dev 串接),需要區分不同 client 權限、需要短 TTL token 限制 blast radius、需要 scope 細粒度
- **不同團隊 / 不同信任邊界**client 端的 secret 不能由 server 端管理 - **不同團隊 / 不同信任邊界**client 端的 secret 不能由 server 端管理
visionA → converter / FAA 的場景**完全不符合上面任一個** **visionA → converter** 的場景**完全不符合上面任一個**
- **1:1 trust 關係**visionA 是 converter / FAA 唯一的 server-to-server caller沒有第三方 - **1:1 trust 關係**visionA 是 converter 唯一的 server-to-server caller沒有第三方
- **使用者同時維護 visionA + converter**jimchenFAA 由 warrenchen 維護(同公司、可協調) - **使用者同時維護 visionA + converter**jimchen可單方拍板改 middleware
- **全部 internal trust**(不是給外部 dev 用,沒有 untrusted client - **全部 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 - **複雜度**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 對齊狀態 - **可觀測性負擔**:要追的 metrics 包含 token cache hit rate、MC 失敗率、scope 對齊狀態
### 已洩漏的 stage service client secret觀察事實 ### 為什麼 v2.0 撤回 FAA 改 API keyFAA 線適用相反邏輯
`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:NFAA 之後可能服務多個 visionA-like 產品線),維持 OAuth 框架對 FAA 端架構演進更友善
→ v2.0 縮限至「**只動 converter 線FAA 線回到 ADR-014 §2 原設計**」是更精確的責任邊界劃分。
### 已洩漏的 stage service client secretv1.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) ## 決策 (Decision)
**pre-shared API key + `Authorization: Bearer <api-key>` header** 取代 OAuth client_credentials 服務間認證。 ### v2.0 範圍:**pre-shared API key + `Authorization: Bearer <api-key>` header** 取代 OAuth client_credentials**僅適用於 visionA → converter**。visionA → FAA 維持 ADR-014 §2 原設計MC service token + delegated download token
### 1. visionA → converter ### 1. visionA → converterv1.0 採用 / v2.0 維持)
``` ```
visionA backend 啟動時 visionA backend 啟動時
@ -86,49 +112,103 @@ subtle.ConstantTimeCompare(token, CONVERTER_API_KEY)
match → 放行mismatch → 401 match → 放行mismatch → 401
``` ```
### 2. visionA → FAA ### 2. ⚠️ visionA → FAAv2.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` endpointvisionA 無法跟 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 啟動時 visionA backend 啟動時
讀 env VISIONA_FAA_API_KEY OIDCConfig.ServiceClientID / ServiceClientSecret + ConversionConfig.TenantID
[加到模型庫流程要 pull NEF] [需要打 FAA例如「加到模型庫」server-to-server pull、或「下載」proxy]
打 FAA visionA → MC POST {issuer}/oauth/token
Authorization: Bearer <VISIONA_FAA_API_KEY> grant_type=client_credentials
client_id=<ServiceClientID>
client_secret=<ServiceClientSecret>
scope=files:upload.write files:metadata.read files:delete files:download.delegate
MC 回 service access_tokencache 至 exp - 15s
A 路線FAA 寫 / metadata / delete / s2s download
visionA 帶 Authorization: Bearer <service-token> 打 FAA
FAA 端 .RequireAuthorization() + EnsureJwtScopeAndTenant 驗
├─ JWT 簽章FAA `AddJwtBearer` Authority = MC issuer自動 JWKS
├─ AudienceFAA `Auth:Audience`
├─ scope claimPUT 要 files:upload.writeGET metadata / HEAD 要 files:metadata.readDELETE 要 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 <delegated-token> 打 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」**
``` FAA 端的 dual-auth 設計(已實作於 `/Users/jimchen/file_access_agent/src/FileAccessAgent.Api/Program.cs`)強制如此:
讀 env FAA_API_KEY
[收到請求]
parse Authorization header → 取 token
constant-time compareC# `CryptographicOperations.FixedTimeEquals` 或等效)
match → 放行mismatch → 401
```
### 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 tokenscope `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. 單一下游converterv1.x「每個下游各自獨立的 key」表格 v2.0 縮限)
**v1.x 的 key 表格** 縮限至 converter 一條:
| Key | 持有者 | 用途 | | Key | 持有者 | 用途 |
|-----|--------|------| |-----|--------|------|
| `VISIONA_CONVERTER_API_KEY`visionA 端) / `CONVERTER_API_KEY`converter 端) | jimchen | visionA → converter | | `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 Implementationv2.0 縮限至 converter 端)
本節提供 converter 端 middleware 的可直接照抄 reference snippet。
#### 3.5.1 converter 端Go — net/http 標準 middleware pattern #### 3.5.1 converter 端Go — net/http 標準 middleware pattern
@ -232,208 +312,24 @@ authMiddleware := middleware.NewAPIKeyAuth(expectedKey, logger)
mux.Handle("/api/v1/", authMiddleware(apiHandler)) 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 tokenFAA 端**不需要新增任何 API key middleware**
>
> - 既有 `AddJwtBearer` + `RequireAuthorization()` + `EnsureJwtScopeAndTenant` 已涵蓋 PUT / metadata / HEAD / DELETE 4 個 endpoint
> - 既有 `IDelegatedDownloadTokenValidator` 已涵蓋 GET download endpoint
> - **FAA 端零變更**——這是 v2.0 撤回的核心收益(不必動公司共用 FAA repo、不必跟 warrenchen 協調)
**寫法 AClassic Middleware Class推薦既有 ASP.NET Core 專案)** #### 3.5.3 部署檢查清單v2.0 縮限至 converter 端)
```csharp 不分 client 端 / server 端,部署前 converter 兩側必須逐項確認:
// 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<ApiKeyAuthMiddleware> _logger;
private readonly byte[] _expectedKeyBytes;
private const string BearerPrefix = "Bearer ";
public ApiKeyAuthMiddleware(
RequestDelegate next,
ILogger<ApiKeyAuthMiddleware> 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<ApiKeyAuthMiddleware>(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();
```
**寫法 BMinimal 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<ILoggerFactory>()
.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#,部署前必須逐項確認:
| # | 檢查項 | 為什麼 | | # | 檢查項 | 為什麼 |
|---|--------|--------| |---|--------|--------|
| 1 | env 已設定且非空(啟動 fail-fast| 避免「未設定 = 全部放行」災難server 應在啟動時 panic / throw、不要等到第一個 request 才發現 | | 1 | env 已設定且非空(啟動 fail-fast| 避免「未設定 = 全部放行」災難server 應在啟動時 panic / throw、不要等到第一個 request 才發現 |
| 2 | constant-time compareGo `subtle.ConstantTimeCompare` / C# `CryptographicOperations.FixedTimeEquals`| 避免 timing attack 反推 key | | 2 | constant-time compareGo `subtle.ConstantTimeCompare`| 避免 timing attack 反推 key |
| 3 | 401 response body 統一不洩漏「key 對 / 不對」/ 「missing / mismatch」差異| 對外只回 `{"error":"unauthorized"}`,差異只記在 server 端 log | | 3 | 401 response body 統一不洩漏「key 對 / 不對」/ 「missing / mismatch」差異| 對外只回 `{"error":"unauthorized"}`,差異只記在 server 端 log |
| 4 | log 絕對不印 token 本身 | 即使是失敗的 token 也不印(攻擊者可能用半正確的 token 試探);只印 reason / path / remote | | 4 | log 絕對不印 token 本身 | 即使是失敗的 token 也不印(攻擊者可能用半正確的 token 試探);只印 reason / path / remote |
| 5 | Bearer prefix 嚴格驗證(缺 prefix 也 401| 不允許 `Authorization: <token>` 這種格式(即使內容對也 reject、強制 client 用標準 Bearer scheme | | 5 | Bearer prefix 嚴格驗證(缺 prefix 也 401| 不允許 `Authorization: <token>` 這種格式(即使內容對也 reject、強制 client 用標準 Bearer scheme |
@ -441,43 +337,58 @@ app.Run();
| 7 | key 不進 git | `.gitignore` 嚴格 ignore `.env*`CI / CD secret 從 Secrets Manager / Vault 注入 | | 7 | key 不進 git | `.gitignore` 嚴格 ignore `.env*`CI / CD secret 從 Secrets Manager / Vault 注入 |
| 8 | 健康檢查 endpoint 是否要 bypass auth | 通常 `/healthz` / `/readyz` 應 bypass讓 LB / k8s 可探測),但業務 endpoint 全部要 auth | | 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 中本來就沒意義) - 不再有「同一個 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 不變) **converter 線**visionA → converter 不再帶 tenant_id。converter 端的 user_id 從 multipart body `user_id` field 拿(仍由 visionA 從 OIDC sub 灌入,這條 trust boundary 不變。converter 端不需要 tenant 概念。
- FAA 端也不需要 tenant 概念pull NEF 用 object_key 定位)
- `VISIONA_OIDC_TENANT_ID` env 在 conversion 場景**廢棄**(如果其他場景還有用就保留,目前未發現其他依賴)
### 6. visionA backend 移除的程式碼 **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
| `internal/conversion/mc_token_client.go`(整個 package | **整個檔案刪除**~440 行 — token cache + delegated download token + double-checked locking| - → visionA 的 service token / delegated download token 必須含正確的 `tenant_id` claim
| `internal/conversion/converter_client.go` 內呼叫 `MCTokenClient.ServiceToken()` 的地方 | 改成讀 `cfg.Conversion.ConverterAPIKey` 直接 set header | - 來源MC service client `4242ba63...` 註冊時對應的 tenantstage 為 `732270c0-449c-489c-bfad-321e9bf89b3d`
| `internal/conversion/faa_client.go` 內呼叫 `MCTokenClient.ServiceToken()` 的地方 | 改成讀 `cfg.Conversion.FAAAPIKey` 直接 set header | - → `VISIONA_OIDC_TENANT_ID` env **重新啟用**v1.x 標廢棄v2.0 撤回廢棄)
| `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 模組不再依賴;如其他模組未使用即可移除 |
新增的 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 <service-token>`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 ```go
// internal/config/config.go // internal/config/config.go
type ConversionConfig struct { type ConversionConfig struct {
ConverterBaseURL string // 既有 ConverterBaseURL string // 既有
FAABaseURL string // 既有 FAABaseURL string // 既有
ConverterAPIKey string // 新增 — env VISIONA_CONVERTER_API_KEY ConverterAPIKey string // v1.0 新增 — env VISIONA_CONVERTER_API_KEYv2.0 維持)
FAAAPIKey string // 新增 — env VISIONA_FAA_API_KEY // FAAAPIKey string // v1.0 加的v2.0 撤回(不再需要)
// TenantID 廢棄 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 改判定: // Enabled 改判定:
@ -485,39 +396,59 @@ func (c ConversionConfig) Enabled() bool {
return c.ConverterBaseURL != "" && return c.ConverterBaseURL != "" &&
c.FAABaseURL != "" && c.FAABaseURL != "" &&
c.ConverterAPIKey != "" && 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=... 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_API_KEY>` 拉 FAA、stream 回 browser」整段全部撤回。
**選項 APhase 0.8b 採用)— 短期:保持 server-side download proxy** **v2.0 採用的設計(保留 server-side stream proxy 不退回 302、但 token 來源改回 delegated download token**
visionA backend 直接用 `Authorization: Bearer <FAA_API_KEY>` 拉 FAAstream 回 browser
``` ```
browser → visionA /download → visionA backend browser → visionA /download → visionA backend
Bearer <FAA_API_KEY> 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 <delegated-token>
FAA IDelegatedDownloadTokenValidator.ValidateAsync(...)
FAA stream NEF binary
visionA backend io.CopyN → browser
``` ```
- 跨 internet 流量 N 倍是接受的取捨Phase 0.8 MVP user 量小、單檔 NEF 通常 < 50MB **為什麼保留 server-side stream proxy不退回 ADR-014 §2 的 302 redirect**
- token 結構性不過 frontend JS仍維持 ADR-014 §2 表格的「server-side 比 frontend 拿 token 更安全」的所有優點)
- visionA backend 變成 streaming bottleneckPhase 1 量大時再評估
**選項 BPhase 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 `<a href download>` 流程已驗證
- 退回 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 tokenline 184-254 沒掛 `RequireAuthorization()`、改用 `IDelegatedDownloadTokenValidator`FAA 端不接受其他 token type
- v1.x 若要用 visionA API key需要 warrenchen 在 FAA 端為 download endpoint 額外實作「API key middleware」並改寫 dual-auth 路由——成本遠超 v2.0 撤回的收益
**選項 BPhase 1+ HMAC token 升級路徑)保留為 follow-up**
如果 Phase 1 流量壓力大要回 302 redirect 模式visionA 可以自己簽 short-TTL HMAC token不需要 MC 介入FAA 端 middleware 多加一條「驗 visionA HMAC token」的路徑 如果 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=<visionA-signed-hmac> browser → FAA?access_token=<visionA-signed-hmac>
FAA middlewareAPI key (server-to-server) OR HMAC (browser direct) 二選一 FAA middlewareJWT (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 的點」邏輯不變: ADR-014 §6「visionA backend 是唯一灌 user_id 的點」邏輯不變:
- user_id 仍從 OIDC cookie session 拿OIDC sub - user_id 仍從 OIDC cookie session 拿OIDC sub
- 仍透過 multipart streaming 注入 converter request 的 `user_id` fieldconverter 端視 visionA 為 trusted caller - 仍透過 multipart streaming 注入 converter request 的 `user_id` fieldconverter 端視 visionA 為 trusted caller
- API key 證明的是「caller 是 visionA」user_id 的真實性由 visionA 內部的 OIDC 機制保證 — 兩條獨立鏈 - API keyconverter/ service tokenFAA證明的是「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 | | Env | Stage | Production | v2.1 變更 |
|-----|-------|-----------| |-----|-------|-----------|----------|
| `VISIONA_CONVERTER_API_KEY`visionA 端) | `.env.stage`jimchen 持有,不進 git | AWS Secrets Manager / Vault | | `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 | 同上 | | `CONVERTER_API_KEY`converter 端) | `.env`jimchen 持有,不進 git | 同上 | 維持 |
| `VISIONA_FAA_API_KEY`visionA 端) | `.env.stage` | 同上 | | `VISIONA_CONVERTER_BASE_URL` | `.env.stage` | Secrets Manager | 維持(既有) |
| `FAA_API_KEY`FAA 端) | warrenchen 設置 | 同上 | | ~~`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 → converterAPI keydownload 也走同一條converter `GET /api/v1/jobs/{id}/result`,詳見 ADR-016
## 考慮過的替代方案 (Alternatives Considered) ## 考慮過的替代方案 (Alternatives Considered)
### 方案 A維持 OAuth client_credentialsADR-014 原方案) ### 方案 A維持 OAuth client_credentialsADR-014 原方案 — converter / FAA 兩條都走
| 評估 | 內容 | | 評估 | 內容 |
|------|------| |------|------|
| 優點 | 標準化、跨團隊可重用、短 TTL token、scope 細粒度可控 | | 優點 | 標準化、跨團隊可重用、短 TTL token、scope 細粒度可控 |
| 缺點 | 需要 MC team 配合 onboard scope、converter / FAA 都要重寫 middleware、JWKS 取得失敗時 graceful degrade 複雜、stage e2e 鏈路 4 個 blocker 全要修齊 | | 缺點 | 需要 MC team 配合 onboard converter scope、converter / FAA 都要重寫 middleware、JWKS 取得失敗時 graceful degrade 複雜、stage e2e 鏈路 4 個 blocker 全要修齊 |
| 排除原因 | 對 1:1 internal trust 場景過度設計Phase 0.8 stage 部署實際遇到的 4 個 blocker 證明這條路 ROI 不好 | | 排除原因 | 對 1:1 internal trust 場景converter過度設計Phase 0.8 stage 部署實際遇到的 4 個 blocker 證明這條路 ROI 不好 |
### 方案 BmTLSmutual TLS ### 方案 BmTLSmutual TLS
@ -586,72 +526,103 @@ key 產生方式:`openssl rand -hex 32`64 字元 hex
|------|------| |------|------|
| 優點 | env 少一個、部署設定簡單 | | 優點 | env 少一個、部署設定簡單 |
| 缺點 | 一處洩漏兩處連坐converter rotate 必須同步 FAA違反「每條 trust boundary 各自獨立」原則 | | 缺點 | 一處洩漏兩處連坐converter rotate 必須同步 FAA違反「每條 trust boundary 各自獨立」原則 |
| 排除原因 | 每個下游各自獨立的 key 是低成本(只多一個 env但隔離效益高採方案決策的反方案 | | 排除原因 | 在 v1.x 兩條都 API key 的設計中作為反方案被排除v2.0 因 FAA 撤回 API key、議題自然不存在 |
### 方案 Ev2.0 採用visionA → converter API key、visionA → FAA 仍走 MC service token + delegated download token
| 評估 | 內容 |
|------|------|
| 優點 | (1) 不必動 FAA repowarrenchen 維護成本歸零);(2) 不必動 MC 端 scope onboarding4242ba63 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) 不希望動 MC5/9 撞 scope 沒註冊的痛);(3) 但 5/16 提供的 service client `4242ba63...` 證明 MC 端針對 FAA 的 4 個 scope 已備妥 — v1.0 拒絕走 OAuth 的「MC scope 沒備妥」前提在 FAA 線**不成立**|
| 採用 | **v2.0 採用** |
## 後果 (Consequences) ## 後果 (Consequences)
### 正面影響 ### 正面影響
- **實作大幅簡化**visionA backend 砍 `internal/conversion/mc_token_client.go`~440 行 — 含 token cache + delegated download token + double-checked locking + retry policy - **converter 線實作大幅簡化**v1.0 收益保留visionA backend 對 converter 的呼叫不查 cache、不打 MC、不重簽
- **不依賴 MC scope onboarding**MC team 完全不需介入stage e2e blocker 從 4 個降到 0 - **converter / FAA stage e2e blocker 收斂**converter 線 0 個 blockerFAA 線靠使用者已備妥的 service client `4242ba63...` 驗證後即可上線(待 verify
- **converter / FAA middleware 極簡**:兩端各自只需「比對單一字串 + constant-time compare」無需驗 JWKS / scope / tenantconverter Phase 1 之前舊 image 可直接補 middleware 上線(不需 redeploy 大改) - **不必動 FAA repo、不必動 warrenchen**v2.0 新增收益v1.x 規劃要 warrenchen 配合改 FAA middleware 的工作完全取消
- **失敗模式收斂**原本「MC 5xx / MC 4xx / token cache miss / scope mismatch」四個失敗類型收斂為「API key 對 / 不對」單一布林 - **不必動 MC scope onboarding**(部分 v2.0 新增收益FAA 線 4 個 scope 已備妥converter 線本來就不依賴 MC
- **可觀測性減負**:不需追 token cache hit rate、不需追 MC 失敗率 - **converter middleware 極簡**v1.0 收益保留):只需「比對單一字串 + constant-time compare」
- **已洩漏的 stage service client secret 直接作廢**:不需協調 MC team rotate - **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 並 redeploysecret 管理責任更重 - **converter 線 API key 是 long-lived secret**:不像 OAuth token 有 TTL通常 1 小時rotate 需要 visionA + converter 同步換 env 並 redeploysecret 管理責任更重
- **沒有 scope 細粒度**:未來如果 visionA 內部要區分「能 init job 但不能 promote」這種需求要回頭加但 1:1 trust 場景幾乎不會有此需求) - **converter 線沒有 scope 細粒度**:未來如果 visionA 內部要區分「能 init job 但不能 promote」這種需求要回頭加但 1:1 trust 場景幾乎不會有此需求)
- **沒有 audit trail誰用 token 做什麼)**OAuth + JWT 的 sub claim 提供天然 auditAPI key 模式下 converter / FAA 只知道「是 visionA」需要靠 visionA 內部 log + request_id 串接才能追到 user_id既有 request_id 機制可滿足) - **converter 線沒有 audit trail誰用 token 做什麼)**OAuth + JWT 的 sub claim 提供天然 auditAPI key 模式下 converter 只知道「是 visionA」需要靠 visionA 內部 log + request_id 串接才能追到 user_id既有 request_id 機制可滿足)
- **delegated download token 暫時不能用 302 redirect 模式**Phase 0.8b 退回 server-side download proxyPhase 1 量大時要另外設計(見 §7 選項 B - **FAA 線維持 OAuth client_credentials 鏈條的部分複雜度**v2.0 新增visionA 仍需 mc_token_clientservice token cache + delegated download token issue + retry policyMC 仍是 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 小時可完成| | converter API key 洩漏git log、log shipping、Slack 訊息等)| (1) `.gitignore` 嚴格 ignore `.env*`(2) log 不印 token(3) stage / prod 用不同 key(4) 一旦發現洩漏 → 換 env → redeploy雙方協調 < 1 小時可完成|
| Rotate 流程缺失 | 配套必須產出 rotate runbookjimchen 對 converterwarrenchen 對 FAA | | converter API key Rotate 流程缺失 | 配套必須產出 rotate runbookjimchen 對 converter|
| 兩個 key 管理混淆converter / FAA | env 命名嚴格區分(`VISIONA_CONVERTER_API_KEY` / `VISIONA_FAA_API_KEY`config validate 啟動時檢查兩個都非空 | | FAA 線 service client secret 洩漏v2.0 新增)| 同 v1.x 處理MC team 換 client secret → visionA 同步 env → restart運維事件需跨 MC team 協調 |
| 開發環境 / stage / prod 用同一把 key | 嚴格分環境產 keydev / stage / prod 各自 `openssl rand -hex 32`),不重用 | | 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 | 嚴格分環境產 keydev / stage / prod 各自 `openssl rand -hex 32`),不重用 |
## 合規性 ## 合規性
- [x] 與使用者確認:採 API key + Authorization Bearer + 每個下游獨立 key - [x] 與使用者確認v2.0 範圍縮限至 visionA → converter API keyvisionA → FAA 回到 ADR-014 §2 原設計2026-05-16
- [x] 與 jimchen 確認(同時為 visionA + converter 維護者converter middleware 改寫由 jimchen 處理 - [x] 與 jimchen 確認(同時為 visionA + converter 維護者converter middleware 改寫由 jimchen 處理(沿用 v1.0 步驟 4 規劃)
- [ ] 與 warrenchen 確認FAA 端 middleware 改寫由 warrenchen 處理(**待 Phase 0.8b 步驟 5** - [x] ~~與 warrenchen 確認FAA 端 middleware 改寫由 warrenchen 處理~~**v2.0 撤回此項FAA repo 不需改動**
- [x] 與 ADR-014 對齊:本 ADR 部分 supersede ADR-014 §5 / §6OAuth 段落) / §7MC token retry rows不影響 ADR-014 其他段落 - [ ] **與 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 - [x] 與 ADR-013 對齊:本 ADR 不影響 user login 的 public PKCE client
- [ ] DevOps rotate runbook 待產出Phase 0.9 follow-up - [ ] DevOps rotate runbook 待產出Phase 0.9 follow-up;範圍縮限至 converter API key
- [ ] 已洩漏的 stage service client secret `RciRUyi...` 自動作廢(不需 MC rotate - [ ] 已洩漏的 stage service client secret `RciRUyi...` 自動作廢(不需 MC rotatev2.0 由新 client `4242ba63...` 取代
## 配套產出(給後續 Phase ## 配套產出(給後續 Phase
### Phase 0.8b 範圍內 ### Phase 0.8b 範圍內v2.0 修訂)
- visionA backend 程式碼改造backend agent 任務) - visionA backend 程式碼改造backend agent 任務):
- converter middleware 改造jimchen 跨 repo - converter_client.go 維持 v1.0 改造API key
- FAA middleware 改造warrenchen 跨 repo - **faa_client.go 改回呼叫 MCTokenClient.ServiceToken() + 為 download 路徑增 DownloadWithDelegated 變體**
- `.env.stage.example` 更新(移除 service client env、新增 API key env - **mc_token_client.go 部分復活**service token cache + delegated download token issue 邏輯)
- 設計文件更新conversion.md / api-conversion.md / oidc-tdd.md — 本 ADR 同步產出) - 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 ### Phase 0.9 / Phase 1 follow-up
- [ ] API key rotate runbook每個下游一份含「產新 key → 雙方同步 env → restart → 驗證 → 拔舊 key」步驟 - [ ] converter API key rotate runbook含「產新 key → 雙方同步 env → restart → 驗證 → 拔舊 key」步驟;範圍縮限至 converter
- [ ] 是否設 API key 有效期(例如 1 年到期自動提醒)— 由 SRE 流程決定 - [ ] 是否設 converter API key 有效期(例如 1 年到期自動提醒)— 由 SRE 流程決定
- [ ] FAA 上「visionA 自己簽 HMAC token」delegated 機制§7 選項 B用於 download 路徑回 302 redirect - [ ] FAA 上「visionA 自己簽 HMAC token」delegated 機制§7 選項 B用於 download 路徑回 302 redirect(同 v1.x 規劃)
- [ ] 觀察 server-side download proxy 在 Phase 1 量大時的效能 / 頻寬 cost - [ ] 觀察 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-013-public-client.md`](./adr-013-public-client.md)user login 部分)
- 詳細實作(本 ADR 同步更新):`conversion.md``api/api-conversion.md``oidc-tdd.md` - 詳細實作(本 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」段落 - 觸發背景:`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-57JWT Bearer 設定、line 184-254download endpoint 不掛 RequireAuthorization、用 IDelegatedDownloadTokenValidator、line 291-322EnsureJwtScopeAndTenant + 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 keyvisionA → converter / FAA 兩條線都改)|
| 2026-05-15 | 1.1 | 補 §3.5 Reference Middleware Implementation — Go (converter) + C# (FAA) snippet + 部署檢查清單,給跨 repo 改 middleware 時照抄 | | 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 tokenvisionA → 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` endpointvisionA 無法跟 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 keyv1.0 / v2.0 / v2.1 都維持不變)**。 |

View File

@ -0,0 +1,482 @@
# ADR-016visionA 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 / `<a href download>` frontend pattern」的決策**沿用、由本 ADR 接手**。
- [ADR-014](./adr-014-conversion-integration.md) §3「半自動 — 加到模型庫」中「visionA backend 跟 FAA pull NEFscope `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」三 rowvisionA 端不再觸發converter 端有自己的 retry 矩陣,不在本 ADR 範圍)。
- [ADR-015 v2.0](./adr-015-server-to-server-api-key.md) §2「visionA → FAAMC 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) §1upload 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 → converterinit / poll / promote / **result download**API keyADR-015 §1
- converter → FAApromote 內部 PUT NEFconverter 自己用 OAuth client_credentials + scope `files:upload.write`converter 既有實作,與 visionA 無關)
- visionA ↔ FAA**Phase 0.8b 範圍內不存在這條鏈**(本 ADR 撤回)
- visionA ↔ MCserver-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 CenterMC與 File Access AgentFAA的 source code 做全面驗證,發現 **ADR-014 §2 設計的 delegated download token 鏈路從 2026-05-02 寫文件至今一直是斷的、只是因為 visionA 從未實際 e2e 跑通 download 而沒人發現**
#### 致命發現 1MC 沒有「issue delegated download token」endpoint
`/Users/jimchen/member_center/src/MemberCenter.Api/Controllers/` 完整 grep8 個 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.csOpenIddict 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 endpointstenants / 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」流程從未存在。
#### 致命發現 2MC 沒有「validate delegated download token」endpoint
FAA 端 `/Users/jimchen/file_access_agent/src/FileAccessAgent.Infrastructure/Services/MemberCenterDelegatedDownloadTokenValidator.cs` 線 27-72
```csharp
public async Task<DelegatedDownloadTokenValidationResult> 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。
#### 致命發現 3ADR-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 tokenscope `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-1532026-05-16 上午寫)也沿用這個 assumption雖然 v2.0 描述的設計「正確的」是 ADR-014 §2 原意圖,但 ADR-014 §2 原意圖本身就建立在錯誤的 MC 能力假設上)。
#### 致命發現 4FAA 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 blockerconverter `: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 `<a href>` 觸發 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 自己維護的 repovisionA + converter內完成、不觸碰 MC / FAA。
### 既有可用基礎建設
| 元件 | 狀態 | 可用性 |
|------|------|--------|
| converter promote → FAA PUT NEFOAuth + `files:upload.write` | Phase 1 已上線 | ✅ work已在 prod 跑 / `apps/task-scheduler/src/fileAccessAgent/client.js`|
| converter MinIO 存 NEF | converter scheduler 既有 | ✅ promote 後 NEF 同時保留在 converter MinIO + FAAconverter expires_at = 7 天)|
| visionA → converter API keyADR-015 §1| commit `86b7175` 已實作 | ✅ workinit / 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 中轉** 解決 downloadpromote 路徑不變。
### 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 <CONVERTER_API_KEY>
Accept: application/octet-stream
```
| 元素 | 規格 |
|------|------|
| Method | `GET` |
| Path | `/api/v1/jobs/:id/result` |
| Path param `id` | converter job idUUID v4 |
| Auth | `Authorization: Bearer <CONVERTER_API_KEY>`(同 ADR-015 §1 既有 middlewareconverter middleware `subtle.ConstantTimeCompare` 比對 env |
| Query params | 無 |
| Request body | 無 |
#### 1.2 Response — 成功200
```
HTTP/1.1 200 OK
Content-Type: application/octet-stream
Content-Length: <NEF binary 真實大小>
Content-Disposition: attachment; filename="<source_filename_stem>_<target_chip_lower>.nef"
Cache-Control: no-store, no-cache, must-revalidate, max-age=0
<NEF binary stream>
```
| Header | 來源 / 規格 |
|--------|-----------|
| `Content-Type` | 固定 `application/octet-stream` |
| `Content-Length` | converter MinIO 物件實際大小(必填,給 visionA backend 設 Content-Length 透傳到 browser|
| `Content-Disposition` | converter 自行構造 filename`<source_filename_stem>_<target_chip_lower>.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 streamconverter 從 MinIO get object 後直接 stream 出來) |
#### 1.3 Response — 錯誤
| HTTP | code | 條件 | converter 回 bodyJSON |
|------|------|------|--------------------------|
| 401 | `unauthorized` | API key 不對 / 缺 / 格式錯 | `{"error":"unauthorized"}`(同 ADR-015 §3.5.1 既有 middleware|
| 404 | `job_not_found` | job_id 不存在 / 已被 GCconverter 7 天 expires_at| `{"error":"job_not_found"}` |
| 409 | `job_not_completed` | job 尚未 completedstatus ≠ completed| `{"error":"job_not_completed","status":"<current>"}` |
| 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 capconverter 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` 同一 scopeAPI key 比對由 middleware 統一)
- handler 流程:
1. 從 jobService 查 jobstatus 必須是 `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 <VISIONA_CONVERTER_API_KEY>
(converter middleware: ConstantTimeCompare)
200 NEF stream + Content-Length + Content-Disposition
visionA backend handler
- Content-Type: application/octet-stream
- Content-Length: <size>
- 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 <VISIONA_CONVERTER_API_KEY>
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.5ADR-015 v2.0| **Phase 0.8b v0.6(本 ADR** |
|------------|------|------------------------------|-----|
| `VISIONA_CONVERTER_API_KEY` | visionA → converterinit / 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 keyjimchen 自己管 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 bottleneckvisionA 跨 internet 從 converter stream、再中轉給 user雙倍流量(2) converter MinIO 的 retention 變成 download 可用性 SLAconverter 7 天 expires_at = 7 天後 user 不能 download(3) converter API 變 download 的單點故障converter down → user 不能 download即使 FAA 上有 NEF(4) 跨 repo 任務converter scheduler 加 endpoint— 但因 jimchen 維護兩端、cost 可控 |
| 排除原因 | **未排除 — 採用** |
### 方案 Bmetadata-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 預期能「永久持有」的動作 |
### 方案 Cconverter 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-triggeruser 可能 promote 完很久才下載)、把 stream 綁在 promote 不符合使用場景 |
| 排除原因 | **promote 設計大改、UX 倒退** |
### 方案 DvisionA 自己簽 HMAC token、要求 FAA 加新 auth path
| 評估 | 內容 |
|------|------|
| 優點 | visionA 不依賴 MC、token 機制比 API key 細粒度(短 TTL + 綁 object_key + 綁 method |
| 缺點 | (1) **動 FAA**warrenchen 要在 FAA dual-authJWT + 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 → redeploy2026-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」、多一 hopconverter 從 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 既有 GC7 天 expires_at即會自動清理Phase 1+ 可加 retention 監控 |
| converter scheduler 在 Phase 0.8b 量小可承受 download bottleneck、但 Phase 1+ 量大時不行 | Phase 1+ 升級到方案 DvisionA 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 實作 bugstream pipeline 寫錯導致記憶體爆)| 加 integration testmock MinIO 大檔,驗 stream 不暫存)|
---
## 配套產出(給後續 Phase
### Phase 0.8b 範圍內(本 ADR 觸發的 follow-up
#### A. converter 跨 repojimchen
- 新增 `apps/task-scheduler/src/routes/v1/result.js`(或加進 `jobs.js``GET /api/v1/jobs/:id/result` handler
- 套用既有 `requireReadAuth` middlewareAPI 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 backendbackend 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 <ConverterAPIKey>`
- 改 `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 模式 → 重新評估方案 DvisionA HMAC token + FAA 加第三條 auth path
- [ ] 若 Phase 2+ 要回 OAuth 框架 → 重新評估方案 E協調 MC / FAA team 補完 delegated token 鏈)
---
## 合規性
- [x] 與使用者確認:本 ADR 採方案 Aconverter 加 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 shiprotate 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) §1upload streaming proxy、§4模組劃分、§6user_id trust boundary
- [`adr-015-server-to-server-api-key.md`](./adr-015-server-to-server-api-key.md) v2.0 §1visionA → 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.4Metadata 區範圍說明、§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`(既有 endpointPOST jobs / GET jobs / GET jobs/:id / POST jobs/:id/promotePOST jobs/:id/download-tokens 預留 501DELETE 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 為部分 supersedeuser loginADR-013/ upload streamingADR-014 §1/ converter API keyADR-015 §1全部不變。涉及 visionA + converter 兩個 repo 改造;不動 MC、不動 FAA、不動 warrenchen。 |

View File

@ -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 > **base URL**`https://stage-9527.innovedus.com:9527/`stage / `http://localhost:3721`dev
> **Authuser → visionA**OIDC cookie session`visiona_session`),參見 `oidc-tdd.md` — 與 Phase 0.8 完全一致,未變 > **Authuser → 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) > **服務間認證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)
> **同層**`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 → 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 契約 > **角色**:給 visionA-frontend 實作時的 API 契約
--- ---
@ -73,7 +74,7 @@ multipart fields**注意:不要帶 user_idbackend 會從 cookie 灌**
| 409 | `active_job_exists` | visionA pre-check / converter | 顯示「你已有進行中任務」+ details.job | | 409 | `active_job_exists` | visionA pre-check / converter | 顯示「你已有進行中任務」+ details.job |
| 413 | `payload_too_large` | converter | 提示檔案大小限制 | | 413 | `payload_too_large` | converter | 提示檔案大小限制 |
| 502 | `converter_unavailable` | visionA | 提示「轉檔服務暫時無法使用」+ 重試按鈕 | | 502 | `converter_unavailable` | visionA | 提示「轉檔服務暫時無法使用」+ 重試按鈕 |
| 502 | `converter_auth_failed` | visionAPhase 0.8b 新增)| 同上文字 — frontend 看不出差別SRE 從 log 排查 API key 同步 | | 502 | `converter_auth_failed` | visionAPhase 0.8b v1.0 新增v2.0 維持| 同上文字 — frontend 看不出差別SRE 從 log 排查 converter API key 同步 |
| 503 | `service_busy` | converter | 提示稍後重試 | | 503 | `service_busy` | converter | 提示稍後重試 |
--- ---
@ -182,22 +183,30 @@ Content-Type: application/json
| HTTP | code | 處理 | | HTTP | code | 處理 |
|------|------|-----| |------|------|-----|
| 403 | `forbidden` | 不是該 user 的 job | | 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 | | 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 | `converter_auth_failed` | converter API key 不同步(運維事件)|
| 502 | `faa_unavailable` | FAA pull 失敗,可重試 |
| 502 | `faa_auth_failed` | FAA API key 不同步(運維事件)|
**冪等性**:對同一 `job_id` 重複呼叫;若已建過 model record回 200 + 既有 model 詳情(不重新建)。 **冪等性**:對同一 `job_id` 重複呼叫;若已建過 model record回 200 + 既有 model 詳情(不重新建)。
> **v0.6 註**:撤回 v0.5 的 `mc_token_unavailable` / `download_token_failed` / `faa_unavailable` 三個 codev0.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 → FAApromote 時 converter 內部 PUT是 converter 自己的事、與 visionA 無關converter 端失敗會在 promote response 中體現為 5xx
--- ---
## 4. `GET /api/conversion/{job_id}/download` ## 4. `GET /api/conversion/{job_id}/download`
「下載」 — **Phase 0.8bvisionA-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.6visionA-backend server-side stream proxy from converter `GET /api/v1/jobs/{id}/result`**。流程演進:
對 frontend 而言**呼叫方式完全一致**`<a href>` / `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 NEFtoken 用 visionA API key
- **Phase 0.8b v0.5 (ADR-015 v2.0)**:保留 server-side stream proxytoken 來源改回 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 而言**呼叫方式完全一致**`<a href>` / `window.location.href`response 仍是「200 + NEF binary stream + Content-Disposition: attachment」與 v0.4 / v0.5 一致;只是 visionA backend 內部抓 stream 的下游 endpoint 改變frontend 無感)。
### Request ### Request
@ -246,12 +255,11 @@ Frontend **不需處理 token、不需處理 redirect**`Content-Disposition:
|------|------|-----| |------|------|-----|
| 401 | `unauthorized` | 沒登入redirect /login前端攔截| | 401 | `unauthorized` | 沒登入redirect /login前端攔截|
| 403 | `forbidden` | 不是該 user 的 job | | 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不能下載 | | 409 | `job_not_completed` | job 還沒 completed不能下載 |
| 502 | `converter_unavailable` | promote 失敗(首次下載且尚未 promote 過時可能發生)| | 410 | `result_expired` | v0.6 新增job completed 但 converter MinIO 內 NEF 已過 7 天 expires_at 被 GCfrontend 顯示「轉檔結果已過期,請重新轉檔」並提供重新轉檔 CTA |
| 502 | `converter_unavailable` | promote 失敗(首次下載且尚未 promote 過時可能發生)/ converter `GET result` 5xx |
| 502 | `converter_auth_failed` | converter API key 不同步運維事件frontend 不需區分)| | 502 | `converter_auth_failed` | converter API key 不同步運維事件frontend 不需區分)|
| 502 | `faa_unavailable` | FAA pull 失敗 |
| 502 | `faa_auth_failed` | FAA API key 不同步運維事件frontend 不需區分)|
**錯誤回應格式**:依 `Accept` header **錯誤回應格式**:依 `Accept` header
- `Accept: application/json``{success:false, error:{code, message}}` - `Accept: application/json``{success:false, error:{code, message}}`
@ -259,8 +267,11 @@ Frontend **不需處理 token、不需處理 redirect**`Content-Disposition:
**注意** **注意**
- 每次「下載」按鈕都直接打 `/download` endpoint不要前端 cache 任何中間狀態 - 每次「下載」按鈕都直接打 `/download` endpoint不要前端 cache 任何中間狀態
- Phase 0.8b 退回 server-side proxy 後visionA backend 變 streaming bottleneck — Phase 1 量大評估升級ADR-015 §7 選項 B - Phase 0.8bv0.4 / v0.5 / v0.6server-side proxy 模式下visionA backend 變 streaming bottleneck — Phase 1+ 量大評估升級converter Phase 2 download-tokens 讓 browser 直連 converter或 FAA HMAC token
- 不會與 `promote-to-models` 衝突;兩者內部都會 ensurePromoted冪等兩條路徑都拿同一個 target_object_key - 不會與 `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.<short-name>` 對齊 `conversion.md` §6。前端 i18n key 統一 `conversion.error.<short-name>`
> **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 | | code | HTTP | i18n key | 預設訊息zh-TW |
|------|------|----------|------------------| |------|------|----------|------------------|
@ -326,25 +337,42 @@ Frontend 在「轉檔」入口的 `/conversion` 頁載入時打這個 endpoint
| `unauthorized` | 401 | `common.error.unauthorized` | 請先登入 | | `unauthorized` | 401 | `common.error.unauthorized` | 請先登入 |
| `forbidden` | 403 | `conversion.error.forbidden` | 你無權存取此任務 | | `forbidden` | 403 | `conversion.error.forbidden` | 你無權存取此任務 |
| `not_found` | 404 | `conversion.error.not_found` | 任務不存在 | | `not_found` | 404 | `conversion.error.not_found` | 任務不存在 |
| `result_not_found` | 404 | `conversion.error.not_found` | 任務不存在v0.6 新增converter `GET result` 回 404i18n 與 `not_found` 共用文字、SRE 從 log 區分)|
| `active_job_exists` | 409 | `conversion.error.active_job` | 你目前已有進行中的轉檔任務 | | `active_job_exists` | 409 | `conversion.error.active_job` | 你目前已有進行中的轉檔任務 |
| `job_not_completed` | 409 | `conversion.error.not_completed` | 任務尚未完成(`promote-to-models``download` 共用) | | `job_not_completed` | 409 | `conversion.error.not_completed` | 任務尚未完成(`promote-to-models``download` 共用) |
| `result_expired` | 410 | `conversion.error.result_expired` | 轉檔結果已過期請重新轉檔v0.6 新增converter `GET result` 回 410frontend 顯示重新轉檔 CTA|
| `payload_too_large` | 413 | `conversion.error.too_large` | 檔案超過大小限制 | | `payload_too_large` | 413 | `conversion.error.too_large` | 檔案超過大小限制 |
| `converter_unavailable` | 502 | `conversion.error.converter_down` | 轉檔服務暫時無法使用 | | `converter_unavailable` | 502 | `conversion.error.converter_down` | 轉檔服務暫時無法使用 |
| `converter_auth_failed` | 502 | `conversion.error.converter_down` | 轉檔服務暫時無法使用Phase 0.8b 新增 — frontend 文字同 converter_unavailable| | `converter_auth_failed` | 502 | `conversion.error.converter_down` | 轉檔服務暫時無法使用v1.0 新增v2.0 / v0.6 維持 — frontend 文字同 converter_unavailable|
| `faa_unavailable` | 502 | `conversion.error.faa_down` | 檔案存取服務暫時無法使用 |
| `faa_auth_failed` | 502 | `conversion.error.faa_down` | 檔案存取服務暫時無法使用Phase 0.8b 新增)|
| `service_busy` | 503 | `conversion.error.busy` | 系統繁忙,請稍後再試 | | `service_busy` | 503 | `conversion.error.busy` | 系統繁忙,請稍後再試 |
**Phase 0.8b 移除(不會再出現的舊 code** **Phase 0.8b v0.6 撤回v0.5 加的、v0.6 移除**
| 已移除 | 原 HTTP | 原語意 | | 已撤回 | 原 HTTP | 原語意v0.5| v0.6 替代 |
|------|---------|------| |------|---------|-------|---|
| `idp_misconfigured` | 500 | MC token endpoint 4xx | | `mc_token_unavailable` | 502 | MC `/oauth/token` 4xx / 5xx | 不需要visionA 端不再打 MC|
| `idp_unavailable` | 503 | MC token endpoint 5xx | | `download_token_failed` | 502 | MC `/file-access/download-tokens` 4xx / 5xx | 不需要visionA 端不再 issue delegated token|
| `download_token_failed` | 502 | MC delegated token 4xx | | `faa_unavailable` | 502 | FAA pull 失敗 | 不需要visionA 端不再直接打 FAAconverter `GET result` 失敗統一收斂進 `converter_unavailable`|
| `mc_token_unavailable` | 502 | MC 持續失敗 |
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 keyuser 角度兩者等價:任務不存在)
--- ---
@ -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` patterntoken 不過 frontend JS、不需 FAA CORS`job_not_completed` HTTP code 從 400 改為 409 + 補 `mc_token_unavailable` | | 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` patterntoken 不過 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-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 呼叫方式(`<a href>` / `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-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 呼叫方式(`<a href>` / `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 tokenvisionA → 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 → converterAPI 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` 顯示「請重新轉檔」CTAi18n key `conversion.error.result_expired`)。 |

View File

@ -1,11 +1,11 @@
# Conversion — 轉檔功能整合Phase 0.8 / Phase 0.8b # Conversion — 轉檔功能整合Phase 0.8 / Phase 0.8b
> **角色**visionA-backend 端的「轉檔」實作 spec內部模組設計 + API + flow > **角色**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 規格細節) > **同層文件**`api/api-conversion.md`(對 frontend 的 API 規格細節)
> **作者**Architect Agent > **作者**Architect Agent
> **狀態**Phase 0.8b 修訂v0.4)— OAuth client_credentials 改 pre-shared API key > **狀態**Phase 0.8b v0.6 修訂 — visionA → converter 走 API key不變**visionA → FAA / MC 兩條鏈完全撤回**download 改走 converter `GET /api/v1/jobs/{id}/result` 中轉
> **最後更新**2026-05-11 > **最後更新**2026-05-16
--- ---
@ -26,13 +26,17 @@
## 1. 整體 flow端對端 ## 1. 整體 flow端對端
> **Phase 0.8b 變更**:服務間認證從「打 MC 換 OAuth service token + JWKS 驗簽 + scope」改為「visionA 帶 `Authorization: Bearer <pre-shared-api-key>` 直接打 converter / FAA」。詳見 §3 與 ADR-015。 > **Phase 0.8b v0.6 變更**visionA 端 server-to-server 鏈路**收斂為單條**(只剩 visionA → converter
> - visionA → converter`Authorization: Bearer <VISIONA_CONVERTER_API_KEY>`ADR-015 §1不變
> - visionA → FAA / MC**完全撤回**v0.5 加回的鏈是 broken design對 MC source 全 grep 驗證後確認 MC 沒有 delegated download token endpoint
> - downloadvisionA → converter `GET /api/v1/jobs/{id}/result`converter 從 MinIO stream NEF 回 visionA再 io.CopyN 中轉給 browserADR-016
> - 詳見 §3 與 [ADR-016](./adr/adr-016-download-via-converter.md)
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
participant B as Browser participant B as Browser
participant V as visionA-backend participant V as visionA-backend
participant C as Converter participant C as Converter (incl. MinIO)
participant F as FAA participant F as FAA
Note over B,F: Stage 1 — Init jobstreaming upload Note over B,F: Stage 1 — Init jobstreaming upload
@ -54,65 +58,85 @@ sequenceDiagram
V-->>B: 整形後 status V-->>B: 整形後 status
end end
Note over B,F: Stage 3a — User 選「加到模型庫」 Note over B,F: Stage 3 — Promoteconverter 內部 push FAA與 visionA 無關)
B->>V: POST /api/conversion/{job_id}/promote-to-models B->>V: POST /api/conversion/{job_id}/promote-to-models (or download trigger)
V->>C: POST /api/v1/jobs/{id}/promote<br/>Authorization: Bearer <VISIONA_CONVERTER_API_KEY> V->>C: POST /api/v1/jobs/{id}/promote<br/>Authorization: Bearer <VISIONA_CONVERTER_API_KEY>
C->>F: PUT /files/{key} (NEF — converter 內部認證,與 visionA 無關) C->>F: PUT /files/{target_object_key} (NEF — converter 自己的 OAuth + files:upload.write scope與 visionA 完全無關)
C-->>V: {target_object_key} C-->>V: {target_object_key}<br/>(NEF 同時保留在 converter MinIO 7d expires_at)
V->>F: GET /files/{key}<br/>Authorization: Bearer <VISIONA_FAA_API_KEY>
F->>F: middleware: ConstantTimeCompare(key, FAA_API_KEY) Note over B,F: Stage 3a — User 選「加到模型庫」
F-->>V: NEF stream V->>C: GET /api/v1/jobs/{id}/result<br/>Authorization: Bearer <VISIONA_CONVERTER_API_KEY>
C-->>V: 200 NEF binary stream (from converter MinIO)
V->>V: /api/models/init → /api/models/finalize<br/>(Source=converted, SourceJobID=job_id) V->>V: /api/models/init → /api/models/finalize<br/>(Source=converted, SourceJobID=job_id)
V-->>B: 201 {model_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 B->>V: GET /api/conversion/{job_id}/download
V->>V: AuthMiddleware → user_id + ownership 檢查 V->>V: AuthMiddleware → user_id + ownership 檢查
V->>C: POST /api/v1/jobs/{id}/promote (若還沒 promote)<br/>Authorization: Bearer <VISIONA_CONVERTER_API_KEY> V->>C: POST /api/v1/jobs/{id}/promote (若還沒 promote — 冪等)
C-->>V: {target_object_key} C-->>V: {target_object_key}
V->>F: GET /files/{key}<br/>Authorization: Bearer <VISIONA_FAA_API_KEY> V->>C: GET /api/v1/jobs/{id}/result<br/>Authorization: Bearer <VISIONA_CONVERTER_API_KEY>
F-->>V: NEF stream C-->>V: 200 NEF binary stream + Content-Length + Content-Disposition
V-->>B: stream NEFvisionA backend 中轉token 結構性不過 browser V-->>B: stream NEFvisionA backend io.CopyN 中轉、size cap 1 GiB
``` ```
**critical path 說明** **critical path 說明**
- visionA-backend 在 upload / download / promote 任一階段都先做 OIDC AuthMiddleware既有+ ownership 檢查 - visionA-backend 在 upload / download / promote 任一階段都先做 OIDC AuthMiddleware既有+ ownership 檢查
- promote 動作是冪等的converter 端對同一 job 重複 promote 接受visionA-backend 內部以 `job_id ↔ promoted_object_key` 記錄避免重複呼叫 - 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 邏輯,不繞過) - 加到模型庫流程v0.6promote → **converter.GetResult 拉 NEF stream**(不是直接打 FAA`/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 各自只比對單一字串。 - download flowv0.6promote 冪等 → **converter.GetResult 拉 NEF stream** → io.CopyN 中轉給 browsersize cap 1 GiB
- **Phase 0.8b v0.6 認證鏈說明**
- visionA 端只有一條visionA → converterAPI key、constant-time compare
- converter → FAAconverter 自己用 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-014OAuth client_credentials | Phase 0.8bAPI key| | 面向 | ADR-014OAuth client_credentials 兩條線)| ADR-015 v1.x兩條線都 API key| ADR-015 v2.0converter API key + FAA OAuth + delegated| **ADR-016 / v0.6visionA 端只剩 converter**|
|------|--------------------------------|------------------| |------|--------------------------------|------------------|---|---|
| visionA → converter 認證 | `Authorization: Bearer <MC-issued service token>` | `Authorization: Bearer <VISIONA_CONVERTER_API_KEY>` | | visionA → converter 認證 | `Authorization: Bearer <MC-issued service token>` | `Authorization: Bearer <VISIONA_CONVERTER_API_KEY>` | 同 v1.x | 同 v1.x / v2.0(不變)|
| visionA → FAA 認證 | `Authorization: Bearer <MC-issued service token>` | `Authorization: Bearer <VISIONA_FAA_API_KEY>` | | visionA → FAAwrite / metadata / delete| `Authorization: Bearer <MC service token>` + scope | `Authorization: Bearer <VISIONA_FAA_API_KEY>` | 回到 ADR-014service token | **不存在**visionA 端不再直接打 FAA|
| download 流程 | server-side 302 redirect → browser 直連 FAA拿 MC delegated token | server-side proxyvisionA backend 中轉 stream| | visionA → FAAdownload `GET /files/{key}`| `Authorization: Bearer <MC delegated download token>` | `Authorization: Bearer <VISIONA_FAA_API_KEY>` | 回到 ADR-014delegated token | **不存在**visionA 端不再直接打 FAA|
| visionA → MC service token 路徑 | 啟動 lazy init MCTokenClient + cache | **完全移除** | | download 從哪取 NEF | FAA `GET /files/{key}` | 同上 | 同上fictional — delegated token endpoint MC 沒有)| **converter `GET /api/v1/jobs/{id}/result`**(從 converter MinIO stream|
| converter / FAA middleware | 驗 JWKS 簽章 + 驗 scope + 驗 tenant | 比對 env 字串constant-time| | 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-014dual-auth | 不適用visionA 端不再呼叫 FAAconverter → FAA 仍走 ADR-014 OAuth 路徑、但 converter 自己管)|
> 為什麼 download 不繼續走 302 redirectAPI key 模式下沒有 MC 簽 short-TTL delegated tokenvisionA 自己簽 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/` ## 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/ internal/conversion/
├── conversion.go # Service interface + 對外暴露的 type ├── conversion.go # Service interface + 對外暴露的 type
├── converter_client.go # converter scheduler API client直接帶 VISIONA_CONVERTER_API_KEY ├── converter_client.go # converter scheduler API clientinit / poll / promote / GetResult — 帶 VISIONA_CONVERTER_API_KEY
├── faa_client.go # FAA API clientpull NEF直接帶 VISIONA_FAA_API_KEY ├── (faa_client.go 刪除 / 改名) # v0.6visionA 端不再直接打 FAA改名為 converter_result_client.go或併入 converter_client.go唯一職責是打 converter GET result endpoint
├── flow.go # 整體 flow 協調 ├── (mc_token_client.go 刪除) # v0.6:撤回 v0.5「部分復活」決定visionA 端不再有任何 visionA → MC server-to-server 路徑
├── types.go # request / response struct ├── flow.go # 整體 flow 協調download / PromoteToModels 都走 converter.GetResult
└── errors.go # error code 定義 ├── types.go # request / response struct
└── errors.go # error code 定義
``` ```
**Phase 0.8b 移除** **Phase 0.8b v0.6 模組變更摘要(相對於 v0.5**
- ❌ `mc_token_client.go`~440 行 — 整檔砍)— 不再需要與 MC 換 service token / delegated download token - ✅ converter_client.go維持 v0.5API key 直接 set header**新增 `GetResult(ctx, jobID)` method** 用於拉 NEF binary stream
- ❌ `MCTokenClient` 在 flow / converter_client / faa_client 上的所有 reference - ❌ 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 ### 2.1 `conversion.go` — 對外 interface
@ -140,13 +164,16 @@ type Service interface {
// 回傳新建的 model_id。 // 回傳新建的 model_id。
PromoteToModels(ctx context.Context, userID, jobID string) (modelID string, err error) PromoteToModels(ctx context.Context, userID, jobID string) (modelID string, err error)
// DownloadStream — 「下載」流程Phase 0.8bserver-side proxy // DownloadStream — 「下載」流程Phase 0.8b v0.6server-side stream proxy + converter `GET /api/v1/jobs/{id}/result`
// 1. ownership 檢查 // 1. ownership 檢查
// 2. promote (若需要) // 2. ensurePromoted冪等 cache 對 converterNEF 確認已在 converter MinIO + FAA
// 3. 從 FAA pull NEF stream // 3. converter.GetResult(ctx, jobID) — 直接打 converter GET result endpoint
// 4. handler 直接 io.Copy stream 給 client // Authorization: Bearer <ConverterAPIKey>(同其他 converter API method
// 不再產生 302 redirect URLAPI key 模式下無 MC delegated token // converter response 200 + NEF binary stream + Content-Length + Content-Disposition
// 詳見 ADR-015 §7 — Phase 1+ 量大時再評估「visionA 自簽 HMAC token + 302」升級路徑。 // 4. handler 直接 io.CopyN stream 給 clientsize cap 1 GiB
// 不產生 302 redirect URLserver-side proxy 在 T4 已實作v0.4 / v0.5 / v0.6 沿用;不退回 302
// 不再經過 visionA → MC / visionA → FAA 任何路徑v0.6 整段撤回;詳見 ADR-016
// Phase 1+ 量大時可評估方案 DvisionA 自簽 HMAC + FAA 加第三條 auth path + 回 302
DownloadStream(ctx context.Context, userID, jobID string) (stream io.ReadCloser, meta *DownloadMetadata, err error) DownloadStream(ctx context.Context, userID, jobID string) (stream io.ReadCloser, meta *DownloadMetadata, err error)
// ActiveJob 查 user 當前是否有 active job給 frontend pre-check 用)。 // ActiveJob 查 user 當前是否有 active job給 frontend pre-check 用)。
@ -177,19 +204,20 @@ type Job struct {
ErrorMessage string `json:"error_message,omitempty"` ErrorMessage string `json:"error_message,omitempty"`
} }
// Phase 0.8bDownloadGrant 移除(不再有 MC delegated token 換取流程)。 // Phase 0.8b v0.6:撤回 v0.5 DownloadGrant struct不再需要 delegated token 持有結構)。
// Download 走 server-side proxytoken 結構性不過 frontend。 // visionA → converter 一條鏈、沒有 token issue 過程flow.go 直接呼叫 converter.GetResult
// 拿 stream + DownloadMetadata 即可。
// DownloadMetadata — DownloadStream 回傳的中介資料(沿用 §2.3 既定的型別)。 // 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 ```go
type ConverterClient struct { type ConverterClient struct {
baseURL string baseURL string
apiKey string // Phase 0.8bpre-shared API keyVISIONA_CONVERTER_API_KEY apiKey string // Phase 0.8b v1.x+v2.0pre-shared API keyVISIONA_CONVERTER_API_KEY
httpClient *http.Client 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) 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、不重簽** 1. `req.Header.Set("Authorization", "Bearer "+c.apiKey)` — 直接帶 pre-shared API key**不查 cache、不打 MC、不重簽**
2. 帶 `X-Request-Id`(從 ctx 取,沿用 visionA-backend 既有 request_id 中介層) 2. 帶 `X-Request-Id`(從 ctx 取,沿用 visionA-backend 既有 request_id 中介層)
3. response 5xx / network error 走 retry§9401/403 不 retryAPI key 錯不會自己變對) 3. response 5xx / network error 走 retry§9401/403 不 retryAPI 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 ```go
type FAAClient struct { // 新檔案(或併入 converter_client.go
baseURL string
apiKey string // Phase 0.8bpre-shared API keyVISIONA_FAA_API_KEY
httpClient *http.Client
}
// Download server-to-server 拉檔(給「加到模型庫」+「下載」兩個流程共用)。 // GetResult — 對 converter GET /api/v1/jobs/{id}/result 拉 NEF binary stream。
// 帶 Authorization: Bearer <VISIONA_FAA_API_KEY> // 帶 Authorization: Bearer <ConverterAPIKey>(同其他 converter API method
func (c *FAAClient) Download(ctx context.Context, objectKey string) (io.ReadCloser, *DownloadMetadata, error) // 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 { type DownloadMetadata struct {
SizeBytes int64 SizeBytes int64 // 從 converter response Content-Length 解析
ContentType string ContentType string // 固定 application/octet-stream
Checksum string // optional Filename string // 從 converter response Content-Disposition 解析visionA 端可選擇覆寫(見 §4.1 註)
} }
``` ```
**Phase 0.8b**:不再需要 `DownloadGrant`(無 MC delegated tokendownload flow 用同一個 `FAAClient.Download()` 拉 stream 後由 handler 中轉給 client。 **v0.6 重要設計約束**
- 「加到模型庫」flow 與「下載」flow **共用同一個 `GetResult`**——兩條 path 都從 converter MinIO 拉 NEF。visionA 端完全不需理解 FAA 的存在。
- size capvisionA backend handler 端用 `io.CopyN(w, stream, 1 GiB)` 保護converter 端不另外設 capconverter 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 tokenclient_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 後: > visionA 端**不再有任何 visionA → MC server-to-server 路徑**。user login 的 OIDCPKCE / cookie session / JWKS 驗 id_token是另一條完全獨立的鏈、不在本文件範圍詳見 `oidc-tdd.md`)。
> - 服務間認證直接帶 `Authorization: Bearer <pre-shared API key>` — 不需 cache、不需 refresh、不需 retry MC
> - download flow 退回 server-side proxyvisionA backend 中轉 stream不再有 delegated token 概念
>
> **Tenant 概念取消**visionA → converter / FAA 不再帶 tenant_idconverter 端的 user_id 仍由 visionA 灌入§7 trust boundary 不變)。
### 2.5 `flow.go` — 流程協調 ### 2.5 `flow.go` — 流程協調
```go ```go
type Flow struct { type Flow struct {
converter *ConverterClient converter *ConverterClient // 含 init / poll / promote / GetResult method
faa *FAAClient // (faa *FAAClient — v0.6 刪除)
// Phase 0.8b:不再需要 tokens *MCTokenClient // (tokens *MCTokenClient — v0.6 刪除,撤回 v0.5 復活)
models model.Repository // 沿用既有 model store models model.Repository // 沿用既有 model store
storage storage.Store // 沿用既有 LocalFS / S3 storage storage.Store // 沿用既有 LocalFS / S3
ownership ownershipStore // job_id → user_id mapping (in-memory map) 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 statusCache *jobStatusCache // 1-2s short cache避免 frontend polling 直接打爆 converter
} }
// 主要 method 對應 Service interface。 // DownloadStreamv0.6 流程):
// PromoteToModels 內部: // 1. ownership.Check(userID, jobID)
// 1. ownership.Check(userID, jobID) // 2. _, _ := flow.ensurePromoted(ctx, jobID) // 冪等 cache確保 converter 端 promote 完成NEF 已在 MinIO + FAA
// 2. promotedKey, err := flow.ensurePromoted(ctx, jobID) // 冪等:若已 promote 過用 cache否則打 converter // 3. stream, meta, _ := flow.converter.GetResult(ctx, jobID)
// 3. reader, meta, err := faa.Download(ctx, promotedKey) // 內部GET {ConverterBaseURL}/api/v1/jobs/{jobID}/result
// 4. modelID, putURL, _ := callModelsInit(...) // 直接呼叫 internal/api 同 package 既有 helper不走 HTTP // Authorization: Bearer <ConverterAPIKey>
// 5. PUT 到 storage或直接 io.Copy 到 storage.Put // converter response 200 + NEF binary stream + Content-Length + Content-Disposition
// 6. callModelsFinalize(...) // 4. meta.Filename = defaultDownloadFilename(cj) // visionA 自行構造§4.1 註)覆寫 converter 給的 filename
// 7. 在 model record 補 Source="converted" + SourceJobID=jobID // 5. return stream, meta, nil
// 8. 回 modelID //
// 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 interfacev0.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 不再用它直接打 FAAconverter `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` storein-memory ### 2.6 `ownership` storein-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.6visionA 端只剩單條 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 keyFAA 線回到 ADR-014 §2 原設計service token + delegated download token
> - **v0.6 (2026-05-16)**:對 MC source 驗證後確認 v0.5 設計的 delegated token 鏈是 fictionalMC 沒有對應 endpoint本節再次整段改寫——visionA → FAA / MC 鏈**完全撤回**download 改走 converter `GET /api/v1/jobs/{id}/result`visionA 端只剩 visionA → converter 一條 server-to-server 認證鏈
### 3.1 取得流程 ### 3.1 visionA → converterAPI 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` endpointADR-016 §1
#### 3.1.1 取得流程
``` ```
visionA-backend 啟動 visionA-backend 啟動
讀 cfg.Conversion.ConverterAPIKeyenv VISIONA_CONVERTER_API_KEY 讀 cfg.Conversion.ConverterAPIKeyenv VISIONA_CONVERTER_API_KEY
讀 cfg.Conversion.FAAAPIKeyenv VISIONA_FAA_API_KEY
[轉檔請求進來] [轉檔請求進來]
converter_client / faa_client 發 request 時: converter_client 發 request 時:
req.Header.Set("Authorization", "Bearer "+apiKey) req.Header.Set("Authorization", "Bearer "+apiKey)
converter / FAA middleware converter middleware
- parse Authorization header → 取 token - parse Authorization header → 取 token
- subtle.ConstantTimeCompare(token, envKey) - subtle.ConstantTimeCompare(token, envKey)
- match → 放行mismatch → 401 + log不附原因 - match → 放行mismatch → 401 + log不附原因
``` ```
**沒有 token cache、沒有 refresh、沒有 retry MC、沒有 scope 驗證**。整條鏈路是「visionA → 下游」一步。 **沒有 token cache、沒有 refresh、沒有 retry MC、沒有 scope 驗證**。整條鏈路是「visionA → converter」一步。
### 3.2 Config 對齊 #### 3.1.2 啟動時驗證
`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=<openssl rand -hex 32 產的值 converter CONVERTER_API_KEY 對齊>
VISIONA_FAA_API_KEY=<openssl rand -hex 32 產的值 FAA FAA_API_KEY 對齊>
# Phase 0.8b 移除:
# VISIONA_OIDC_SERVICE_CLIENT_ID
# VISIONA_OIDC_SERVICE_CLIENT_SECRET
# VISIONA_OIDC_TENANT_IDconversion 不依賴;其他模組未發現使用)
```
### 3.3 啟動時驗證
api-server 啟動時 log 一行(**不可 log key 本身** 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.1.3 Key 產生 / 部署 / Rotate
### 3.4 Key 產生 / 部署 / Rotate
| 項目 | 規格 | | 項目 | 規格 |
|------|------| |------|------|
| 長度 | 64 字元 hex256 bit 熵) — `openssl rand -hex 32` | | 長度 | 64 字元 hex256 bit 熵) — `openssl rand -hex 32` |
| 環境隔離 | dev / stage / prod 各自獨立的 key**不重用** | | 環境隔離 | dev / stage / prod 各自獨立的 key**不重用** |
| 兩個下游 | converter / FAA 各自一把,**不共用** |
| 儲存dev| `.env.dev`gitignore | | 儲存dev| `.env.dev`gitignore |
| 儲存stage| stage host `.env.stage`(不進 git | | 儲存stage| stage host `.env.stage`(不進 git |
| 儲存prod| AWS Secrets Manager / Vault | | 儲存prod| AWS Secrets Manager / Vault |
| Rotate | runbook Phase 0.9 補;流程:產新 key → 雙方同步 env → restart → 驗證 → 拔舊 key | | Rotate | runbook Phase 0.9 補;流程:產新 key → 雙方同步 env → restart → 驗證 → 拔舊 key |
| Log policy | 永遠不印 key 全文;可印 `api_key_set=true/false` 或前 8 字元 prefix | | 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 → MCissue 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 endpointPUT / metadata / HEAD / DELETE保留給 Phase 1+ 擴充用(如 visionA 主動清理 FAA 上的孤兒檔),到時候才走 service token 路徑。
#### ~~3.2.2 visionA 端流程~~v0.6 撤回 — 整段不再執行)
```
visionA-backend 啟動
讀 cfg.OIDC.ServiceClientID / ServiceClientSecretenv VISIONA_OIDC_SERVICE_CLIENT_ID / _SECRET
讀 cfg.OIDC.IssuerURLenv VISIONA_OIDC_ISSUER_URL — 同 user login 的 issuer
讀 cfg.Conversion.TenantIDenv VISIONA_OIDC_TENANT_ID
讀 cfg.Conversion.FAABaseURLenv VISIONA_FAA_BASE_URL
[Download 請求進來]
flow.DownloadStream / flow.PromoteToModels
mcTokenClient.ServiceToken(ctx)
cache hit
Yes → return cached tokenexp - 15s 內)
No → POST {issuer}/oauth/token
grant_type=client_credentials
client_id=<ServiceClientID>
client_secret=<ServiceClientSecret>
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 <service-token>
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 <delegated-token>
→ 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 envv2.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=<openssl rand -hex 32 產的值 converter 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=<see stage host .env.stage; 不進 git / 文件>
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 tokenscope 配置由 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 tokenFAA 端最終驗的是 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 authvisionA 端唯一一條)**visionA → converter 用 pre-shared API keyinit / poll / promote / **GetResult**
- **machine auth不在 visionA 範圍)**converter → FAA 用 OAuth client_credentials + `files:upload.write` scopeconverter 自己管 `apps/task-scheduler/src/fileAccessAgent/client.js`、Phase 1 已上線)
- **user auth**browser → visionA 用 OIDC cookie session既有未變 - **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。 詳見 §7。
@ -460,7 +619,7 @@ API key 證明的是「caller 是 visionA」machine 身份user_id 的
| `POST` | `/api/conversion/init` | OIDC cookie | 上傳 + 建 jobmultipart streaming | | `POST` | `/api/conversion/init` | OIDC cookie | 上傳 + 建 jobmultipart streaming |
| `GET` | `/api/conversion/{job_id}` | OIDC cookie | 查 job 狀態 | | `GET` | `/api/conversion/{job_id}` | OIDC cookie | 查 job 狀態 |
| `POST` | `/api/conversion/{job_id}/promote-to-models` | OIDC cookie | 「加到模型庫」 | | `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.6server-side stream proxyvisionA backend 中轉 NEF binarysource 從 converter `GET /api/v1/jobs/{id}/result` 拉,使用 `VISIONA_CONVERTER_API_KEY`;不再經 FAA / MC |
| `GET` | `/api/conversion/active` | OIDC cookie | 查當前 user 有無 active jobfrontend pre-checkin-memory miss 時 fallback 對 converter lazy rebuild§2.6.1 | | `GET` | `/api/conversion/active` | OIDC cookie | 查當前 user 有無 active jobfrontend pre-checkin-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 做韌性處理。 > **不對外暴露但內部使用的 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 - response 用既有 `WriteSuccess` / `WriteError` helper
- request_id 透傳給 converter`X-Request-Id` header - request_id 透傳給 converter`X-Request-Id` header
### 4.1 `GET /api/conversion/{job_id}/download` — Phase 0.8bserver-side stream proxy handler ### 4.1 `GET /api/conversion/{job_id}/download` — Phase 0.8b v0.6server-side stream proxy from converter
> **變更**Phase 0.8ADR-014 v1.1)原本是 `c.Redirect(302, FAA_URL_with_delegated_token)`Phase 0.8b API key 模式下無 MC delegated token改為 visionA backend 中轉 stream。 > **演進**
> - **Phase 0.8ADR-014 v1.1**`c.Redirect(302, FAA_URL_with_delegated_token)`
> - **Phase 0.8b v0.4 (ADR-015 v1.x)**:改為 server-side stream proxytoken 來源用 visionA API keyv0.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 ```go
// GET /api/conversion/{job_id}/download // GET /api/conversion/{job_id}/download
@ -485,7 +648,13 @@ func conversionDownloadHandler(deps Deps) gin.HandlerFunc {
uc, _ := UserContextFrom(c) // AuthMiddleware 已驗 uc, _ := UserContextFrom(c) // AuthMiddleware 已驗
jobID := c.Param("job_id") 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 <ConverterAPIKey>
// → 200 NEF binary stream + Content-Length + Content-Disposition
stream, meta, err := deps.Conversion.DownloadStream(c.Request.Context(), uc.UserID, jobID) stream, meta, err := deps.Conversion.DownloadStream(c.Request.Context(), uc.UserID, jobID)
if err != nil { if err != nil {
writeConversionError(c, err) // §6 錯誤碼分類 writeConversionError(c, err) // §6 錯誤碼分類
@ -493,21 +662,24 @@ func conversionDownloadHandler(deps Deps) gin.HandlerFunc {
} }
defer stream.Close() defer stream.Close()
// streaming proxy 給 clientio.Copy不暫存 disk / RAM 全 buffer // streaming proxy 給 clientio.CopyN;不暫存 disk / RAM 全 buffer
c.Header("Content-Type", meta.ContentType) c.Header("Content-Type", meta.ContentType)
if meta.SizeBytes > 0 { if meta.SizeBytes > 0 {
c.Header("Content-Length", strconv.FormatInt(meta.SizeBytes, 10)) c.Header("Content-Length", strconv.FormatInt(meta.SizeBytes, 10))
} }
// 鼓勵 browser 觸發 save dialog // 鼓勵 browser 觸發 save dialog
// 注意meta.Filename **不是** FAA metadata 直接給的FAA 端的 object_key 是 // 注意meta.Filename **不是** converter 直接給的 raw object_keyconverter 端的
// `models/<user>/<job>.nef` 對 user 不友善),而是 visionA backend 在 service 層 // object_key 是 `models/<user>/<job>.nef` 對 user 不友善converter response 的
// 由 `defaultDownloadFilename(cj)` 從 conversion job metadata 構造,規則: // Content-Disposition 雖含 filename 建議值,但 visionA backend 仍在 service 層用
// `defaultDownloadFilename(cj)` 從 conversion job metadata 重新構造,規則:
// `<source_filename_stem>_<target_chip_lower>.nef`(例:`yolov5s_kl720.nef` // `<source_filename_stem>_<target_chip_lower>.nef`(例:`yolov5s_kl720.nef`
// 對齊 wireframe success card 顯示範例(`yolov5s.onnx → yolov5s_kl720.nef`)。 // 對齊 wireframe success card 顯示範例(`yolov5s.onnx → yolov5s_kl720.nef`)。
c.Header("Content-Disposition", `attachment; filename="`+sanitizeFilename(meta.Filename)+`"`) c.Header("Content-Disposition", `attachment; filename="`+sanitizeFilename(meta.Filename)+`"`)
c.Header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") c.Header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
c.Status(http.StatusOK) c.Status(http.StatusOK)
io.Copy(c.Writer, stream) // 注意:用 io.CopyN(c.Writer, stream, sizeCap) 帶 1 GiB 上限保護 visionA backend 不被超大檔吃記憶體
// sizeCap = 1 << 301 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 的結果」語意 - GET semantically 對應「拿一個資源」,符合「下載這個 job 的結果」語意
- 既有 OIDC AuthMiddleware 會自然處理 GET 上的 cookie session無 CSRF 風險沒有狀態變更promote 是冪等的) - 既有 OIDC AuthMiddleware 會自然處理 GET 上的 cookie session無 CSRF 風險沒有狀態變更promote 是冪等的)
**Frontend 使用範例**(與 Phase 0.8 一致,無需改動): **Frontend 使用範例**(與 Phase 0.8 / v0.4 一致,無需改動):
```html ```html
<!-- 推薦anchor tagbrowser 自動處理 navigation + 收 attachment --> <!-- 推薦anchor tagbrowser 自動處理 navigation + 收 attachment -->
@ -532,17 +704,23 @@ func conversionDownloadHandler(deps Deps) gin.HandlerFunc {
window.location.href = `/api/conversion/${jobId}/download`; window.location.href = `/api/conversion/${jobId}/download`;
``` ```
**安全性面比較Phase 0.8 → Phase 0.8b** **安全性面比較Phase 0.8 → v0.4 → v0.5 → v0.6**
| 面向 | Phase 0.8302 redirect + MC delegated token| Phase 0.8bserver-side stream proxy| | 面向 | Phase 0.8302 + MC delegated token| v0.4server-side proxy + visionA API key 撤回)| v0.5server-side proxy + MC delegated token**fictional 從未跑通**| **v0.6server-side proxy + visionA → converter API key** |
|------|----|----| |------|----|----|---|---|
| Token 在 frontend JS / URL bar | △ 短暫Location header 流經 browsernav 完即消失) | ✓ 結構性不存在(無 token 概念)| | Token 在 frontend JS / URL bar | △ 短暫Location header 流經 browser | ✓ 結構性不存在 | ✓ 結構性不存在 | ✓ 結構性不存在API key 只在 server-side 流動)|
| 要 FAA CORS | ✓ 不需要navigation 不適用 CORS| ✓ 同 — visionA 為 same-originFAA 直連在 server-side | | 要 FAA CORS | ✓ 不需要 | ✓ 不需要 | ✓ 不需要 | ✓ 不需要visionA 端不直接打 FAA、CORS 完全不適用) |
| 跨 internet 流量(同 NEF 多次下載)| ✓ 直連 FAA、N× 流量算 FAA 出 | ✗ 每次都繞 visionA backendN× 流量算 visionA 出 | | 跨 internet 流量(同 NEF 多次下載)| ✓ 直連 FAA | ✗ 每次繞 visionA | ✗ 每次繞 visionA | ✗ 每次繞 visionA同 v0.4 / v0.5,未改變;但 source 從 FAA 換 converter MinIO|
| visionA backend 是否變 streaming bottleneck | ✓ 不是 | ✗ 是 — Phase 0.8 MVP user 量小可接受Phase 1 量大需改 ADR-015 §7 選項 B | | visionA backend 是否變 streaming bottleneck | ✓ 不是 | ✗ 是 | ✗ 是 | ✗ 是(同 v0.4 / v0.5Phase 0.8b MVP 接受Phase 1+ 升級見 ADR-016 後果 §負面影響)|
| 認證鏈簡化 | ✗ 需要 MC scope `files:download.delegate` | ✓ 一把 API key 解決 | | 認證鏈複雜度visionA → 下游)| MC service token + MC delegated token | 一把 API key | MC service token + MC delegated tokenfictional| 一把 API key同 v0.4,但這次是真的 work|
| Token TTL | 5 minMC 簽)| ∞API key long-lived| 5 minMC 簽,但 endpoint 不存在所以 issue 不到)| ∞API key long-livedrotate by runbook|
| Token 洩漏的 blast radius | 5 min 內可下載該 object_key | 永遠可打 FAA 任何 endpoint | — | 永遠可打 converter 任何 endpointjimchen 自己管 rotateconverter 不存其他 user 資料、攻擊面限於 converter 自己)|
**Phase 1 升級路徑**:如量大需回 302 redirect 模式,採 ADR-015 §7 選項 BvisionA 自己簽 short-TTL HMAC tokenFAA middleware 多支援「visionA HMAC」路徑 **為什麼 v0.6 仍對齊 v0.4 / v0.5 的 server-side proxy 而非退回 302**:見 §1 整體 flow 變更說明。
**Phase 1+ 升級路徑**:如量大需回 302 redirect 模式(讓 browser 直連 converter 或 FAA有兩個方向
- 方向 Aconverter Phase 2converter 補上 `POST /api/v1/jobs/:id/download-tokens`(既有預留 501給 browser 直連 converterADR-016 與此路徑相容
- 方向 BFAA HMACvisionA 自己簽 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 ## 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 `validation_error` | `validation_failed` | 400 | `conversion.error.validation` | 上傳的檔案不符合要求(請查看詳細欄位錯誤) |
| converter `invalid_multipart` | `validation_failed` | 400 | `conversion.error.invalid_multipart` | 上傳格式錯誤,請重新嘗試 | | converter `invalid_multipart` | `validation_failed` | 400 | `conversion.error.invalid_multipart` | 上傳格式錯誤,請重新嘗試 |
| converter `user_has_active_job` | `active_job_exists` | 409 | `conversion.error.active_job` | 你目前已有進行中的轉檔任務 | | 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 `file_too_large` | `payload_too_large` | 413 | `conversion.error.too_large` | 檔案超過大小限制 |
| converter `service_busy` | `service_busy` | 503 | `conversion.error.busy` | 系統繁忙,請稍後再試 | | 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 5xx / network | `converter_unavailable` | 502 | `conversion.error.converter_down` | 同上 |
| **converter 401API key 不對 / 過期 / rotate 未同步)** | `converter_auth_failed` | 502 | `conversion.error.converter_down` | 轉檔服務暫時無法使用(內部 log 區分 auth_failed vs 5xx| | **converter 401API 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` | 檔案存取服務暫時無法使用 | | **converter 404 `result_not_found`**v0.6 新增;`GET /api/v1/jobs/{id}/result` job 不存在)| `not_found` | 404 | `conversion.error.not_found` | 轉檔任務不存在 |
| **FAA 401API key 不對 / 過期 / rotate 未同步)** | `faa_auth_failed` | 502 | `conversion.error.faa_down` | 檔案存取服務暫時無法使用(內部 log 區分 auth_failed vs 5xx| | **converter 410 `result_expired`**v0.6 新增job completed 但 NEF 已被 converter MinIO GC、超 7 天 expires_at| `result_expired` | 410 | `conversion.error.result_expired` | 轉檔結果已過期,請重新轉檔 |
| job 不屬於當前 user | `forbidden` | 403 | `conversion.error.forbidden` | 你無權存取此轉檔任務 | | job 不屬於當前 uservisionA 端 ownership 檢查)| `forbidden` | 403 | `conversion.error.forbidden` | 你無權存取此轉檔任務 |
| job_id 不存在 | `not_found` | 404 | `conversion.error.not_found` | 轉檔任務不存在 | | job_id 不存在visionA ownership store | `not_found` | 404 | `conversion.error.not_found` | 轉檔任務不存在 |
| job 還沒 completed | `job_not_completed` | 409 | `conversion.error.not_completed` | 任務尚未完成,請等轉檔完成再下載 | | job 還沒 completed | `job_not_completed` | 409 | `conversion.error.not_completed` | 任務尚未完成,請等轉檔完成再下載 |
**Phase 0.8b 移除的錯誤碼**(與 MC token 相關,認證路徑取消後不會發生 **v0.6 變更摘要**(相對於 v0.5
| 已移除 | 原來語意 | Phase 0.8b 替代 | | code | v0.5 狀態 | v0.6 狀態 | 說明 |
|------|----------|-----| |------|---------|---------|------|
| `idp_misconfigured`500 | MC token endpoint 回 4xxscope 沒註冊 / client 設錯)| — | | `converter_auth_failed` | 維持 | **維持** | converter API key 仍使用init / poll / promote / GetResult 共用同一把)|
| `idp_unavailable`503 | MC token endpoint 5xx | — | | `converter_unavailable` | 維持 | **維持** | converter 5xx / network |
| `download_token_failed`502 | MC delegated token 4xx | — | | `result_not_found` | — | **新增** | converter `GET /api/v1/jobs/{id}/result` 回 404 |
| `mc_token_unavailable`502 | MC 持續失敗 | — | | `result_expired` | — | **新增** | converter `GET /api/v1/jobs/{id}/result` 回 410job 過期)|
| `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完全不同層次 - **converter 401**API key 不對齊)→ `converter_auth_failed`,內部 log 標 reason 給 SRE
- visionA 從 `converter_client` / `faa_client` 收到 401 → log error含 request_id方便 SRE 排查)→ 回 frontend `502 converter_auth_failed` / `faa_auth_failed`,不要對 frontend 暴露「API key 不對」這個內部細節 - **converter 404**job_id 不存在 / 已被 GC`result_not_found`frontend 顯示「轉檔任務不存在」
- 401 不 retry同 §9— rotate 流程不同步是運維事件,需人工介入 - **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.<short-name>`,前端 i18n 字典在 `visionA-frontend/messages/{zh-TW,en}.json` 補。 i18n key 命名:`conversion.error.<short-name>`,前端 i18n 字典在 `visionA-frontend/messages/{zh-TW,en}.json` 補。
@ -883,20 +1070,25 @@ frontend 用 `<a href>` 觸發時,若失敗 browser 會把錯誤頁顯示在
### 9.1 retry 規則 ### 9.1 retry 規則
> **Phase 0.8b 變更**:移除 MC 兩 row下游 401/403API 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 | 退避 | | 操作 | 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 `GET /jobs/{id}` | **不重試** | 透傳 | retry | retry | 3 | 0.5s, 1s, 2s |
| Converter `POST /jobs/{id}/cancel`(內部 cleanupPhase 1+| 不重試 | 不重試 | 不重試 | 不重試 | 0 | best-effort失敗只 log**Phase 0.8 converter 未實作此 endpoint靠 socket close 兜底**§5.3.2| | Converter `POST /jobs/{id}/cancel`(內部 cleanupPhase 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 `GET /jobs?user_id=&status=in_progress`lazy rebuild| **不重試** | 透傳 | retry | retry | 1 | 0.5s |
| Converter `POST /promote` | **不重試** | 透傳 | retry | retry | 2 | 1s, 2s | | 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_failed404 → result_not_found410 → result_expired| 透傳 | retry | retry | 2 | 1s, 2s |
每次 retry 之間檢查 `ctx.Done()`ctx cancel → 立即 return ctx.Err()。 每次 retry 之間檢查 `ctx.Done()`ctx cancel → 立即 return ctx.Err()。
**401 不重試的理由**API key 是 long-lived secretrotate 同步是運維事件、不是瞬時抖動。401 通常意味「visionA env 與下游 env 不同步」retry 100 次也不會自己變對,反而浪費 latency 並掩蓋運維事件。直接回 502 `*_auth_failed` 讓 SRE 看到。 **401 / 403 不重試的理由**
- **converter 401**API key 是 long-lived secretrotate 同步是運維事件、不是瞬時抖動。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 ### 9.2 graceful degradation
@ -905,10 +1097,12 @@ frontend 用 `<a href>` 觸發時,若失敗 browser 會把錯誤頁顯示在
| converter 完全不可達(持續 5xx | `502 converter_unavailable`UI 提示「轉檔服務暫時無法使用,請稍後再試」 | | converter 完全不可達(持續 5xx | `502 converter_unavailable`UI 提示「轉檔服務暫時無法使用,請稍後再試」 |
| converter 回 401API key 不同步)| `502 converter_auth_failed`UI 同上文字SRE 從 log 看到 auth_failed 計數異常 → 檢查 env | | converter 回 401API 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 | | 完成後 promote 失敗converter 5xx | job 留在 completed 狀態FAA 上沒檔但 visionA 知道UI 給 user 「重試 promote」按鈕重打 promote-to-models / download |
| FAA pull 失敗 — 加到模型庫流程5xx| model record 不寫入UI 提示重試 | | converter `GET /jobs/{id}/result` 回 404 `result_not_found`v0.6 新增)| `404 result_not_found`UI 顯示「轉檔任務不存在」 |
| FAA pull 失敗 — 下載流程5xx| visionA backend 中轉時 503 / 502UI 提示重試Phase 0.8b 兩條 download path 都共用 visionA → FAA pull| | converter `GET /jobs/{id}/result` 回 410 `result_expired`v0.6 新增)| `410 result_expired`UI 顯示「轉檔結果已過期,請重新轉檔」並提供重新轉檔 CTA |
| FAA 回 401API key 不同步)| `502 faa_auth_failed`UI 文字「檔案存取服務暫時無法使用」SRE 從 log 排查 | | converter `GET /jobs/{id}/result` 5xxconverter 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.1rebuild 不到的 job 由 converter 7 天 expire 自然兜底 | | visionA-backend 重啟 | in-memory ownership 與 promoted_key cache 全失frontend 進 /conversion 時 `/active` lazy rebuild§2.6.1rebuild 不到的 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 衝突 ### 9.3 同 user active job 衝突
@ -932,46 +1126,51 @@ frontend 用 `<a href>` 觸發時,若失敗 browser 會把錯誤頁顯示在
詳見 §7。任何繞過此原則的設計都必須先過 ADR review。 詳見 §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 tokendownload 走 server-side stream proxy。原段落5 分鐘 TTL、`VISIONA_FAA_DELEGATED_TTL_SECONDS` env刪除。 > v0.4 移除API key 模式下無 delegated tokenv0.5 撤回 v0.4 並回收FAA 線回到 MC delegated download token**v0.6 再次整段撤回**——對 MC source 驗證後確認 MC 沒有 issue delegated token 的 endpointv0.5 設計是 fictional。visionA 端 v0.6 起完全沒有 delegated token 概念。
>
> Phase 1+ 若量大改 ADR-015 §7 選項 BvisionA 自簽 HMAC token那時再回設 TTL 規格。
### 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 注入 - 部署用 AWS Secrets Manager / k8s Secret 注入
- log 永遠不印 key 全文;可印 `api_key_set=true` 或前 8 字元 prefixdebug 用) - log 永遠不印 key 全文;可印 `api_key_set=true` 或前 8 字元 prefixdebug 用)
- 若 key 洩漏:產新 key → 雙方同步 env → restart visionA / converter或 FAA→ 驗證 → 拔舊 keyrunbook Phase 0.9 補) - 若 key 洩漏:產新 key → visionA + converter 同步 env → restart → 驗證 → 拔舊 keyrunbook Phase 0.9 補)
- 已洩漏的 stage service client secret `RciRUyi...` 改 API key 後直接作廢,無 rotate 動作 - 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.8bvisionA-backend 透過 server-side stream proxy 把 NEF stream 中轉回 browser**FAA URL / object_key 都不出現在任何 frontend response** > 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 的 OIDCpublic PKCE是另一條完全獨立的鏈、不在本節範圍詳見 `oidc-tdd.md`)。
- frontend JS 對 object_key / 內部 FAA 路徑完全沒有 reference
### 10.5 Object key 不暴露給 frontend JS
- Phase 0.8b v0.6visionA-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 - 防快取handler 設 `Cache-Control: no-store, no-cache, must-revalidate`,避免 browser cache NEF stream
- **不需 FAA CORS**visionA → FAA 是 server-side 同進程內 outbound HTTP call不適用 CORSCORS 只管 browser JS fetch / XHR - **不需 CORSconverter 端或 FAA 端)**visionA → converter 是 server-side 同進程內 outbound HTTP call不適用 CORSCORS 只管 browser JS fetch / XHRbrowser 完全不知道 converter / FAA 的存在
- visionA backend 是 attack surface任何能拿到 visionA cookie session 的 attacker 都能下載自己 user_id 的 NEF — 但這本來就是 user 自己的檔,無 escalation - 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.8302 + delegated token| Phase 0.8bserver-side proxy| | 面向 | Phase 0.8302 + delegated token| v0.4server-side proxy + visionA API key| v0.5server-side proxy + delegated token**fictional 從未跑通**| **v0.6server-side proxy + visionA → converter API key**|
|------|----|----| |------|----|----|---|---|
| Token 結構是否存在 | 是MC issue5 分鐘 TTL| 否 | | Token 結構是否存在於 visionA ↔ 下游鏈 | 是MC issue5 分鐘 TTL過 browser| 是visionA API keylong-livedserver-side only| 是MC issue但實際 issue 不到)| 是visionA API keylong-livedserver-side only|
| 攻擊者攔截 visionA → browser response 拿到 token | 短期可用 5 分鐘 | 結構性無 token | | 攻擊者攔截 visionA → browser response 拿到 token | 短期可用 5 分鐘 | 結構性無 token | — | 結構性無 tokenAPI key 不過 browser|
| Frontend XSS 影響範圍 | 短 TTL token | 無 token 可竊 | | Frontend XSS 影響範圍 | 短 TTL token | 無 token 可竊 | — | 無 token 可竊 |
| Server compromisevisionA backend 被攻破)| 攻擊者可簽任意 MC delegated token | 攻擊者拿到 API key 後可任意打 converter / FAA | | Server compromisevisionA backend 被攻破)| 攻擊者可簽任意 MC delegated token限於 service client 的 scope| 攻擊者拿到 visionA API key 後可任意打 FAA 所有 endpoint | — | 攻擊者拿到 `VISIONA_CONVERTER_API_KEY` 後可任意打 converter 所有 endpoint但 converter **不存其他 user / 其他產品線資料**(只存進行中 / 完成的轉檔 job、7 天 GCblast radius 比 v0.4 直接打 FAA 小 |
| Defense in depth | Token TTL + scope 限制 | API key + visionA OIDC 上游 user auth | | MC 是否為依賴 | 是issue token| 否 | 是issue service token + delegated token| 否visionA 端 server-to-server 不依賴 MCuser login 仍依賴 MC OIDC但與本表無關|
| 結論 | 兩者都安全可接受Phase 0.8b 取捨「實作簡化 + bottleneck」換「無 token in browser 的更乾淨模型」| | FAA 是否為依賴 | 是(直接打)| 是(直接打)| 是(直接打)| **否**visionA 端只打 converterconverter → 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 TTLv0.4 移除 token 結構但 API key long-livedv0.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 - 同 user 同時兩 tab init → 第一個成功寫 ownership / converter 接受;第二個 pre-check 通過但 converter 409
- 兩 tab 同時 promote-to-models → 第一個寫 model record 成功;第二個重複呼叫 ensurePromotedcache hitFAA pull 兩次接受的取捨FAA 端冪等)→ models repo 寫入時可能撞 model_id 衝突 — 改用 model_id 在 finalize 前 SELECT 檢查 - 兩 tab 同時 promote-to-models → 第一個寫 model record 成功;第二個重複呼叫 ensurePromotedcache hit**converter.GetResult 拉 NEF 兩次**接受的取捨converter MinIO 端冪等讀)→ 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 同時 download → visionA backend 各自獨立 converter.GetResult無 cache兩條 stream 同時跑、兩條都成功converter MinIO 端冪等讀)— Phase 0.8b 可接受,量大時再加 server-side stream cache 或方向 Aconverter 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 - 同 user 1 個 active job 的限制本身就是 DoS 防護user 不能 init 1000 個 job
- visionA-backend conversion endpoint 不額外 rate limitPhase 1 補;對齊 `security.md` §4 - visionA-backend conversion endpoint 不額外 rate limitPhase 1 補;對齊 `security.md` §4
@ -983,26 +1182,73 @@ frontend 用 `<a href>` 觸發時,若失敗 browser 會把錯誤頁顯示在
實作此 spec 會動到的檔案(給 Backend Agent 參考Backend 自己拆任務): 實作此 spec 會動到的檔案(給 Backend Agent 參考Backend 自己拆任務):
**Phase 0.8b 變更(在 Phase 0.8 已上的 code 上面動** **Phase 0.8b v0.6 變更(相對於 v0.5 規劃但未上的 codebackend agent 下次任務範圍**
- 砍:`visionA-backend/internal/conversion/mc_token_client.go`~440 行整檔刪除) > 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` methodconfig.go / .env 撤回 v0.5 規劃要加回的 OIDC ServiceClient* / TenantID / FAABaseURL。
- 砍:`visionA-backend/internal/conversion/mc_token_client_test.go`(對應 test >
- 改:`visionA-backend/internal/conversion/converter_client.go` — 移除 `tokens *MCTokenClient` 欄位,改 `apiKey string`;每個 method 內 `Authorization: Bearer <apiKey>` 直接 set > 同時:**converter 跨 repo 加新 endpoint**jimchen 自己處理)。
- 改:`visionA-backend/internal/conversion/faa_client.go` — 同上模式
- 改:`visionA-backend/internal/conversion/flow.go` — 移除 `tokens` 欄位download path 從 `DownloadRedirectURL` 改為 `DownloadStream`(從 FAA pull stream 回給 caller **v0.6 跨 repoconverter schedulerjimchen 在 `/Users/jimchen/kneron_model_converter/apps/task-scheduler/`**
- 改:`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 - 新增 `src/routes/v1/result.js`(或加進 `jobs.js``GET /api/v1/jobs/:id/result` handler套用既有 `requireReadAuth` middlewareAPI 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 backendv0.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` methodv0.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 <ConverterAPIKey>`,解析 response Content-Length / Content-Disposition / body streamerror 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` - 改:`visionA-backend/internal/config/config.go`
- `ConversionConfig`:新增 `ConverterAPIKey` / `FAAAPIKey` 兩欄位 - `ConversionConfig.FAAAPIKey` 維持移除v0.5 撤回過、v0.6 維持)
- `ConversionConfig.Enabled()` 加入兩個 API key 非空檢查 - **新增移除**`ConversionConfig.FAABaseURL`v0.5 規劃要保留 / v0.6 移除)
- `OIDCConfig.ServiceClientID` / `ServiceClientSecret`conversion 不再依賴;如其他模組未使用即從 struct 移除(檢查 grep - `ConversionConfig.TenantID` 維持移除v0.5 規劃要加回 / v0.6 撤回)
- `ConversionConfig.TenantID`conversion 不再依賴;如其他模組未使用即移除 - `OIDCConfig.ServiceClientID` / `ServiceClientSecret` 維持移除v0.5 規劃要加回 / v0.6 撤回)
- 改:`visionA-backend/cmd/api-server/main.go` — wire conversion.Flow 時不再傳 MCTokenClient改傳兩個 API key - `ConversionConfig.Enabled()` 簡化:只判 `ConverterBaseURL != "" && ConverterAPIKey != ""`
- 改:`.env.stage.example` / `.env.dev.example` — 移除 `VISIONA_OIDC_SERVICE_CLIENT_ID` / `_SECRET` / `VISIONA_OIDC_TENANT_ID`;新增 `VISIONA_CONVERTER_API_KEY` / `VISIONA_FAA_API_KEY` - 改:`visionA-backend/cmd/api-server/main.go` — wire `conversion.Flow` 時不傳 `MCTokenClient`、不傳 `FAAClient`、不傳 FAA / Tenant config
- 改:對應的 unit test / integration test — 移除 MC mock改用 fake converter / FAA server`Authorization: Bearer <apiKey>` header 正確帶上 - 改:`.env.stage.example` / `.env.dev.example`
- 不動:`internal/model/*`schema 不變) - 維持移除 `VISIONA_FAA_API_KEY`v0.4 加 / v0.5 撤回 / v0.6 維持撤回)
- 不動:`internal/api/models.go`(既有 init/finalize 不動flow.PromoteToModels 內部呼叫 helper - 維持移除 `VISIONA_OIDC_SERVICE_CLIENT_ID` / `_SECRET` / `VISIONA_OIDC_TENANT_ID`
- 不動OIDC user login 相關全部(`internal/oidc/``internal/usersession/``/api/auth/*` handlers - **新增移除**`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 <ConverterAPIKey>`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 / .envconverter 端跨 repo 加新 endpoint 由 jimchen 自行處理)。
--- ---
@ -1015,3 +1261,5 @@ frontend 用 `<a href>` 觸發時,若失敗 browser 會把錯誤頁顯示在
| 2026-04-30 | 0.3 | Phase 0.8 三方交叉審閱回饋整合§2.6.1 補「visionA-backend 重啟後 lazy rebuild ownership」議題 #2A4 方案§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-04-30 | 0.3 | Phase 0.8 三方交叉審閱回饋整合§2.6.1 補「visionA-backend 重啟後 lazy rebuild ownership」議題 #2A4 方案§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 proxyService 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-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 proxyService 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 構造(規則 `<source_filename_stem>_<target_chip_lower>.nef`),對齊 wireframe success card 顯示範例」、並補充「FAA 端的 object_key 是 `models/<user>/<job>.nef` 對 user 不友善」的對比說明。純文字釐清、無實作行為變更。 | | 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 構造(規則 `<source_filename_stem>_<target_chip_lower>.nef`),對齊 wireframe success card 顯示範例」、並補充「FAA 端的 object_key 是 `models/<user>/<job>.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 tokenvisionA → 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 → converterAPI key+ §3.2 visionA → FAAservice 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 → converterAPI keypromote 仍走 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 完全不動。 |

View File

@ -2,17 +2,18 @@
## Metadata ## Metadata
- **作者**Architect Agent - **作者**Architect Agent
- **狀態**Phase 0.8b 修訂service client / server-to-server 改 API keyuser login 部分不變) - **狀態**Phase 0.8b v0.4 修訂converter 線改 API keyFAA 線**完全撤回**改走 ADR-016 converter 中轉user login 部分不變)
- **最後更新**2026-05-11 - **最後更新**2026-05-16
- **文件角色**Phase 0.6 把 visionA-backend 的 `StaticAuthProvider` 替換為 OIDC 接 Innovedus Member Center - **文件角色**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 - **下位文件**`adr/adr-010-oidc-bff.md`(本文件 §16
- **讀者**Backend / Frontend / DevOps / Testing Agents - **讀者**Backend / Frontend / DevOps / Testing Agents
> **Phase 0.8b 範圍說明(重要)** > **Phase 0.8b v0.4 範圍說明(重要)**
> >
> - **user loginbrowser → visionA backend**:完全不變。仍走 PKCE-only public client、ADR-013 描述的 redirect flow、cookie session、JWKS 驗 id_token本文件 §1-§12、§14-§17 全部仍有效。 > - **user loginbrowser → visionA backend**:完全不變。仍走 PKCE-only public client、ADR-013 描述的 redirect flow、cookie session、JWKS 驗 id_token本文件 §1-§12、§14-§17 全部仍有效。
> - **server-to-servervisionA backend → converter / FAA**Phase 0.8b 改用 pre-shared API key 取代原本的 OAuth `client_credentials` grant。詳見 ADR-015本文件 §13.1 「Service Client 預留欄位」段落隨之更新(**改為標示廢棄、不再啟用**)。 > - **server-to-servervisionA backend → converter**v1.0 改用 pre-shared API key 取代 OAuth `client_credentials` grantv2.0 / v0.4 維持。詳見 ADR-015 §1。
> - **server-to-servervisionA 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 modeADR-013| | `VISIONA_OIDC_CLIENT_SECRET` | — | **選填**A1 改造後)| 為空時走 **public PKCE-only mode**;非空時走 confidential modeADR-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_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_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 keyADR-015取代env 從 `.env.stage.example` 移除 | | ~~`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 廢棄** | 同上廢棄stage 上已洩漏的 secret 直接作廢、不 rotate | | ~~`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_CONVERTER_API_KEY` | — | **Phase 0.8b 新增** | visionA → converter 服務間認證 pre-shared API key64 字元 hex詳見 ADR-015 與 `conversion.md` §3 | | ~~`VISIONA_OIDC_TENANT_ID`~~ | — | **Phase 0.8b v0.4 再次廢棄**(同上)| ~~MC service client 註冊時對應的 tenant~~。visionA 端不再需要 tenant_id claimFAA 端的 tenant 驗由 converter 自己管,與 visionA 無關)|
| `VISIONA_FAA_API_KEY` | — | **Phase 0.8b 新增** | visionA → FAA 服務間認證 pre-shared API key64 字元 hex詳見 ADR-015 與 `conversion.md` §3 | | `VISIONA_CONVERTER_API_KEY` | — | **Phase 0.8b v1.0 新增v2.0 / v0.4 維持** | visionA → converter 服務間認證 pre-shared API key64 字元 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_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_SECRET` | — | ✅ | 至少 32 byte 隨機字串HMAC cookie 簽章。產法:`openssl rand -hex 32` |
| `VISIONA_SESSION_COOKIE_NAME` | `visiona_session` | — | — | | `VISIONA_SESSION_COOKIE_NAME` | `visiona_session` | — | — |
@ -1484,7 +1487,7 @@ VISIONA_SESSION_COOKIE_SECURE=false
# Service client 不設Phase 0.7 不接 MC API # 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 ```bash
# .env.stage # .env.stage
@ -1497,18 +1500,27 @@ VISIONA_FRONTEND_URL=https://stage-9527.innovedus.com:9527
VISIONA_SESSION_SECRET=<openssl rand -hex 32 產的值stage host 持有不進 git> VISIONA_SESSION_SECRET=<openssl rand -hex 32 產的值stage host 持有不進 git>
VISIONA_SESSION_COOKIE_SECURE=true VISIONA_SESSION_COOKIE_SECURE=true
# === Phase 0.8b 服務間認證API key取代 OAuth service token=== # === Phase 0.8b v0.4 — visionA 端唯一一條 server-to-server 鏈visionA → converterAPI key===
# 同一把 key 用於 init / poll / promote / GET result endpoint
VISIONA_CONVERTER_BASE_URL=http://192.168.0.130:9501 VISIONA_CONVERTER_BASE_URL=http://192.168.0.130:9501
VISIONA_FAA_BASE_URL=http://192.168.0.130:5081
VISIONA_CONVERTER_API_KEY=<openssl rand -hex 32 產的值 converter CONVERTER_API_KEY 對齊> VISIONA_CONVERTER_API_KEY=<openssl rand -hex 32 產的值 converter CONVERTER_API_KEY 對齊>
VISIONA_FAA_API_KEY=<openssl rand -hex 32 產的值 FAA FAA_API_KEY 對齊>
# === Phase 0.8b 移除(不再使用)=== # === Phase 0.8b v0.4 撤回v2.0 規劃要設、v0.4 / ADR-016 撤回)===
# VISIONA_OIDC_SERVICE_CLIENT_ID=...已廢棄ADR-015 # visionA 端不再需要 MC service clientvisionA → MC server-to-server 路徑完全移除)
# VISIONA_OIDC_SERVICE_CLIENT_SECRET=...已廢棄stage 上已洩漏的值直接作廢、不 rotate # VISIONA_OIDC_SERVICE_CLIENT_ID=...
# VISIONA_OIDC_TENANT_ID=...conversion 不再依賴;其他模組未發現使用) # VISIONA_OIDC_SERVICE_CLIENT_SECRET=...
# VISIONA_OIDC_TENANT_ID=...
# visionA 端不再直接打 FAAdownload 走 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 環境(依 IT 配置):**
prod MC client 是 public 還是 confidential 由 Innovedus IT 在註冊 OAuth client 時決定。visionA-backend 兩者都支援env 設 / 不設 `VISIONA_OIDC_CLIENT_SECRET` 即可,**不需重 build**(同一份 binary 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 == ""` 判斷依據:`OIDCConfig.ClientSecret == ""`
這條 log 是排查「為什麼 token exchange 401」的第一步 — IdP 註冊的 client 類型必須與 visionA-backend 啟動時的 mode 對齊(兩端錯配會 401 unauthorized client 這條 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 驗 的鏈路 > **v0.4 摘要**visionA 端只剩一條 server-to-server 線):
> - **改用 pre-shared API key**:每個下游各自獨立的 64-hex API key`VISIONA_CONVERTER_API_KEY` / `VISIONA_FAA_API_KEY`
> - **header 格式不變**:仍是 `Authorization: Bearer <key>`,只是 token 來源從「MC 簽的 JWT」變成「visionA env 內的 pre-shared secret」
> - **converter / FAA 端 middleware 同步改寫**constant-time compare env 字串,不再驗 JWKS / scope / tenant
> >
> **為什麼把這個段落放在 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 <key>`
> - **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仍是 OIDCPKCE / cookie session / JWKS — 本文件 §1-§12 全部適用 > **visionA → FAA / MCv0.4 整段撤回)**
> - server-to-server不再是 OIDC、不再屬於本文件範圍 — 看 `conversion.md` §3 與 ADR-015 > - v1.x 改 API key 已撤回v2.0v2.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 回 visionAconverter → 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 designMC 沒有對應 endpoint整條鏈從未 e2e 跑通。v0.4 改採 ADR-016converter 加新 endpointvisionA + converter 都是 jimchen 自己維護的 repo、可單方控制不必動 MC / FAA / warrenchen。
>
> - **user login**:仍是 OIDCPKCE / 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 新增 ### 13.2 visionA-frontend 新增
@ -1845,3 +1868,5 @@ OF1 (與 OF2 平行)
|------|------|------| |------|------|------|
| 2026-04-26 | 0.1 | Architect Agent 初稿Phase 0.6 OIDC 接入 TDD 增補) | | 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-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 clientconverter 線維持 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 → converterAPI 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 URLv0.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 的設計完全不受影響。 |