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>
27 KiB
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.mdv2.1 +adr-016-download-via-converter.mdv1.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 路徑
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 並存,理由:
- visionA 是 1 個 caller、走 API key
- 其他既有 caller(如 jimchen 手動測試、CI、未來其他產品線)仍可用 OAuth
- converter 不需要強迫所有 caller 一次 migrate
- 如果未來 100% caller 都用 API key、再砍 OAuth path
3.2 實作(新增 src/auth/apiKeyMiddleware.js)
對齊既有 requireAuth(scope) API surface,讓 routes/v1/jobs.js 可以無痛換掛。
// 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。
// 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
// 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 複雜度(
requireApiKeyOrOAuthhacky)+ 維護成本 - 未來真有第二個 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/1.1 200 OK
Content-Type: application/octet-stream
Content-Length: <NEF binary 大小>
Content-Disposition: attachment; filename="<source_filename_stem>_<chip>.nef"
<NEF binary stream>
重要:
- 走 streaming、不要先 buffer 整個檔(NEF 可能幾百 MB)
Content-Length必須帶(visionA 端會用來決定是否 timeout)Content-Dispositionfilename 由 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 格式:
{
"error": {
"code": "job_not_found",
"message": "Job not found",
"request_id": "<uuid>"
}
}
4.2 實作(新增 src/routes/v1/result.js)
// 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
// 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 怎麼產
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: trueboolean、不印 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_KEYenv - 修
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加/resultendpoint 描述
Phase C — 部署
openssl rand -hex 32產 stageCONVERTER_API_KEY- 設到 converter stage
.env/ docker-compose env - 設到 visionA stage
.env.stageVISIONA_CONVERTER_API_KEY=<same> - redeploy converter
- curl verify
/resultendpoint 用 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)的提醒
- 不要 assume MC team 會配合:5/2 寫 ADR-014 時 assume MC 有 delegated token endpoint、結果根本沒有。動 MC 之前先 grep MC source。
- converter / FAA / MC 三方都 grep 一次:以後做任何跨 repo integration design,寫 ADR 前先 grep 每一方的 source code 確認 endpoint 真的存在。
- e2e 要早跑:Phase 0.8 設計到 5/4 完工、5/9 才實機跑 e2e 撞牆。整合 design 完成、code 還沒寫前、先用 curl 跑一遍真實 e2e(可以一些 endpoint mock、但起碼確認 auth / token / scope 鏈通)。