// POST /components — 零件提交端點(沙盒驗收流程) // POST /components/index-only — metadata-only 索引(無 wasm、無沙盒,給 backfill 用) // Requirements: 2.1, 2.2, 2.3 // SDD: matrix/arcrun/.agents/specs/component-registry-canon/design.md import { Hono } from 'hono'; import type { Bindings } from '../types'; import { validateContract } from '../actions/validateContract'; import { submitComponent } from '../actions/submitComponent'; import type { SubmitOptions } from '../actions/submitComponent'; import { indexOnlyComponent } from '../actions/indexOnlyComponent'; const app = new Hono<{ Bindings: Bindings }>(); app.post('/', async c => { // 接受 multipart/form-data:contract(JSON 字串)+ wasm(binary) let contract: unknown; let wasmBytes: Uint8Array; // G0/G4:人類確認 + 舉證 + 本地 Gherkin evidence(multipart 或 JSON 皆可帶) const submitOptions: SubmitOptions = {}; const contentType = c.req.header('content-type') ?? ''; if (contentType.includes('multipart/form-data')) { const formData = await c.req.formData(); const contractStr = formData.get('contract'); const wasmFile = formData.get('wasm'); if (!contractStr || typeof contractStr !== 'string') { return c.json({ success: false, error: '缺少 contract 欄位' }, 400); } if (!wasmFile || !(wasmFile instanceof File)) { return c.json({ success: false, error: '缺少 wasm 欄位' }, 400); } try { contract = JSON.parse(contractStr); } catch { return c.json({ success: false, error: 'contract 必須為合法 JSON' }, 400); } wasmBytes = new Uint8Array(await wasmFile.arrayBuffer()); const hc = formData.get('human_confirmation'); if (typeof hc === 'string') { try { submitOptions.human_confirmation = JSON.parse(hc); } catch { /* ignore */ } } const ge = formData.get('gherkin_evidence'); if (typeof ge === 'string') { try { submitOptions.gherkin_evidence = JSON.parse(ge); } catch { /* ignore */ } } if (formData.get('skip_acceptance') === 'true') submitOptions.skip_acceptance = true; } else { // 也支援純 JSON(用於測試,wasm 以 base64 傳入) let body: Record; try { body = await c.req.json(); } catch { return c.json({ success: false, error: 'request body 必須為 multipart/form-data 或 JSON' }, 400); } contract = body.contract; const wasmBase64 = body.wasm_base64; if (!contract) { return c.json({ success: false, error: '缺少 contract 欄位' }, 400); } if (!wasmBase64 || typeof wasmBase64 !== 'string') { return c.json({ success: false, error: '缺少 wasm_base64 欄位' }, 400); } // base64 decode const binaryStr = atob(wasmBase64); wasmBytes = new Uint8Array(binaryStr.length); for (let i = 0; i < binaryStr.length; i++) { wasmBytes[i] = binaryStr.charCodeAt(i); } if (body.human_confirmation) submitOptions.human_confirmation = body.human_confirmation as SubmitOptions['human_confirmation']; if (body.gherkin_evidence) submitOptions.gherkin_evidence = body.gherkin_evidence as SubmitOptions['gherkin_evidence']; if (body.skip_acceptance === true) submitOptions.skip_acceptance = true; } // 驗證 contract 格式 const validation = validateContract(contract); if (!validation.valid) { return c.json({ success: false, failed_step: 'contract_validation', reason: `合約格式驗證失敗:${validation.errors.join(', ')}`, missing_fields: validation.missing_fields, guide_anchor: '#contract-example', }, 422); } // 執行沙盒驗收(含 G0 人類閘門)+ 寫入 metadata const result = await submitComponent(wasmBytes, validation.contract!, c.env, submitOptions); if (!result.success) { return c.json(result, 422); } return c.json(result, 201); }); // POST /components/index-only — metadata-only 索引(給 backfill 用) // 只接 contract(JSON),不收 wasm bytes、不沙盒驗收 // 用途:cypher-executor 已不從 R2 動態載 wasm(零件用獨立 Worker URL), // 故已部署但未索引的零件,只要把 metadata 寫進 KV 讓 search 找得到即可。 app.post('/index-only', async c => { let body: Record; try { body = await c.req.json(); } catch { return c.json({ success: false, error: 'request body 必須為 JSON' }, 400); } const contract = body.contract; if (!contract) { return c.json({ success: false, error: '缺少 contract 欄位' }, 400); } // index-only 是 backfill 用,比 submit 寬鬆: // 既有零件可能沒 gherkin_tests / 沒 description / aliases — 補預設讓索引能進 if (typeof contract === 'object' && contract !== null) { const c2 = contract as Record; if (!c2.gherkin_tests || (Array.isArray(c2.gherkin_tests) && c2.gherkin_tests.length < 2)) { c2.gherkin_tests = [ { scenario: 'placeholder happy', given: '{}', then_contains: '{' }, { scenario: 'placeholder fail', given: '{}', then_contains: '}' }, ]; } if (!c2.description) c2.description = ''; if (!c2.tags) c2.tags = []; } const validation = validateContract(contract); if (!validation.valid) { return c.json({ success: false, failed_step: 'contract_validation', reason: `合約格式驗證失敗:${validation.errors.join(', ')}`, missing_fields: validation.missing_fields, }, 422); } const result = await indexOnlyComponent(validation.contract!, c.env); return c.json(result, result.already_indexed ? 200 : 201); }); export default app;