# 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 // - 用 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 )'); } 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 ` | | **Query** | 無 | | **Body** | 無 | #### Response 200(成功) ```http HTTP/1.1 200 OK Content-Type: application/octet-stream Content-Length: Content-Disposition: attachment; filename="_.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": "" } } ``` ### 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。 * * 規則:_.nef * 例:yolov5s.onnx + KL720 → yolov5s_kl720.nef * * Fallback:job_.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 → 確認回 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=` - [ ] 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 鏈通)。