/** * /recipes — API recipe CRUD * * recipe 是「http_request + 參數模板」的具名封裝。 * 不需要 deploy Worker,執行時由 cypher-executor 直接 fetch。 * * KV 結構: * recipe:{canonical_id} → RecipeDefinition JSON * idx:{rec_hash} → canonical_id (反查索引) * * 引用方式(workflow config): * component: "rec_f7e2a1b3" → 永久穩定,不受改名影響 * component: "slack" → 向前兼容,直接用 canonical_id 查 */ import { Hono } from 'hono'; import type { Bindings } from '../types'; import { deriveRecipeHash } from '../lib/hash'; import { checkExposureConsent, resolveConsentForRecord } from '../lib/exposure-consent'; import type { ExposureConsent } from '../lib/exposure-consent'; export const recipesRouter = new Hono<{ Bindings: Bindings }>(); export interface RecipeDefinition { canonical_id: string; hash_id: string; // rec_xxxxxxxx display_name?: string; description?: string; endpoint: string; method?: string; // GET | POST | PUT | PATCH | DELETE,預設 POST headers?: Record; body?: Record; /** * 此 recipe 要用哪個 auth recipe(auth_recipe:{auth_service})。 * 讓多個 recipe 共用同一把 auth(例:kbdb_get / kbdb_create_block 都設 "kbdb")。 * 未設時 auth-dispatcher fallback 到把 canonical_id 當 service name(向後相容)。 */ auth_service?: string; credentials_required?: Array<{ key: string; inject_as: string; }>; // 資料外流警示:recipe 定義一個資料去向(endpoint)。push 需人類明示同意(法律憑證)。 // SDD: data-exfil-warning §7(公私一視同仁) exposure_consent?: ExposureConsent; created_at: number; updated_at: number; } // POST /recipes — 新增或更新 recipe recipesRouter.post('/recipes', async (c) => { let body: Partial; try { body = await c.req.json(); } catch { return c.json({ success: false, error: 'request body 必須為 JSON' }, 400); } const canonicalId = (body.canonical_id ?? '').trim().toLowerCase(); if (!canonicalId) return c.json({ success: false, error: 'canonical_id 必填' }, 400); if (!body.endpoint) return c.json({ success: false, error: 'endpoint 必填' }, 400); const hashId = await deriveRecipeHash(canonicalId); const now = Date.now(); // 讀取現有版本(保留 created_at + 既有同意憑證) const existing = await c.env.RECIPES.get(`recipe:${canonicalId}`, 'json') as RecipeDefinition | null; // 資料外流警示:recipe 定義資料去向(endpoint)。首次 push 需人類明示同意(公私一視同仁)。 const consentError = checkExposureConsent(body.exposure_consent, existing?.exposure_consent); if (consentError !== null) { return c.json({ success: false, error: consentError, requires: 'exposure_consent' }, 403); } const recipe: RecipeDefinition = { canonical_id: canonicalId, hash_id: hashId, display_name: body.display_name, description: body.description, endpoint: body.endpoint, method: (body.method ?? 'POST').toUpperCase(), headers: body.headers, body: body.body, auth_service: body.auth_service, credentials_required: body.credentials_required, exposure_consent: resolveConsentForRecord(body.exposure_consent, existing?.exposure_consent), created_at: existing?.created_at ?? now, updated_at: now, }; // 寫入兩個 KV key await Promise.all([ c.env.RECIPES.put(`recipe:${canonicalId}`, JSON.stringify(recipe)), c.env.RECIPES.put(`idx:${hashId}`, canonicalId), ]); return c.json({ success: true, recipe }); }); // GET /recipes/:id — 讀取 recipe(支援 canonical_id 或 rec_hash) recipesRouter.get('/recipes/:id', async (c) => { const id = c.req.param('id'); const recipe = await resolveRecipe(id, c.env.RECIPES); if (!recipe) return c.json({ success: false, error: `找不到 recipe: ${id}` }, 404); return c.json({ success: true, recipe }); }); // GET /recipes — 列出所有 recipe recipesRouter.get('/recipes', async (c) => { const list = await c.env.RECIPES.list({ prefix: 'recipe:' }); const recipes = await Promise.all( list.keys.map(k => c.env.RECIPES.get(k.name, 'json')) ); return c.json({ success: true, recipes: recipes.filter(Boolean), count: recipes.length }); }); // DELETE /recipes/:id — 刪除 recipe recipesRouter.delete('/recipes/:id', async (c) => { const id = c.req.param('id'); const recipe = await resolveRecipe(id, c.env.RECIPES); if (!recipe) return c.json({ success: false, error: `找不到 recipe: ${id}` }, 404); await Promise.all([ c.env.RECIPES.delete(`recipe:${recipe.canonical_id}`), c.env.RECIPES.delete(`idx:${recipe.hash_id}`), ]); return c.json({ success: true, deleted: recipe.canonical_id }); }); /** 用 canonical_id 或 rec_hash 查 recipe */ export async function resolveRecipe( id: string, kv: KVNamespace, ): Promise { // rec_xxxxxxxx → 先查 idx 反查 canonical_id if (id.startsWith('rec_')) { const canonicalId = await kv.get(`idx:${id}`); if (!canonicalId) return null; return kv.get(`recipe:${canonicalId}`, 'json'); } // 直接用 canonical_id return kv.get(`recipe:${id}`, 'json'); } // ── Auth Recipe ──────────────────────────────────────────────────────────────── export type AuthPrimitive = 'static_key' | 'oauth2' | 'service_account' | 'mtls'; export interface SecretRequirement { key: string; // CREDENTIALS_KV 的名稱(e.g. "notion_token") label: string; // CLI/UI 顯示(e.g. "Internal Integration Token") type?: 'string' | 'json_blob'; // default: string help?: string; help_url?: string; optional?: boolean; } export interface AuthInjectSpec { header?: Record; // e.g. { Authorization: "Bearer {{secret.token}}" } query?: Record; body?: Record; } export interface AuthRecipeDefinition { kind: 'auth_recipe'; service: string; // canonical_id,e.g. "notion" version: number; primitive: AuthPrimitive; base_url: string; display_name?: string; description?: string; // service_account 專用 service_account_kind?: 'google_jwt'; token_exchange?: { endpoint: string; scopes: string[]; }; required_secrets: SecretRequirement[]; inject: AuthInjectSpec; created_at: number; updated_at: number; } /** 查 auth recipe(KV key: auth_recipe:{service})*/ export async function resolveAuthRecipe( service: string, kv: KVNamespace, ): Promise { return kv.get(`auth_recipe:${service}`, 'json'); } // POST /auth-recipes — 新增或更新 auth recipe recipesRouter.post('/auth-recipes', async (c) => { let body: Partial; try { body = await c.req.json(); } catch { return c.json({ success: false, error: 'request body 必須為 JSON' }, 400); } const service = (body.service ?? '').trim().toLowerCase(); if (!service) return c.json({ success: false, error: 'service 必填' }, 400); if (!body.primitive) return c.json({ success: false, error: 'primitive 必填' }, 400); if (!body.base_url) return c.json({ success: false, error: 'base_url 必填' }, 400); if (!body.required_secrets?.length) return c.json({ success: false, error: 'required_secrets 必填' }, 400); if (!body.inject) return c.json({ success: false, error: 'inject 必填' }, 400); const now = Date.now(); const existing = await c.env.RECIPES.get(`auth_recipe:${service}`, 'json') as AuthRecipeDefinition | null; const recipe: AuthRecipeDefinition = { kind: 'auth_recipe', service, version: body.version ?? 1, primitive: body.primitive, base_url: body.base_url, display_name: body.display_name, description: body.description, service_account_kind: body.service_account_kind, token_exchange: body.token_exchange, required_secrets: body.required_secrets, inject: body.inject, created_at: existing?.created_at ?? now, updated_at: now, }; await c.env.RECIPES.put(`auth_recipe:${service}`, JSON.stringify(recipe)); return c.json({ success: true, recipe }); }); // GET /auth-recipes — 列出所有 auth recipe recipesRouter.get('/auth-recipes', async (c) => { const list = await c.env.RECIPES.list({ prefix: 'auth_recipe:' }); const recipes = await Promise.all( list.keys.map(k => c.env.RECIPES.get(k.name, 'json')) ); return c.json({ success: true, recipes: recipes.filter(Boolean), count: recipes.length }); }); // GET /auth-recipes/:service — 讀取單一 auth recipe recipesRouter.get('/auth-recipes/:service', async (c) => { const service = c.req.param('service'); const recipe = await resolveAuthRecipe(service, c.env.RECIPES); if (!recipe) return c.json({ success: false, error: `找不到 auth recipe: ${service}` }, 404); return c.json({ success: true, recipe }); }); // DELETE /auth-recipes/:service — 刪除 auth recipe recipesRouter.delete('/auth-recipes/:service', async (c) => { const service = c.req.param('service'); const recipe = await resolveAuthRecipe(service, c.env.RECIPES); if (!recipe) return c.json({ success: false, error: `找不到 auth recipe: ${service}` }, 404); await c.env.RECIPES.delete(`auth_recipe:${service}`); return c.json({ success: true, deleted: service }); });