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>
714 lines
27 KiB
Markdown
714 lines
27 KiB
Markdown
# visionA Cloud 整合 — converter scheduler 交接檔 v2
|
||
|
||
> **時間**:2026-05-16
|
||
> **背景**:visionA Cloud Phase 0.8b — 從 OAuth client_credentials 改 API key + 重設計 download 路徑
|
||
> **替代**:本檔取代 `docs/TODO-visionA-integration.md`(5/2 寫的、ADR-014 設計 — 已過時)
|
||
> **對應**:visionA repo 的 `docs/autoflow/04-architecture/adr/adr-015-server-to-server-api-key.md` v2.1 + `adr-016-download-via-converter.md` v1.0
|
||
|
||
---
|
||
|
||
## 1. 為什麼會有這份交接檔
|
||
|
||
### 5/2 原本的設計(ADR-014、現已 supersede)
|
||
|
||
visionA → converter / FAA 走 **OAuth `client_credentials`** + MC(Member Center)service token + scope (`converter:job.read/write` / `files:download.delegate`)。
|
||
|
||
### 5/9 stage e2e 撞 4 個 blocker → ADR-015 改 API key
|
||
|
||
| # | Blocker |
|
||
|---|---------|
|
||
| 1 | MC stage 沒註冊 `converter:job.read/write` 兩個 scope |
|
||
| 2 | converter image 5 週前舊版、沒 OAuth middleware、沒 `/api/v1/jobs` endpoint |
|
||
| 3 | converter 缺 `MEMBER_CENTER_*` env |
|
||
| 4 | FAA 端 OAuth 整合狀態不確定(warrenchen 維護) |
|
||
|
||
**使用者拍板**:1:1 internal trust(visionA ↔ converter 是 1 對 1)用 OAuth 過度設計、改 pre-shared API key。
|
||
|
||
### 5/16 grep MC + FAA source 發現 ADR-014 §2 設計缺口 → ADR-016 改 download 路徑
|
||
|
||
```bash
|
||
grep -rn "delegated\|DownloadToken" member_center/src --include="*.cs"
|
||
# 0 命中 — MC source 沒有 issue / validate delegated download token endpoint
|
||
```
|
||
|
||
MC 端**從來就沒有實作** ADR-014 §2 假設的「issue delegated download token」+「validate delegated download token」兩個 endpoint。FAA 的 `MemberCenterDelegatedDownloadTokenValidator.cs` 假設 MC 有 `_options.DownloadTokenValidationPath` introspection endpoint,**也假設錯了**。
|
||
|
||
→ **delegated token 鏈從 5/2 寫完到現在一直是斷的**,只是因為從未實際 e2e 跑通過所以沒人發現。
|
||
|
||
**使用者拍板**:不動 MC、不動 FAA。改設計成 visionA → converter `GET /api/v1/jobs/{id}/result` 中轉(ADR-016)。
|
||
|
||
---
|
||
|
||
## 2. converter scheduler 需要做的 2 件事
|
||
|
||
| # | 範圍 | 動 code 多少 | 風險 |
|
||
|---|------|-------------|------|
|
||
| **任務 A** | 加 API key middleware(取代 OAuth JWT 驗證、或並存)| ~50-100 行 | 低(現有 `requireAuth` pattern 可借鑑)|
|
||
| **任務 B** | 加 `GET /api/v1/jobs/:id/result` endpoint | ~80 行 + test | 低(`getObjectStream` 已存在、Phase 2 預留位已有 routing 慣例)|
|
||
|
||
兩個都在 `apps/task-scheduler/src/` 內、單一 repo。
|
||
|
||
---
|
||
|
||
## 3. 任務 A — API key middleware
|
||
|
||
### 3.1 設計取捨:API key only vs API key + OAuth 並存
|
||
|
||
**推薦:並存模式**(最少 breaking change)
|
||
|
||
| 設計 | 動 code | 影響 | 推薦? |
|
||
|------|---------|------|-------|
|
||
| **A. 純 API key**(砍 OAuth)| 砍 `src/auth/middleware.js` + `src/auth/jwks.js` + `src/auth/oauthClient.js` | 既有 OAuth caller 全部要 migrate(如有)| ❌ 風險高 |
|
||
| **B. 並存**(OAuth + API key 二選一)| 新增 `src/auth/apiKeyMiddleware.js` + 改 `routes/v1/index.js` wire | 既有 OAuth 路徑完全不動、API key 是額外 path | ✅ **推薦** |
|
||
| **C. 純 API key + 保留 OAuth helper code**(不 wire)| 既有 `auth/` 留著但不啟用 | 模糊、未來容易誤啟用 | ⚠️ 不推薦 |
|
||
|
||
採 **B 並存**,理由:
|
||
1. visionA 是 1 個 caller、走 API key
|
||
2. 其他既有 caller(如 jimchen 手動測試、CI、未來其他產品線)仍可用 OAuth
|
||
3. converter 不需要強迫所有 caller 一次 migrate
|
||
4. 如果未來 100% caller 都用 API key、再砍 OAuth path
|
||
|
||
### 3.2 實作(新增 `src/auth/apiKeyMiddleware.js`)
|
||
|
||
對齊既有 `requireAuth(scope)` API surface,讓 routes/v1/jobs.js 可以無痛換掛。
|
||
|
||
```javascript
|
||
// src/auth/apiKeyMiddleware.js
|
||
//
|
||
// API key middleware — Phase 0.8b 為 visionA 整合新增。
|
||
//
|
||
// 設計:
|
||
// - 接受 Authorization: Bearer <pre-shared-API-key>
|
||
// - 用 crypto.timingSafeEqual constant-time compare(避免 timing attack)
|
||
// - 不驗 scope / tenant — 1:1 internal trust,API key 就是「caller 是 visionA」的完整證明
|
||
// - 對齊既有 sendAuthError 模式(含 destroy socket M2 行為)
|
||
//
|
||
// 對應 visionA repo 的:
|
||
// - ADR-015 v2.1 §1 visionA → converter
|
||
// - ADR-015 v2.1 §3.5.1 reference middleware implementation (Go)
|
||
// - 本檔是 Node.js port
|
||
//
|
||
// 如何接:
|
||
// const { requireAuth } = require('./middleware'); // 既有 OAuth
|
||
// const { requireApiKey } = require('./apiKeyMiddleware'); // 新 API key
|
||
// const auth = requireApiKey() || requireAuth('converter:job.write'); // 不能直接這樣寫,看 §3.3
|
||
//
|
||
// 看 §3.3「並存策略」實際 wire 方式。
|
||
|
||
'use strict';
|
||
|
||
const crypto = require('crypto');
|
||
|
||
/**
|
||
* 解析 Bearer header(複用 ./middleware.js 內部 helper 邏輯)。
|
||
*/
|
||
function extractBearerToken(headerValue) {
|
||
if (typeof headerValue !== 'string' || headerValue.length === 0) return null;
|
||
const match = headerValue.match(/^Bearer\s+(.+)$/i);
|
||
if (!match) return null;
|
||
const token = match[1].trim();
|
||
return token === '' ? null : token;
|
||
}
|
||
|
||
/**
|
||
* sendApiKeyError — 對齊既有 ./middleware.js sendAuthError 的 destroy socket M2 行為。
|
||
*
|
||
* 不直接 require ./middleware._internals.sendAuthError 是因為它含 request_id 邏輯、
|
||
* 跟 API key 不同 context;inline 一個簡化版避免循環依賴。
|
||
*/
|
||
function sendApiKeyError(req, res, status, code, message) {
|
||
if (res.headersSent) {
|
||
try { if (req.socket && !req.socket.destroyed) req.socket.destroy(); } catch (_) {}
|
||
return;
|
||
}
|
||
res.setHeader('Connection', 'close');
|
||
res.status(status).json({
|
||
error: {
|
||
code,
|
||
message,
|
||
request_id: req.requestId || null,
|
||
},
|
||
});
|
||
res.once('finish', () => {
|
||
try { if (req.socket && !req.socket.destroyed) req.socket.destroy(); } catch (_) {}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Constant-time string compare。
|
||
*
|
||
* 重要:
|
||
* - 必須長度先比(避免 timingSafeEqual 在長度不同時 throw)
|
||
* - 長度不算 secret(公開資訊)
|
||
*/
|
||
function constantTimeEquals(a, b) {
|
||
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
||
const bufA = Buffer.from(a, 'utf8');
|
||
const bufB = Buffer.from(b, 'utf8');
|
||
if (bufA.length !== bufB.length) return false;
|
||
return crypto.timingSafeEqual(bufA, bufB);
|
||
}
|
||
|
||
/**
|
||
* 建立一個 requireApiKey middleware。
|
||
*
|
||
* @param {object} [deps] — 測試注入
|
||
* @param {string} [deps.expectedApiKey] — 明文 API key;不傳則 lazy load from config
|
||
* @returns {import('express').RequestHandler}
|
||
*/
|
||
function requireApiKey(deps = {}) {
|
||
let expected = deps.expectedApiKey;
|
||
|
||
return function apiKeyMiddleware(req, res, next) {
|
||
try {
|
||
if (!expected) {
|
||
// Lazy-load config(對齊 ./middleware.js pattern)
|
||
const config = require('../config').loadConfig();
|
||
expected = config.converter.apiKey; // §3.4 加入 config 後可讀
|
||
}
|
||
|
||
// Fail-fast: API key 未設定就拒絕所有 request(不要 silently allow)
|
||
if (!expected || expected === '') {
|
||
// eslint-disable-next-line no-console
|
||
console.error(JSON.stringify({
|
||
level: 'ERROR',
|
||
action: 'auth.api_key.not_configured',
|
||
message: 'CONVERTER_API_KEY env not set; rejecting all requests',
|
||
timestamp: new Date().toISOString(),
|
||
}));
|
||
return sendApiKeyError(req, res, 503, 'service_unavailable', 'API key not configured');
|
||
}
|
||
|
||
const token = extractBearerToken(req.headers && req.headers.authorization);
|
||
if (!token) {
|
||
return sendApiKeyError(req, res, 401, 'invalid_token',
|
||
'缺少或格式錯誤的 Authorization header(需為 Bearer <token>)');
|
||
}
|
||
|
||
if (!constantTimeEquals(token, expected)) {
|
||
return sendApiKeyError(req, res, 401, 'invalid_token', 'API key 驗證失敗');
|
||
}
|
||
|
||
// 驗證成功 — 設 req.auth 給下游使用(對齊 OAuth middleware 的 req.auth shape)
|
||
req.auth = {
|
||
sub: 'visionA-service', // 固定值(API key 沒 sub)
|
||
clientId: 'visionA-service',
|
||
tenantId: null, // API key 不帶 tenant
|
||
scopes: ['converter:job.write', 'converter:job.read'], // implicit full access
|
||
raw: { authType: 'api_key' },
|
||
};
|
||
|
||
return next();
|
||
} catch (err) {
|
||
// eslint-disable-next-line no-console
|
||
console.error(JSON.stringify({
|
||
level: 'ERROR',
|
||
action: 'auth.api_key.unexpected_error',
|
||
message: err && err.message ? err.message : 'unknown',
|
||
timestamp: new Date().toISOString(),
|
||
}));
|
||
return sendApiKeyError(req, res, 401, 'invalid_token', 'API key 驗證失敗');
|
||
}
|
||
};
|
||
}
|
||
|
||
module.exports = {
|
||
requireApiKey,
|
||
_internals: { extractBearerToken, constantTimeEquals, sendApiKeyError },
|
||
};
|
||
```
|
||
|
||
### 3.3 並存策略 — 二選一 middleware
|
||
|
||
最簡單做法:寫一個 `requireApiKeyOrOAuth(oauthScope)` wrapper。
|
||
|
||
```javascript
|
||
// src/auth/middleware.js 末尾加:
|
||
|
||
const { requireApiKey } = require('./apiKeyMiddleware');
|
||
|
||
/**
|
||
* 並存 middleware — 先試 API key、不行 fallback OAuth。
|
||
*
|
||
* 用法(取代既有 routes/v1/jobs.js + promote.js 的 requireAuth(scope) 呼叫):
|
||
* const auth = require('./middleware');
|
||
* router.post('/jobs', auth.requireApiKeyOrOAuth('converter:job.write'), handler);
|
||
*
|
||
* 行為:
|
||
* 1. 沒帶 Authorization header → 401(API key middleware 處理)
|
||
* 2. 帶的 token 是 visionA 的 API key(constant-time match)→ API key path 過、req.auth 設好、next()
|
||
* 3. 帶的 token 不 match API key → 走 OAuth JWT 驗證(既有 requireAuth)
|
||
* - 過 → next()
|
||
* - 不過 → 401(OAuth middleware 處理)
|
||
*
|
||
* 為什麼 API key 優先:API key compare 快(constant-time string compare),JWT 驗證慢(JWKS fetch + verify)。
|
||
*/
|
||
function requireApiKeyOrOAuth(oauthScope) {
|
||
const apiKey = requireApiKey();
|
||
const oauth = requireAuth(oauthScope);
|
||
|
||
return function combinedAuth(req, res, next) {
|
||
// 先攔截 response — 如果 API key 過了 next(),就完成;如果 API key 寫了 401,我們 swap 成試 OAuth
|
||
const originalSetHeader = res.setHeader.bind(res);
|
||
const originalStatus = res.status.bind(res);
|
||
const originalJson = res.json.bind(res);
|
||
|
||
let apiKeyRejected = false;
|
||
let pendingResponse = null;
|
||
|
||
// 暫時 mock res 來看 API key middleware 的決定
|
||
const mockRes = {
|
||
setHeader: (...args) => { /* swallow */ },
|
||
status: (code) => ({ json: (body) => { apiKeyRejected = true; pendingResponse = { code, body }; return mockRes; } }),
|
||
headersSent: false,
|
||
once: () => {},
|
||
};
|
||
|
||
apiKey(req, mockRes, (err) => {
|
||
if (err) return next(err);
|
||
if (apiKeyRejected) {
|
||
// API key 失敗 → 試 OAuth(真的 res 給 OAuth 用)
|
||
return oauth(req, res, next);
|
||
}
|
||
// API key 過 → next() 已被 apiKey middleware 呼叫
|
||
return next();
|
||
});
|
||
};
|
||
}
|
||
```
|
||
|
||
**注意**:上面 `requireApiKeyOrOAuth` 用 mock res 攔截 API key 的 401 response、實作有點 hacky。**比較乾淨的做法**是兩個 middleware 都不直接 send response、而是 `next(err)` 給統一 error handler。但這需要改 `sendAuthError` 行為、變動範圍大。
|
||
|
||
**建議**:先用上面 hacky 版本 ship、後續 refactor 改成 `next(err)` 模式。
|
||
|
||
**或者**:更簡單——**直接砍 OAuth、只用 API key**(如果你確定沒其他 caller)。看下面 §3.5 決策樹。
|
||
|
||
### 3.4 Config 加 API key
|
||
|
||
```javascript
|
||
// src/config.js — 在 converter 段落內加:
|
||
|
||
// 在 schema / loadConfig 內:
|
||
const config = {
|
||
// ... 既有欄位
|
||
converter: {
|
||
// ... 既有 audience / scopeWrite / scopeRead / tenantId
|
||
apiKey: process.env.CONVERTER_API_KEY || '', // Phase 0.8b 新增
|
||
},
|
||
};
|
||
|
||
// 啟動時驗證(fail-fast):
|
||
function validateConfig(config) {
|
||
// 既有驗證...
|
||
// 不強制 apiKey 必填(保留 OAuth-only 部署)— 但 startup log 印明確訊息
|
||
if (!config.converter.apiKey) {
|
||
// eslint-disable-next-line no-console
|
||
console.warn(JSON.stringify({
|
||
level: 'WARN',
|
||
action: 'config.api_key_not_set',
|
||
message: 'CONVERTER_API_KEY env not set; API key middleware will reject all requests',
|
||
timestamp: new Date().toISOString(),
|
||
}));
|
||
} else {
|
||
console.log(JSON.stringify({
|
||
level: 'INFO',
|
||
action: 'config.api_key_enabled',
|
||
message: 'API key middleware enabled',
|
||
// 不印 key 本身(對齊 visionA 的 api_key_set boolean pattern)
|
||
api_key_length: config.converter.apiKey.length,
|
||
timestamp: new Date().toISOString(),
|
||
}));
|
||
}
|
||
}
|
||
```
|
||
|
||
### 3.5 決策樹:純 API key vs 並存
|
||
|
||
**先回答**:除了 visionA 之外,**現在 / 短期內**還有別的 caller 會打 converter `/api/v1/*` 嗎?
|
||
|
||
| 答案 | 推薦 |
|
||
|------|------|
|
||
| 沒有 / 不確定 | **純 API key**(砍 OAuth)— 簡單、code 少、不用維護兩條 path |
|
||
| 有 / 短期會有 | **並存** — 用 §3.3 的 `requireApiKeyOrOAuth` |
|
||
| 只有 jimchen 手動測試會用 | **純 API key** + 提供 API key 給自己用即可 |
|
||
|
||
**我(jimchen)建議**:**純 API key**。理由:
|
||
- visionA 是唯一真實 caller
|
||
- 自己手動測試用同一把 API key 就好
|
||
- 並存增加 code 複雜度(`requireApiKeyOrOAuth` hacky)+ 維護成本
|
||
- 未來真有第二個 caller 再加 OAuth 回來不遲
|
||
|
||
→ **如果你選純 API key**:把 `requireAuth(scope)` 全部改成 `requireApiKey()`(4 個 endpoint)、砍掉 `auth/middleware.js` + `auth/jwks.js` + `auth/oauthClient.js`、`config.js` 移除 OAuth 相關欄位。
|
||
|
||
---
|
||
|
||
## 4. 任務 B — 新增 `GET /api/v1/jobs/:id/result` endpoint
|
||
|
||
對齊 visionA repo 的 **ADR-016 §1** spec。
|
||
|
||
### 4.1 API spec(**這是 visionA 端會打的契約、不可變動**)
|
||
|
||
| 欄位 | 值 |
|
||
|------|---|
|
||
| **Method + Path** | `GET /api/v1/jobs/:id/result` |
|
||
| **Auth** | `Authorization: Bearer <CONVERTER_API_KEY>` |
|
||
| **Query** | 無 |
|
||
| **Body** | 無 |
|
||
|
||
#### Response 200(成功)
|
||
|
||
```http
|
||
HTTP/1.1 200 OK
|
||
Content-Type: application/octet-stream
|
||
Content-Length: <NEF binary 大小>
|
||
Content-Disposition: attachment; filename="<source_filename_stem>_<chip>.nef"
|
||
|
||
```
|
||
|
||
**重要**:
|
||
- 走 **streaming**、不要先 buffer 整個檔(NEF 可能幾百 MB)
|
||
- `Content-Length` 必須帶(visionA 端會用來決定是否 timeout)
|
||
- `Content-Disposition` filename 由 converter 端構造(visionA 端會用 `defaultDownloadFilename` 覆寫、但 converter 也要給)
|
||
|
||
#### Response 4xx/5xx(錯誤)
|
||
|
||
| HTTP | error.code | 情境 |
|
||
|------|-----------|------|
|
||
| 401 | `invalid_token` | API key 不對 / missing |
|
||
| 404 | `job_not_found` | jobID 不存在 |
|
||
| 409 | `job_not_completed` | job 還沒 completed(still running / failed) |
|
||
| **410** | `result_expired` | **converter MinIO 已過期清除(7 天 expires_at 後)** |
|
||
| 502 | `storage_unavailable` | MinIO 連不上 |
|
||
| 503 | `service_unavailable` | 其他暫時性錯誤 |
|
||
|
||
Body 格式:
|
||
```json
|
||
{
|
||
"error": {
|
||
"code": "job_not_found",
|
||
"message": "Job not found",
|
||
"request_id": "<uuid>"
|
||
}
|
||
}
|
||
```
|
||
|
||
### 4.2 實作(新增 `src/routes/v1/result.js`)
|
||
|
||
```javascript
|
||
// src/routes/v1/result.js
|
||
//
|
||
// GET /api/v1/jobs/:id/result — Phase 0.8b 為 visionA download 路徑新增。
|
||
//
|
||
// 對應 visionA repo 的:
|
||
// - ADR-016 §1 API spec
|
||
// - conversion.md v0.6 §2.3 ConverterClient.GetResult method
|
||
//
|
||
// 設計:
|
||
// - Stream NEF binary 從 MinIO 回 caller(不 buffer)
|
||
// - 4 種失敗情境對應 4xx:401 (auth) / 404 (job 不存在) / 409 (還沒完成) / 410 (過期清除)
|
||
// - Phase 2 預留的 download-tokens endpoint (回 501) 仍保留、不撤銷
|
||
|
||
'use strict';
|
||
|
||
const express = require('express');
|
||
const { ApiError } = require('../../middleware/errorHandler');
|
||
|
||
/**
|
||
* @param {object} deps
|
||
* @param {object} deps.jobService - existing job service(getJob method)
|
||
* @param {object} deps.minioStorage - storage facade(getObjectStream method)
|
||
* @returns {express.Router}
|
||
*/
|
||
function createResultRouter(deps = {}) {
|
||
const { jobService, minioStorage } = deps;
|
||
if (!jobService) throw new Error('[createResultRouter] jobService is required');
|
||
if (!minioStorage) throw new Error('[createResultRouter] minioStorage is required');
|
||
|
||
const router = express.Router({ mergeParams: true }); // mergeParams 取 :id
|
||
|
||
router.get('/', async (req, res, next) => {
|
||
try {
|
||
const jobId = req.params.id;
|
||
if (!jobId) {
|
||
return next(new ApiError(400, 'invalid_request', 'job id is required'));
|
||
}
|
||
|
||
// 1. 拿 job record
|
||
const job = await jobService.getJob(jobId);
|
||
if (!job) {
|
||
return next(new ApiError(404, 'job_not_found', `Job ${jobId} not found`));
|
||
}
|
||
|
||
// 2. 檢查 status — 必須 completed 才能拿 result
|
||
if (job.status !== 'completed') {
|
||
return next(new ApiError(409, 'job_not_completed',
|
||
`Job ${jobId} is ${job.status}; result only available after completion`));
|
||
}
|
||
|
||
// 3. 檢查 expires_at — 過期 NEF 已從 MinIO 清掉
|
||
if (job.expires_at && new Date(job.expires_at) < new Date()) {
|
||
return next(new ApiError(410, 'result_expired',
|
||
`Job ${jobId} result expired at ${job.expires_at}; re-convert to get a fresh result`));
|
||
}
|
||
|
||
// 4. 解析 result NEF object key
|
||
// 對齊 promote.js §extractSourceObjectKey 的雙路徑邏輯(新格式 result_object_keys / 舊格式 output)
|
||
const nefKey = extractNefObjectKey(job);
|
||
if (!nefKey) {
|
||
return next(new ApiError(404, 'result_not_found',
|
||
`Job ${jobId} completed but no NEF result available`));
|
||
}
|
||
|
||
// 5. 從 MinIO 拿 stream + metadata
|
||
const result = await minioStorage.getObjectStream(nefKey);
|
||
if (!result) {
|
||
// MinIO 說沒這個 object(與 job record 不一致 — 通常是過期清除但 record 沒同步更新)
|
||
return next(new ApiError(410, 'result_expired',
|
||
`Job ${jobId} NEF object not found in storage (likely expired)`));
|
||
}
|
||
|
||
// 6. 寫 response headers
|
||
res.setHeader('Content-Type', result.contentType || 'application/octet-stream');
|
||
if (result.contentLength) {
|
||
res.setHeader('Content-Length', String(result.contentLength));
|
||
}
|
||
const filename = buildFilename(job);
|
||
res.setHeader('Content-Disposition',
|
||
`attachment; filename="${filename}"`);
|
||
|
||
// 7. Pipe stream 回 client
|
||
result.stream.on('error', (streamErr) => {
|
||
// 注意:此時 headers 可能已 sent、不能改 status code
|
||
// 只能 destroy connection、讓 client 看到 ECONNRESET
|
||
// eslint-disable-next-line no-console
|
||
console.error(JSON.stringify({
|
||
level: 'ERROR',
|
||
action: 'result.stream_error',
|
||
job_id: jobId,
|
||
error: streamErr.message,
|
||
timestamp: new Date().toISOString(),
|
||
}));
|
||
if (!res.destroyed) res.destroy(streamErr);
|
||
});
|
||
|
||
req.on('close', () => {
|
||
// Client 中斷下載 — 主動關 stream 釋放 MinIO connection
|
||
if (result.stream && typeof result.stream.destroy === 'function') {
|
||
result.stream.destroy();
|
||
}
|
||
});
|
||
|
||
result.stream.pipe(res);
|
||
} catch (err) {
|
||
return next(err);
|
||
}
|
||
});
|
||
|
||
return router;
|
||
}
|
||
|
||
/**
|
||
* 從 job record 拿 NEF object key(雙路徑:新格式 + 舊格式)。
|
||
* 對齊 promote.js extractSourceObjectKey logic。
|
||
*/
|
||
function extractNefObjectKey(job) {
|
||
// 新格式
|
||
if (job.result_object_keys && typeof job.result_object_keys === 'object'
|
||
&& typeof job.result_object_keys.nef === 'string'
|
||
&& job.result_object_keys.nef.length > 0) {
|
||
return job.result_object_keys.nef;
|
||
}
|
||
// 舊格式(向後相容)
|
||
if (job.output && typeof job.output === 'object'
|
||
&& typeof job.output.nef_path === 'string'
|
||
&& job.output.nef_path.length > 0) {
|
||
return job.output.nef_path;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 構造 download filename。
|
||
*
|
||
* 規則:<source_filename_stem>_<chip>.nef
|
||
* 例:yolov5s.onnx + KL720 → yolov5s_kl720.nef
|
||
*
|
||
* Fallback:job_<jobID>.nef(極端情況、source_filename 缺失)
|
||
*/
|
||
function buildFilename(job) {
|
||
const sourceFilename = job.source_filename || '';
|
||
const platform = (job.platform || '').toLowerCase();
|
||
|
||
// 去副檔名
|
||
const stem = sourceFilename.replace(/\.(onnx|tflite|pb|h5)$/i, '');
|
||
|
||
if (stem && platform) {
|
||
return `${stem}_${platform}.nef`;
|
||
}
|
||
return `job_${job.job_id || 'unknown'}.nef`;
|
||
}
|
||
|
||
module.exports = { createResultRouter, _internals: { extractNefObjectKey, buildFilename } };
|
||
```
|
||
|
||
### 4.3 Wire 進 v1 router
|
||
|
||
```javascript
|
||
// src/routes/v1/index.js — 加 result router:
|
||
|
||
const { createResultRouter } = require('./result');
|
||
const { requireApiKey } = require('../../auth/apiKeyMiddleware'); // 或 requireApiKeyOrOAuth
|
||
|
||
function createV1Router(deps) {
|
||
const router = express.Router();
|
||
|
||
// ... 既有 jobs / promote
|
||
|
||
// 新:result endpoint(GET /api/v1/jobs/:id/result)
|
||
// 注意:mount 在 /jobs/:id/result 上、mergeParams 取 :id
|
||
router.use('/jobs/:id/result',
|
||
requireApiKey(), // 或 requireApiKeyOrOAuth('converter:job.read')
|
||
createResultRouter({
|
||
jobService: deps.jobService,
|
||
minioStorage: deps.minioStorage,
|
||
}));
|
||
|
||
// errorHandler 仍掛最末
|
||
router.use(errorHandler);
|
||
return router;
|
||
}
|
||
```
|
||
|
||
### 4.4 Test(新增 `src/routes/v1/__tests__/result.integration.test.js`)
|
||
|
||
最少 cover 4 個情境:
|
||
- ✅ 200 happy path — completed job + 有 NEF + 不過期 → stream 整段、Content-Type / Content-Length / Content-Disposition 正確
|
||
- ❌ 404 job 不存在
|
||
- ❌ 409 job 還在 running
|
||
- ❌ 410 job 已過期(測 expires_at 在過去)
|
||
- ❌ 401 missing API key / wrong API key(如果用 requireApiKey)
|
||
|
||
對齊既有 `__tests__/getJobs.integration.test.js` 的 fixture / mock pattern。
|
||
|
||
---
|
||
|
||
## 5. 部署順序(重要 — visionA / converter 雙端對齊)
|
||
|
||
**錯誤的順序會讓 stage 整段 down**。正確順序:
|
||
|
||
```
|
||
Step 1: converter 端先實作完 + deploy(含 API key 驗證 + result endpoint)
|
||
→ 但 CONVERTER_API_KEY env 設成跟 visionA 一樣的值
|
||
→ 此時 converter 同時接 OAuth(既有)+ API key(新)
|
||
→ 既有 caller 不受影響
|
||
|
||
Step 2: 驗證 converter 新 endpoint 可用
|
||
→ 用 curl 打 GET /api/v1/jobs/<某個 completed job>/result 帶 Bearer <CONVERTER_API_KEY>
|
||
→ 確認回 200 + NEF binary stream
|
||
|
||
Step 3: visionA backend deploy(已 ready、commit 9e29ebf)
|
||
→ VISIONA_CONVERTER_API_KEY env 跟 CONVERTER_API_KEY 對齊
|
||
→ 此時 visionA 用 API key 打 converter、走新的 GetResult endpoint
|
||
|
||
Step 4: e2e 驗證
|
||
→ User upload → init → poll → promote → download
|
||
→ 全綠 = 完成
|
||
|
||
Step 5(選配): 砍 converter OAuth path
|
||
→ 確認沒其他 caller 後、砍 OAuth middleware + jwks + oauthClient
|
||
→ 砍 MEMBER_CENTER_* env
|
||
```
|
||
|
||
---
|
||
|
||
## 6. CONVERTER_API_KEY 怎麼產
|
||
|
||
```bash
|
||
openssl rand -hex 32
|
||
# 輸出:64 個 hex 字元、例如:a3f9b2c1d8e7f6a5b4c3d2e1f0987654321fedcba9876543210abcdef1234567
|
||
```
|
||
|
||
**部署**:
|
||
- converter stage:放 `kneron_model_converter/apps/task-scheduler/.env` 或對應的 docker-compose env
|
||
- visionA stage:放 `~/visionA/.env.stage` 的 `VISIONA_CONVERTER_API_KEY=...`
|
||
- **兩端必須完全相同字串**
|
||
|
||
**安全**:
|
||
- ⚠️ **絕不進 git**(`.gitignore` 已 exclude `.env`、verify 一次)
|
||
- ⚠️ **絕不寫進 Slack / email / 對話**
|
||
- ⚠️ **絕不印 log**(middleware 內 log 用 `api_key_length` 或 `api_key_set: true` boolean、不印 key 本身)
|
||
- 每環境獨立 key(dev / stage / prod 各自 `openssl rand -hex 32`)
|
||
|
||
---
|
||
|
||
## 7. 既有 promote 流程不變
|
||
|
||
**重要**:converter promote 流程(`POST /api/v1/jobs/:id/promote` → converter 自己 PUT FAA)**完全不動**。
|
||
|
||
- visionA → converter promote 仍會打、但 visionA 拿到 promote response 後**不再從 FAA pull NEF**(v0.6 設計改成從 converter GetResult pull)
|
||
- converter promote response 仍含 `target_object_key`(在 FAA 上)— visionA 不再用、但 converter promote logic 保留
|
||
- converter → FAA 的 OAuth client_credentials 鏈條保留(這條不在本次 scope)
|
||
|
||
→ 換句話說,visionA 既走 `/promote`(promote 還在 FAA)+ 也走 `/result`(拿 NEF 給 user download),**兩個 endpoint 都會被 visionA 打**。
|
||
|
||
---
|
||
|
||
## 8. Phase 2 預留的 `/download-tokens` endpoint
|
||
|
||
`apps/task-scheduler/README.md` 寫的 Phase 2 預留:
|
||
|
||
```
|
||
POST /api/v1/jobs/:id/download-tokens converter:job.read Phase 2 預留,回 501
|
||
```
|
||
|
||
**這個跟 ADR-016 沒衝突、保留**。`/download-tokens` 是給未來 browser 直連 converter download 用的 short-TTL token;本次 `/result` 是給 visionA backend 用的 stream proxy 入口。兩個 endpoint 用途不同。
|
||
|
||
---
|
||
|
||
## 9. 簡化版 checklist
|
||
|
||
如果你想跳過上面細節、只要可執行 checklist:
|
||
|
||
### Phase A — API key middleware
|
||
- [ ] 新建 `src/auth/apiKeyMiddleware.js`(複製 §3.2 code)
|
||
- [ ] 修 `src/config.js` 加 `converter.apiKey` 欄位、讀 `CONVERTER_API_KEY` env
|
||
- [ ] 修 `src/routes/v1/index.js` 把 `requireAuth(scope)` 全改 `requireApiKey()`(如果走純 API key)
|
||
- [ ] 加 unit test 對 `apiKeyMiddleware`(happy / missing / wrong key / constant-time 行為)
|
||
- [ ] 修 `.env.example` 加 `CONVERTER_API_KEY=` placeholder
|
||
- [ ] 修 README.md 認證段落(OAuth → API key)
|
||
|
||
### Phase B — `/result` endpoint
|
||
- [ ] 新建 `src/routes/v1/result.js`(複製 §4.2 code)
|
||
- [ ] 修 `src/routes/v1/index.js` 加 result router wire
|
||
- [ ] 加 integration test cover 4 情境(200 / 404 / 409 / 410)
|
||
- [ ] 修 `apps/task-scheduler/README.md` 加 `/result` endpoint 描述
|
||
|
||
### Phase C — 部署
|
||
- [ ] `openssl rand -hex 32` 產 stage `CONVERTER_API_KEY`
|
||
- [ ] 設到 converter stage `.env` / docker-compose env
|
||
- [ ] 設到 visionA stage `.env.stage` `VISIONA_CONVERTER_API_KEY=<same>`
|
||
- [ ] redeploy converter
|
||
- [ ] curl verify `/result` endpoint 用 API key 可拿
|
||
- [ ] redeploy visionA
|
||
- [ ] e2e 跑完整 upload → poll → promote → download
|
||
|
||
---
|
||
|
||
## 10. 參考文件(visionA repo)
|
||
|
||
| 文件 | 用途 |
|
||
|------|------|
|
||
| `docs/autoflow/04-architecture/adr/adr-015-server-to-server-api-key.md` v2.1 | 為什麼用 API key、§3.5.1 Go reference middleware(要 port 成 Node) |
|
||
| `docs/autoflow/04-architecture/adr/adr-016-download-via-converter.md` v1.0 | 為什麼加 `/result` endpoint、完整 6 個替代方案分析 |
|
||
| `docs/autoflow/04-architecture/conversion.md` v0.6.1 §2.3 | ConverterClient.GetResult method spec(visionA 端) |
|
||
| `docs/autoflow/04-architecture/api/api-conversion.md` v0.6 §4 | download endpoint 對外契約(visionA backend → browser) |
|
||
|
||
---
|
||
|
||
## 11. 給未來自己(jimchen)的提醒
|
||
|
||
1. **不要 assume MC team 會配合**:5/2 寫 ADR-014 時 assume MC 有 delegated token endpoint、結果根本沒有。**動 MC 之前先 grep MC source**。
|
||
2. **converter / FAA / MC 三方都 grep 一次**:以後做任何跨 repo integration design,**寫 ADR 前先 grep 每一方的 source code 確認 endpoint 真的存在**。
|
||
3. **e2e 要早跑**:Phase 0.8 設計到 5/4 完工、5/9 才實機跑 e2e 撞牆。**整合 design 完成、code 還沒寫前、先用 curl 跑一遍真實 e2e**(可以一些 endpoint mock、但起碼確認 auth / token / scope 鏈通)。
|