Auth pillar 從 OAuth 2.0 resource server 改成 pre-shared API key (visionA ↔ converter 1:1 internal trust)。新增 GET /api/v1/jobs/:id/result streaming endpoint 給 visionA backend 中轉 NEF 下載。 Phase A(auth 切換): - 新增 apiKeyMiddleware(constant-time compare、tokenFingerprint、4 audit events) - 砍 OAuth middleware + JWKS(保留 oauthClient 供 promote → FAA 使用) - 4 個 endpoint 換掛 requireApiKey - 加 TRUST_PROXY env + Express trust proxy 設定(forensic source_ip) Phase B(/result endpoint): - streaming NEF download with 5min timeout + concurrent cap 10 - Two-tier rate limit(burst 5/10s + sustained 20/min) - Bandwidth quota(1 GB/hr + 6 GB/24hr)by token_fingerprint - Range header silently ignored + Accept-Ranges: none - filename quote-escape + RFC 5987 fallback + sanitize - 8 個 /result audit events(forensic 完整) 設計演進記錄:docs/TODO-visionA-integration-v2.md(5/2 OAuth → 5/16 API key → 5/16 download via converter;對應 visionA repo ADR-015/016) Tests: 597 → 666 (+69)、29 suites all pass Security: APPROVE WITH CONDITIONS(單 instance 部署、6 新 env、24hr 監控) npm audit: 3 vuln → 0(transitive AWS SDK xml chain) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5.7 KiB
5.7 KiB
API: POST /api/v1/jobs/:id/promote
狀態:Phase 1 完工 — Phase 0.8b 完全保留,只是對外 auth 換成 API key(converter → FAA 仍走 OAuth client_credentials)。
配套:
auth.md§2(converter → FAA OAuth client 設計)。
1. 用途
把 Converter Bucket 中的轉檔結果檔(onnx / bie / nef)PUT 到 FAA NAS Bucket(長期儲存)。
2. Request
POST /api/v1/jobs/550e8400-.../promote
Authorization: Bearer <CONVERTER_API_KEY>
Content-Type: application/json
{
"targets": [
{
"source": "nef",
"target_object_key": "visionA/models/user-12345/model-1001/v0001/out.nef"
},
{
"source": "bie",
"target_object_key": "visionA/models/user-12345/model-1001/v0001/out.bie"
}
]
}
2.1 Body
| 欄位 | 類型 | 必填 | 說明 |
|---|---|---|---|
targets |
array | ✅ | 至少 1 個,最多 10 個 |
targets[].source |
string | ✅ | enum: onnx, bie, nef |
targets[].target_object_key |
string | ✅ | FAA 的目標 key(visionA 決定命名);長度 ≤ 1024、不可含 .. / \ / 控制字元 / 開頭 / / ? / # / % |
3. Response 200
{
"job_id": "550e8400-...",
"promoted": [
{
"source": "nef",
"target_object_key": "visionA/models/user-12345/model-1001/v0001/out.nef",
"size_bytes": 10485760,
"file_access_agent_etag": "abc123",
"promoted_at": "2026-05-16T12:30:00Z"
},
{
"source": "bie",
"target_object_key": "...",
"size_bytes": 5242880,
"file_access_agent_etag": "def456",
"promoted_at": "..."
}
]
}
4. Error Responses
| HTTP | error.code | 情境 |
|---|---|---|
| 400 | validation_error |
targets 格式錯、source 非合法 stage、duplicate source |
| 401 | invalid_token |
API key 缺 / 不符 |
| 404 | job_not_found |
job 不存在 |
| 409 | job_not_ready_for_promote |
status != COMPLETED(details.current_status) |
| 409 | source_not_available |
job 沒產這個 stage 的結果 |
| 422 | invalid_object_key |
target_object_key 格式不合法(含 reason) |
| 502 | file_gateway_unavailable |
FAA PUT 失敗(4xx / 5xx / timeout 已重試 3 次) |
| 502 | storage_unavailable |
MinIO HEAD / GET 失敗 |
| 503 | auth_service_unavailable |
取 FAA token 失敗(401 已 invalidate + retry 仍失敗) |
5. 冪等性
promote 對同樣 target_object_key PUT 兩次結果一樣(FAA 會覆蓋)。
Two-layer 冪等性(保留 Phase 1 實作):
- Job-level:
job.promoted === true→ 直接回 200 + 既有promoted_object_keys,不重打 FAA - FAA-level:FAA PUT 本身冪等,重試安全
6. 實作流程
1. requireApiKey() → 401
2. perClientLimiter → 429
3. validate body → 400 / 422
4. jobService.getJob(id) + client 隔離 → 404
5. 冪等性 check(job.promoted === true → return 200)
6. status === 'COMPLETED' check → 409
7. for each target (序列):
a. getJobOutputKey(job, target.source) → 409 source_not_available
b. minio.headObject(sourceKey) → 502 storage_unavailable
c. oauthClient.getServiceToken('files:upload.write') ← OAuth client(保留)
d. faaClient.putFile(targetKey, streamFactory, ...) → 502 / 503
e. 收集 promoted result
8. jobService.markPromoted(jobId, ...) → log ERROR if 失敗(但 client 仍回 200,因為檔案實際已搬完)
9. return 200 + { job_id, promoted: [...] }
7. 重要決策(保留 Phase 1)
7.1 序列 promote 各 target
為什麼序列:
- FAA 端對單一 client 並發可能有限制
- 失敗時容易判斷哪個 target 已成功
- 大檔串流並發會放大記憶體 / CPU 壓力
7.2 Stream factory pattern
faaClient.putFile 接受 streamFactory: () => Promise<Stream>,每次 attempt 才呼叫 minio.getObjectStream 拿新 stream。
為什麼:HTTP body 不可 replay;attempt #1 5xx 失敗,attempt #2 必須拿新 stream。
7.3 Target_object_key 安全檢查
拒絕:
- 空字串、超長(> 1024)
- 開頭
/(避免被 FAA 解讀為絕對路徑) - 含
..(路徑穿越) - 含
\(Windows 路徑 / URL 注入) - 含
\0/ 控制字元(\x00-\x1F、\x7F) - 含
?/#(URL query / fragment 注入) - 含
%(雙重編碼攻擊,避免%2E%2E解碼為..)
7.4 FAA 錯誤分類
| FAA 錯誤 | 轉換成 v1 ApiError |
|---|---|
FAAUnauthorizedError(已 retry 仍 401) |
503 auth_service_unavailable |
FAAClientError(4xx 非 401) |
502 file_gateway_unavailable(拒絕細節,避免洩漏 FAA 內部訊息) |
FAAServerError(5xx)/ FAATimeoutError |
502 file_gateway_unavailable |
| 其他 | 500 internal_error |
7.5 FAA 重試策略
- 4xx 非 401:不重試(client error,重試無益)
- 401:
oauthClient.invalidate(scope)+ retry 1 次;仍 401 → 503 - 5xx / timeout / network:重試 2 次(exponential backoff 500ms / 2000ms);全失敗 → 502
7.6 markPromoted 失敗的處理
FAA 已成功(檔案在 NAS 上)但 Redis markPromoted 失敗:
- Log ERROR
- 仍回 200 給 client(檔案實際已搬完)
- 下次 promote 同 job 時
markPromoted會再嘗試(FAA PUT 冪等) - 副作用:client 後續呼叫不會走 idempotent path、會再 PUT 一次(無害)
8. Curl 範例
curl -X POST https://converter.innovedus.com/api/v1/jobs/550e8400-.../promote \
-H "Authorization: Bearer $CONVERTER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"targets": [
{"source": "nef", "target_object_key": "visionA/models/u-12345/m-1001/v0001/out.nef"}
]
}'