Files
Arcrun/cypher-executor/src/routes/recipes.ts
T
uncle6me-web c1a06df68f feat(exposure): 完全移除 acr push 暴露 consent 閘 (Arcrun#13 P1)
leo 2026-06-29 拍板:arcrun 是給 AI 用的系統,push/暴露不再需要人類確認。
- 刪 cypher-executor/src/lib/exposure-consent.ts(server 閘,MCP push 的真正擋點)
- 刪 cli/src/lib/exposure-warning.ts(CLI 互動 + 非 TTY 拒絕)
- recipes.ts / webhooks-named.ts:移除 checkExposureConsent 403 閘,直接放行
- recipe.ts / push.ts:移除 obtainExposureConsent 呼叫,不再 prompt/拒絕
- init-seed / seed-api-recipes:移除種子層級 consent
- exposure_consent 欄位降為向後相容(讀舊 record 不報錯,不再寫入/檢查)
不補審計線索、不做替代防護(leo:先拿掉,出問題再設置)。
tsc 全綠(cypher-executor + cli)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 20:58:32 +08:00

564 lines
25 KiB
TypeScript
Raw 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.
/**
* /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 自哪個 uuidLeo 改 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<string, string>;
body?: Record<string, unknown>;
/**
* 此 recipe 要用哪個 auth recipeauth_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 keykbdb-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}`;
/**
* 寫一份 recipeUUID 身份模型):給定 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<void> {
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<RecipeDefinition>;
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 舊 keymigration 前的種子)。
const existing = await resolveRecipe(canonicalId, c.env.RECIPES);
// 暴露 consent 閘已移除(leo 2026-06-29Arcrun#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_consentmindset §6:暴露需人類明示同意)。
// 投稿者帶的 stat 只當「存證」(誰在何時投了什麼、聲稱打通幾次),寫進 KBDB 一筆
// recipe_submission entry**不**併進 recipe-stat 真實計數(避免自報數污染市場數據,§7.3)。
// 市場信任靠真實使用累積(5.1),不拿投稿者自報數當門檻 → 不造債。
recipesRouter.post('/recipes/submit', async (c) => {
let body: Partial<RecipeDefinition> & {
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-29Arcrun#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<string, string> = { '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<RecipeDefinition | null>)
)).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<string, string> = {};
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<RecipeDefinition[]> {
const list = await kv.list({ prefix: 'recipe:' });
const all = (await Promise.all(
list.keys.map(k => kv.get(k.name, 'json') as Promise<RecipeDefinition | null>),
)).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-uuid5.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<unknown>[] = [
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 是 uuidrecipe:{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<RecipeDefinition | null> {
// 1. 直接 uuidpull / 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<string, string>; // e.g. { Authorization: "Bearer {{secret.token}}" }
query?: Record<string, string>;
body?: Record<string, string>;
// path:注入 endpoint URL path 的 secretauth-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<string, string>;
}
export interface AuthRecipeDefinition {
kind: 'auth_recipe';
service: string; // canonical_ide.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 recipeKV key: auth_recipe:{service}*/
export async function resolveAuthRecipe(
service: string,
kv: KVNamespace,
): Promise<AuthRecipeDefinition | null> {
return kv.get(`auth_recipe:${service}`, 'json');
}
// POST /auth-recipes — 新增或更新 auth recipe
recipesRouter.post('/auth-recipes', async (c) => {
let body: Partial<AuthRecipeDefinition>;
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 });
});