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

27 KiB
Raw Blame History

visionA Cloud 整合 — converter scheduler 交接檔 v2

時間2026-05-16 背景visionA Cloud Phase 0.8b — 從 OAuth client_credentials 改 API key + 重設計 download 路徑 替代:本檔取代 docs/TODO-visionA-integration.md5/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 路徑

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 可以無痛換掛。

// 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。

// 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

// 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.jsconfig.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-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 格式:

{
  "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 種失敗情境對應 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

// 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 怎麼產

openssl rand -hex 32
# 輸出64 個 hex 字元、例如a3f9b2c1d8e7f6a5b4c3d2e1f0987654321fedcba9876543210abcdef1234567

部署

  • converter stagekneron_model_converter/apps/task-scheduler/.env 或對應的 docker-compose env
  • visionA stage~/visionA/.env.stageVISIONA_CONVERTER_API_KEY=...
  • 兩端必須完全相同字串

安全

  • ⚠️ 絕不進 git.gitignore 已 exclude .env、verify 一次)
  • ⚠️ 絕不寫進 Slack / email / 對話
  • ⚠️ 絕不印 logmiddleware 內 log 用 api_key_lengthapi_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 NEFv0.6 設計改成從 converter GetResult pull
  • converter promote response 仍含 target_object_key(在 FAA 上)— visionA 不再用、但 converter promote logic 保留
  • converter → FAA 的 OAuth client_credentials 鏈條保留(這條不在本次 scope

→ 換句話說visionA 既走 /promotepromote 還在 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.jsconverter.apiKey 欄位、讀 CONVERTER_API_KEY env
  • src/routes/v1/index.jsrequireAuth(scope) 全改 requireApiKey()(如果走純 API key
  • 加 unit test 對 apiKeyMiddlewarehappy / missing / wrong key / constant-time 行為)
  • .env.exampleCONVERTER_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 鏈通)。