feat(arcrun): Phase 2 降級假零件成 recipe + credential 鏈路修復
Phase 1(credential 注入鏈路):
- 修 auth_static_key ENCRYPTION_KEY 漂移根因(見 docs/incidents)
- component-loader: readBodyOnce() 修 "Body has already been used"
Phase 2(降級假零件成 recipe,registry/components 33→22):
- 引擎: RecipeDefinition 加 auth_service(多 recipe 共用一把 auth)
auth-dispatcher 先查 recipe.auth_service 再 fallback componentId
- 引擎: auth_static_key inject.path + makeRecipeRunner {{auth.K}}
(endpoint 可插 secret,解 telegram 類 URL-path token)
- 引擎: makeRecipeRunner auto-body 剔除 _ 前綴內部欄位
- 降級並刪除: kbdb_{get,create_block,patch_block,delete,ingest}
gmail/telegram/line_notify/google_sheets(改建為 recipe)
- 刪除: ai_transform_{compile,run}(Arcrun 是 AI 呼叫的工具,
工作流不該內嵌 AI 節點回頭呼叫 AI)
- deferred(源碼暫留): claude_api/km_writer(交 Mira 收成工作流)、
kbdb_upsert_block(交 KBDB 出 upsert endpoint)
文件: DECISIONS.md(工作流是 default/建零件人類閘門/AI→工具)、
BACKLOG.md、auth-recipe.md §七、docs/incidents 加密 key 漂移
驗收: KBDB get/create/ingest/delete 2xx;telegram auth 注入綠;
gmail/sheets/line recipe 正確但缺 credential 未驗收;
kbdb patch 403 為 KBDB 端 bug(已交 kbdb/docs)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
|
||||
import type { Bindings } from '../types';
|
||||
import { resolveAuthRecipe } from '../routes/recipes';
|
||||
import { resolveAuthRecipe, resolveRecipe } from '../routes/recipes';
|
||||
import { wasmWorkerUrl } from '../lib/component-loader';
|
||||
|
||||
/** 對應 Phase 1-4 會部署的 auth primitive Worker */
|
||||
@@ -48,7 +48,17 @@ export async function tryAuthDispatch(
|
||||
return null;
|
||||
}
|
||||
|
||||
const recipe = await resolveAuthRecipe(componentId, env.RECIPES);
|
||||
// 決定 auth service name:
|
||||
// 1. 若 API recipe 宣告了 auth_service(例 recipe:kbdb_get → "kbdb")→ 用它,
|
||||
// 讓多個 recipe 共用同一把 auth_recipe(不必每個 action 複製 auth recipe)。
|
||||
// 2. 否則 fallback 到把 componentId 當 service name(向後相容舊行為)。
|
||||
let service = componentId;
|
||||
const apiRecipe = await resolveRecipe(componentId, env.RECIPES);
|
||||
if (apiRecipe?.auth_service) {
|
||||
service = apiRecipe.auth_service;
|
||||
}
|
||||
|
||||
const recipe = await resolveAuthRecipe(service, env.RECIPES);
|
||||
if (!recipe) return null;
|
||||
if (!SUPPORTED_PRIMITIVES.has(recipe.primitive)) return null;
|
||||
|
||||
@@ -61,7 +71,7 @@ export async function tryAuthDispatch(
|
||||
body: JSON.stringify({
|
||||
action: 'authenticate',
|
||||
api_key: apiKey,
|
||||
service: componentId,
|
||||
service,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -78,6 +88,7 @@ export async function tryAuthDispatch(
|
||||
auth_headers?: Record<string, string>;
|
||||
auth_query?: Record<string, string>;
|
||||
auth_body?: Record<string, string>;
|
||||
auth_path?: Record<string, string>;
|
||||
} | null;
|
||||
|
||||
if (!result || result.success === false) {
|
||||
@@ -91,5 +102,6 @@ export async function tryAuthDispatch(
|
||||
_auth_headers: result.auth_headers ?? {},
|
||||
_auth_query: result.auth_query ?? {},
|
||||
_auth_body: result.auth_body ?? {},
|
||||
_auth_path: result.auth_path ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,11 +36,9 @@ import type { Bindings, ComponentRunner, ServiceBinding } from '../types';
|
||||
const WASM_HTTP_RUNNER_IDS: ReadonlySet<string> = new Set([
|
||||
// 通用 HTTP 零件
|
||||
'http_request',
|
||||
// 下一階段待降級為 recipe(http_request + 固定設定)
|
||||
'gmail',
|
||||
'telegram',
|
||||
'line_notify',
|
||||
'google_sheets',
|
||||
// gmail / telegram / line_notify / google_sheets 已降級為 recipe(2026-05-29 Phase 2):
|
||||
// recipe:gmail_send / telegram_send / line_notify_send / google_sheets_read|append
|
||||
// 走 step 6 KV recipe 解析,不再是零件。零件目錄已刪。
|
||||
'cron',
|
||||
// Auth primitives
|
||||
'auth_static_key',
|
||||
@@ -80,8 +78,8 @@ const LOGIC_BINDING_MAP: Record<string, keyof Bindings> = {
|
||||
number_ops: 'SVC_NUMBER_OPS',
|
||||
date_ops: 'SVC_DATE_OPS',
|
||||
validate_json: 'SVC_VALIDATE_JSON',
|
||||
ai_transform_compile: 'SVC_AI_TRANSFORM_COMPILE',
|
||||
ai_transform_run: 'SVC_AI_TRANSFORM_RUN',
|
||||
// ai_transform_compile / ai_transform_run 已刪除(2026-05-29):
|
||||
// Arcrun 是 AI 呼叫的工具,工作流不該內嵌 AI 節點回頭呼叫 AI(n8n 才需要,因它沒大腦)。
|
||||
};
|
||||
|
||||
export function createComponentLoader(env: Bindings) {
|
||||
@@ -272,12 +270,20 @@ function makeRecipeRunner(recipe: import('../routes/recipes').RecipeDefinition):
|
||||
return async (ctx: unknown) => {
|
||||
const ctxObj = (ctx && typeof ctx === 'object') ? ctx as Record<string, unknown> : {};
|
||||
|
||||
// 模板替換:把 {{key}} 換成 ctx 裡的值
|
||||
// 模板替換:{{key}} 從 ctx 取;{{auth.K}} 從 _auth_path 取
|
||||
// (_auth_path 由 auth primitive 解密後注入,供 URL path 用,如 telegram /bot{{auth.token}}/)
|
||||
const authPath = (ctxObj._auth_path as Record<string, string>) ?? {};
|
||||
const interpolate = (s: string) =>
|
||||
s.replace(/\{\{(\w+)\}\}/g, (_, k) => String(ctxObj[k] ?? ''));
|
||||
s.replace(/\{\{(auth\.)?(\w+)\}\}/g, (_, authPrefix, k) =>
|
||||
String(authPrefix ? (authPath[k] ?? '') : (ctxObj[k] ?? '')),
|
||||
);
|
||||
|
||||
const method = (recipe.method ?? 'POST').toUpperCase();
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
const authHeaders = (ctxObj._auth_headers as Record<string, string>) ?? {};
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...authHeaders,
|
||||
};
|
||||
for (const [k, v] of Object.entries(recipe.headers ?? {})) {
|
||||
headers[k] = interpolate(v);
|
||||
}
|
||||
@@ -287,7 +293,12 @@ function makeRecipeRunner(recipe: import('../routes/recipes').RecipeDefinition):
|
||||
if (recipe.body) {
|
||||
bodyStr = interpolate(JSON.stringify(recipe.body));
|
||||
} else if (method !== 'GET') {
|
||||
bodyStr = JSON.stringify(ctxObj);
|
||||
// 沒指定 body template → 用 ctx 當 body,但剔除 _ 前綴的內部欄位
|
||||
// (_path / _auth_headers / _auth_query / _auth_body 不該漏進下游請求)
|
||||
const bodyObj = Object.fromEntries(
|
||||
Object.entries(ctxObj).filter(([k]) => !k.startsWith('_')),
|
||||
);
|
||||
bodyStr = JSON.stringify(bodyObj);
|
||||
}
|
||||
|
||||
const res = await fetch(interpolate(recipe.endpoint), {
|
||||
@@ -296,7 +307,7 @@ function makeRecipeRunner(recipe: import('../routes/recipes').RecipeDefinition):
|
||||
body: bodyStr,
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => res.text());
|
||||
const data = await readBodyOnce(res);
|
||||
return { success: res.ok, status: res.status, data };
|
||||
};
|
||||
}
|
||||
@@ -338,8 +349,17 @@ function makeAuthRecipeRunner(recipe: AuthRecipeDefinition): ComponentRunner {
|
||||
body: method !== 'GET' ? JSON.stringify(bodyObj) : undefined,
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => res.text());
|
||||
const data = await readBodyOnce(res);
|
||||
return { success: res.ok, status: res.status, data };
|
||||
};
|
||||
}
|
||||
|
||||
// 讀 response body 一次:先取 text,再嘗試 parse JSON。
|
||||
// 不可用 `res.json().catch(() => res.text())` —— res.json() 失敗時 body 已被消費,
|
||||
// 第二次讀會丟 "Body has already been used"。
|
||||
async function readBodyOnce(res: Response): Promise<unknown> {
|
||||
const text = await res.text();
|
||||
try { return JSON.parse(text); }
|
||||
catch { return text; }
|
||||
}
|
||||
|
||||
|
||||
@@ -17,8 +17,9 @@ executeRouter.post('/execute', async (c) => {
|
||||
}
|
||||
|
||||
const { graph, context } = parsed.data;
|
||||
const apiKey = c.req.header('x-arcrun-api-key') ?? undefined;
|
||||
const loader = createComponentLoader(c.env);
|
||||
const executor = new GraphExecutor(loader);
|
||||
const executor = new GraphExecutor(loader, undefined, c.env, apiKey);
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
|
||||
@@ -28,6 +28,12 @@ export interface RecipeDefinition {
|
||||
method?: string; // GET | POST | PUT | PATCH | DELETE,預設 POST
|
||||
headers?: Record<string, string>;
|
||||
body?: Record<string, unknown>;
|
||||
/**
|
||||
* 此 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;
|
||||
@@ -64,6 +70,7 @@ recipesRouter.post('/recipes', async (c) => {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user