/** * /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'; export const recipesRouter = new Hono<{ Bindings: Bindings }>(); export interface RecipeDefinition { // UUID 身份模型(kbdb-base §7.5.5):每個 recipe 一誕生領 uuid = 唯一身份。 // canonical_id / author / 公私 都是屬性,不是身份。身份(uuid) 與歸屬(author) 分離。 // 舊 recipe 無 uuid → resolveRecipe / migration 兼容(migration 增量補 uuid,不刪舊 key)。 uuid?: string; // 唯一身份;舊資料可能缺,讀取時容忍 author?: string; // 該 uuid 投稿者(誰投誰負責那版市場數據);'system' = init-seed 種子 derived_from?: string; // 可選溯源:fork 自哪個 uuid(Leo 改 John 版時記 John 的 uuid) 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; }>; // 暴露 consent 閘已移除(leo 2026-06-29 拍板,Arcrun#13):arcrun 是給 AI 用的系統, // 不再對 push/暴露要求人類確認。此欄位保留只為向後相容舊 KV record(讀到不報錯,不再寫入/檢查)。 exposure_consent?: unknown; created_at: number; updated_at: number; } // ── UUID 身份模型 KV key(kbdb-base §7.5.5)──────────────────────────────────── // recipe:{uuid} → recipe 本體(唯一身份) // idx:canonical:{canonical_id} → JSON array of uuid(同 canonical 多作者版本並存,公庫用) // idx:installed:{canonical_id} → 單一 uuid(本部署執行時用哪個版本;pull/submit 時定) // idx:{hash_id} → canonical_id(既有 rec_hash 反查,保留) // 舊資料 recipe:{canonical_id} 不刪,resolveRecipe fallback 讀得到(migration 增量補,不破現況)。 const kIdxCanonical = (canonicalId: string) => `idx:canonical:${canonicalId}`; const kIdxInstalled = (canonicalId: string) => `idx:installed:${canonicalId}`; /** * 寫一份 recipe(UUID 身份模型):給定 recipe 已含 uuid → 寫 recipe:{uuid}、 * 把 uuid 併進 idx:canonical:{canonical_id} 清單、設為本部署 installed(執行時用此版本)、 * 維護 idx:{hash_id} 反查。private(POST /recipes) 與 public(submit-p) 共用此寫入。 */ export async function installRecipeRecord(kv: KVNamespace, recipe: RecipeDefinition): Promise { const uuid = recipe.uuid!; const { canonical_id, hash_id } = recipe; const listRaw = await kv.get(kIdxCanonical(canonical_id)); const uuids: string[] = listRaw ? JSON.parse(listRaw) : []; if (!uuids.includes(uuid)) uuids.push(uuid); await Promise.all([ kv.put(`recipe:${uuid}`, JSON.stringify(recipe)), kv.put(kIdxCanonical(canonical_id), JSON.stringify(uuids)), kv.put(kIdxInstalled(canonical_id), uuid), kv.put(`idx:${hash_id}`, canonical_id), ]); } // 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(); // 私庫(POST /recipes)= 自己地盤,同 canonical 就地更新自己安裝的那份(沿用既有 uuid)。 // 既有 installed → 沿用其 uuid + created_at;無 → 新領 uuid(首次裝這個 canonical)。 // 讀取順序:先 UUID 模型(installed→uuid),fallback 舊 key(migration 前的種子)。 const existing = await resolveRecipe(canonicalId, c.env.RECIPES); // 暴露 consent 閘已移除(leo 2026-06-29,Arcrun#13):直接 push,不攔。 const recipe: RecipeDefinition = { uuid: existing?.uuid ?? crypto.randomUUID(), author: body.author ?? existing?.author ?? 'local', derived_from: body.derived_from ?? existing?.derived_from, 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, created_at: existing?.created_at ?? now, updated_at: now, }; await installRecipeRecord(c.env.RECIPES, recipe); return c.json({ success: true, recipe }); }); // POST /recipes/submit — 公共庫投稿(submit-p)。kbdb-base SDD §7.2/§7.3。 // // 兩套部署模型:self-hosted cypher = 私庫(直接 POST /recipes 寫自己 KV); // 官方 cypher = 公共庫,外部投稿者把修好的 recipe 送來這個端點。 // // app-store / UUID 模型(§7.5.5):submit-p = **新增一個作者版本(領新 uuid)**, // 不覆蓋同 canonical_id。同 canonical 多作者並存(Leo 版、John 版各自 uuid + 市場數據)。 // 公共庫 = 暴露面 → 強制 exposure_consent(mindset §6:暴露需人類明示同意)。 // 投稿者帶的 stat 只當「存證」(誰在何時投了什麼、聲稱打通幾次),寫進 KBDB 一筆 // recipe_submission entry,**不**併進 recipe-stat 真實計數(避免自報數污染市場數據,§7.3)。 // 市場信任靠真實使用累積(5.1),不拿投稿者自報數當門檻 → 不造債。 recipesRouter.post('/recipes/submit', async (c) => { let body: Partial & { stat?: { success_count?: number; failure_count?: number }; submitter?: string; }; 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(); // 暴露 consent 閘已移除(leo 2026-06-29,Arcrun#13):公共庫投稿不再需要人類確認。 // app-store 模型:**領新 uuid = 新增作者版本**,不覆蓋既有 canonical(§7.5.5)。 const recipe: RecipeDefinition = { uuid: crypto.randomUUID(), author: body.author ?? body.submitter ?? 'anonymous', derived_from: body.derived_from, 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, created_at: now, updated_at: now, }; // 新增作者版本:寫 recipe:{uuid} + 併進 idx:canonical 清單(同 canonical 多版本並存)。 // installed 也指向這個新版本(官方部署投稿後預設用最新;market 選擇由 §7.5.5 端點處理)。 await installRecipeRecord(c.env.RECIPES, recipe); // stat 存證:寫一筆 recipe_submission entry 進 KBDB(不當門檻,當法律歸責軌跡)。 // fire-and-forget:存證失敗不擋投稿成功。 const kbdbBase = (c.env.KBDB_BASE_URL ?? 'https://kbdb.finally.click').replace(/\/$/, ''); const evidence = { content: canonicalId, entry_type: 'recipe_submission', metadata_json: JSON.stringify({ uuid: recipe.uuid, canonical_id: canonicalId, author: recipe.author, submitter: body.submitter ?? 'unknown', claimed_stat: body.stat ?? null, submitted_at: now, }), }; const kbdbHeaders: Record = { 'Content-Type': 'application/json' }; if (c.env.KBDB_INTERNAL_TOKEN) kbdbHeaders['Authorization'] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`; c.executionCtx.waitUntil( fetch(`${kbdbBase}/entries`, { method: 'POST', headers: kbdbHeaders, body: JSON.stringify(evidence), }).catch(() => undefined), ); return c.json({ success: true, recipe, evidence_recorded: true }); }); // POST /recipes/migrate-uuid — 一次性 migration:把 migration 前的舊 key recipe:{canonical_id} // (無 uuid)轉成 UUID 身份模型(§7.5.5)。增量寫、**不刪舊 key**(失敗也不破現況;resolveRecipe // 本就 fallback 舊 key)。冪等:已有 uuid 的跳過。重跑安全。 recipesRouter.post('/recipes/migrate-uuid', async (c) => { const list = await c.env.RECIPES.list({ prefix: 'recipe:' }); let migrated = 0, skipped = 0; const errors: string[] = []; for (const k of list.keys) { try { const rec = await c.env.RECIPES.get(k.name, 'json') as RecipeDefinition | null; if (!rec || !rec.canonical_id) { skipped++; continue; } if (rec.uuid) { skipped++; continue; } // 已是新模型 const migrated_recipe: RecipeDefinition = { ...rec, uuid: crypto.randomUUID(), author: rec.author ?? 'system', // 舊種子歸 system }; await installRecipeRecord(c.env.RECIPES, migrated_recipe); migrated++; } catch (e) { errors.push(`${k.name}: ${e instanceof Error ? e.message : String(e)}`); } } return c.json({ success: errors.length === 0, migrated, skipped, errors }); }); // GET /recipes/:id — 讀取 recipe(支援 canonical_id / rec_hash / uuid) 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(本部署 KV 全部版本,含多作者)。 // prefix recipe: 同時命中 recipe:{uuid}(新)與 recipe:{canonical_id}(migration 前舊 key)。 // 去重:同 canonical_id 若已有帶 uuid 的版本,捨棄無 uuid 的舊 key 重複項。 recipesRouter.get('/recipes', async (c) => { const list = await c.env.RECIPES.list({ prefix: 'recipe:' }); const all = (await Promise.all( list.keys.map(k => c.env.RECIPES.get(k.name, 'json') as Promise) )).filter(Boolean) as RecipeDefinition[]; // canonical → 是否已有帶 uuid 的版本 const hasUuidVersion = new Set(all.filter(r => r.uuid).map(r => r.canonical_id)); const recipes = all.filter(r => r.uuid || !hasUuidVersion.has(r.canonical_id)); return c.json({ success: true, recipes, count: recipes.length }); }); // ── 公庫只讀端點(kbdb-base §7.5.4,公→私 pull + 瀏覽的後端基礎)────────────────── // 官方 cypher 開公開只讀(無需 api_key,公庫本就公共)。語意 = 「這是公庫,給 self-hosted pull/瀏覽」, // 含作者維度 + 市場星數(與內部 /recipes 分開命名,公庫的多作者/排序不污染內部)。 /** 從 KBDB 抓 recipe 市場星數(5.1 記的 success/failure)。失敗回 null(端點仍可用,星數缺省)。*/ async function fetchMarketStat( env: Bindings, canonicalId: string, ): Promise<{ success_count: number; failure_count: number } | null> { try { const base = (env.KBDB_BASE_URL ?? 'https://kbdb.finally.click').replace(/\/$/, ''); const headers: Record = {}; if (env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${env.KBDB_INTERNAL_TOKEN}`; const res = await fetch(`${base}/recipe-stats/${encodeURIComponent(canonicalId)}`, { headers }); if (!res.ok) return null; const json = await res.json() as { stat?: { success_count?: number; failure_count?: number } }; if (!json.stat) return null; return { success_count: json.stat.success_count ?? 0, failure_count: json.stat.failure_count ?? 0, }; } catch { return null; } } // 收集本部署 KV 全部 recipe(去重,與 GET /recipes 同邏輯),給公庫端點共用。 async function listAllRecipes(kv: KVNamespace): Promise { const list = await kv.list({ prefix: 'recipe:' }); const all = (await Promise.all( list.keys.map(k => kv.get(k.name, 'json') as Promise), )).filter(Boolean) as RecipeDefinition[]; const hasUuid = new Set(all.filter(r => r.uuid).map(r => r.canonical_id)); return all.filter(r => r.uuid || !hasUuid.has(r.canonical_id)); } // GET /public-recipes?q=&limit=&offset= — 搜尋/列出公庫 recipe。 // 同 canonical_id 回多筆(多作者),各附市場星數,供 CC 依數據選(§7.5.5)。 // 落空(q 無命中)→ 回 found:false + 創作引導(§7.5.6),不回空陣列乾等。 recipesRouter.get('/public-recipes', async (c) => { const q = (c.req.query('q') ?? '').trim().toLowerCase(); const limit = Math.min(Number(c.req.query('limit') ?? 50), 200); const offset = Number(c.req.query('offset') ?? 0); const all = await listAllRecipes(c.env.RECIPES); const matched = q ? all.filter(r => r.canonical_id.toLowerCase().includes(q) || (r.display_name ?? '').toLowerCase().includes(q) || (r.description ?? '').toLowerCase().includes(q)) : all; if (q && matched.length === 0) { // 落空 = 創作入口(§7.5.6):讓 CC 知道「公庫沒有,可自己做一個成為作者」。 return c.json({ found: false, query: q, hint: `公庫無符合「${q}」的 recipe。可自行建立並 submit-p 投稿成為作者(app-store 模型)。`, }); } const page = matched.slice(offset, offset + limit); const withStats = await Promise.all( page.map(async r => ({ uuid: r.uuid, canonical_id: r.canonical_id, author: r.author, display_name: r.display_name, description: r.description, market_stat: await fetchMarketStat(c.env, r.uuid ?? r.canonical_id), // §7.5.h per-uuid })), ); return c.json({ found: true, recipes: withStats, count: matched.length }); }); // GET /public-recipes/:canonical_id?author= — 取單一 recipe 全文(pull 用)。 // 不指定 author → 回市場最佳版本(success_count 最高)。落空 → found:false 創作引導(§7.5.6)。 recipesRouter.get('/public-recipes/:canonical_id', async (c) => { const canonicalId = c.req.param('canonical_id').trim().toLowerCase(); const author = c.req.query('author'); const all = await listAllRecipes(c.env.RECIPES); let versions = all.filter(r => r.canonical_id === canonicalId); if (author) versions = versions.filter(r => r.author === author); if (versions.length === 0) { return c.json({ found: false, canonical_id: canonicalId, hint: `公庫無 recipe「${canonicalId}」${author ? `(author=${author})` : ''}。可自行建立並 submit-p 投稿成為作者(app-store 模型)。`, }); } // 多作者 → 選市場最佳(success_count 最高;無 stat 視為 0)。 // §7.5.h:星數 per-uuid(5.1 記 uuid)→ 能真正區分 Leo 版/John 版。舊資料無 uuid fallback canonical_id。 let best = versions[0]; let bestStat: { success_count: number; failure_count: number } | null = null; let bestScore = -1; for (const v of versions) { const stat = await fetchMarketStat(c.env, v.uuid ?? v.canonical_id); const score = stat?.success_count ?? 0; if (score > bestScore) { bestScore = score; best = v; bestStat = stat; } } return c.json({ found: true, recipe: best, market_stat: bestStat }); }); // DELETE /recipes/:id — 刪除(依 UUID 模型清掉 recipe:{uuid} + installed + canonical 清單裡的該 uuid + 舊 key) 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); const canonicalId = recipe.canonical_id; const ops: Promise[] = [ c.env.RECIPES.delete(`idx:${recipe.hash_id}`), c.env.RECIPES.delete(`recipe:${canonicalId}`), // 舊 key(若存在) ]; if (recipe.uuid) { ops.push(c.env.RECIPES.delete(`recipe:${recipe.uuid}`)); // 從 canonical 清單移除此 uuid;若清單空了連 installed 一起清 const listRaw = await c.env.RECIPES.get(kIdxCanonical(canonicalId)); const uuids: string[] = listRaw ? JSON.parse(listRaw) : []; const left = uuids.filter(u => u !== recipe.uuid); if (left.length > 0) { ops.push(c.env.RECIPES.put(kIdxCanonical(canonicalId), JSON.stringify(left))); // installed 若指向被刪的 uuid → 改指剩下第一個 const installed = await c.env.RECIPES.get(kIdxInstalled(canonicalId)); if (installed === recipe.uuid) ops.push(c.env.RECIPES.put(kIdxInstalled(canonicalId), left[0])); } else { ops.push(c.env.RECIPES.delete(kIdxCanonical(canonicalId))); ops.push(c.env.RECIPES.delete(kIdxInstalled(canonicalId))); } } await Promise.all(ops); return c.json({ success: true, deleted: recipe.uuid ?? canonicalId }); }); /** * 用 canonical_id / rec_hash / uuid 查 recipe(執行時的解析入口)。 * UUID 身份模型(§7.5.5)+ 向後相容(migration 前的舊 key): * 1. id 是 uuid(recipe:{uuid} 直接存在)→ 直接回。 * 2. rec_xxxxxxxx → idx:{hash} 反查 canonical_id → 再走 canonical 解析。 * 3. canonical_id → 先查 idx:installed:{canonical_id}(本部署安裝的唯一版本)→ recipe:{uuid}; * 查不到 fallback 舊 key recipe:{canonical_id}(種子 / migration 前資料)。 * 執行鏈路(component-loader/auth-dispatcher/credential-injector)都經此 → 不破執行。 */ export async function resolveRecipe( id: string, kv: KVNamespace, ): Promise { // 1. 直接 uuid(pull / market 指定版本時用) const direct = await kv.get(`recipe:${id}`, 'json') as RecipeDefinition | null; if (direct && direct.uuid) return direct; // direct 命中但無 uuid = 舊 key recipe:{canonical_id}(migration 前)→ 仍可用,但繼續嘗試 installed 拿新版 // (installed 優先:migration 後新版在 recipe:{uuid},舊 key 為 fallback) // 2. rec_hash 反查 canonical_id let canonicalId = id; if (id.startsWith('rec_')) { const looked = await kv.get(`idx:${id}`); if (!looked) return direct; // hash 查不到,回 step1 結果(通常 null) canonicalId = looked; } // 3. canonical → installed uuid → recipe:{uuid};fallback 舊 key const installedUuid = await kv.get(kIdxInstalled(canonicalId)); if (installedUuid) { const byUuid = await kv.get(`recipe:${installedUuid}`, 'json') as RecipeDefinition | null; if (byUuid) return byUuid; } // fallback:舊 key recipe:{canonical_id}(direct 若正是它,已在手上) return direct ?? (await kv.get(`recipe:${canonicalId}`, '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; // path:注入 endpoint URL path 的 secret(auth-recipe.md §六,2026-05-29 加)。 // 解 telegram 類「token 在 URL path」(/bot{token}/)—— header/query/body 都不適用。 // key = 模板變數名(recipe endpoint 用 {{auth.K}} 引用),value = {{secret.X}} 模板。 // auth_static_key WASM 解密後輸出為 auth_path → auth-dispatcher 帶進 _auth_path → makeRecipeRunner interpolate。 path?: 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); // help_url 一律必填(不分冷熱門服務):每個 secret 都要留官方文件連結, // 讓 AI/使用者不必猜該去哪設定、也不會搜到錯誤來源(壓測 §recipe 設定說明)。 const missingHelp = body.required_secrets.filter(s => !s.help_url || !/^https?:\/\//.test(s.help_url)); if (missingHelp.length > 0) { return c.json({ success: false, error: `每個 required_secret 必須有 help_url(官方文件連結,http(s)://)。缺:${missingHelp.map(s => s.key).join(', ')}`, requires: 'help_url', }, 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 }); });