kneron_model_converter/docs/TODO-visionA-integration-v2.md
jim800121chen d8a9517c9d feat(task-scheduler): Phase 0.8b — API key auth + /result endpoint
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>
2026-05-17 22:47:28 +08:00

714 lines
27 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`** + MCMember Centerservice 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 trustvisionA ↔ 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 trustAPI 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 不同 contextinline 一個簡化版避免循環依賴。
*/
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 → 401API key middleware 處理)
* 2. 帶的 token 是 visionA 的 API keyconstant-time match→ API key path 過、req.auth 設好、next()
* 3. 帶的 token 不 match API key → 走 OAuth JWT 驗證(既有 requireAuth
* - 過 → next()
* - 不過 → 401OAuth middleware 處理)
*
* 為什麼 API key 優先API key compare 快constant-time string compareJWT 驗證慢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 還沒 completedstill 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 種失敗情境對應 4xx401 (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 servicegetJob method
* @param {object} deps.minioStorage - storage facadegetObjectStream 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
*
* Fallbackjob_<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 endpointGET /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 本身)
- 每環境獨立 keydev / 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 specvisionA 端) |
| `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 鏈通)。