1 Commits

Author SHA1 Message Date
uncle6me-web 90777e3877 fix(auth-recipe): 回灌 telegram/line_notify/kbdb 種子,補 AuthInjectSpec.path(修 source/live 漂移)
issue #13 升級:根因不只 acr parts 殘留,是 auth-recipe-seeds.ts source 漏了 3 個 static_key
auth recipe(telegram/line_notify/kbdb),但 prod KV 手動 seed 過 → 任何全新 self-hosted
`POST /init/seed` 只 seed 23 個、漏 telegram → self-host(leo21c/mira)telegram_send 的
auth_service:'telegram' resolve 不到 → {{auth.bot_token}} 注入空 → telegram 發訊壞掉。

實測 smoking gun(2026-06-29):
- prod cypher.arcrun.dev/auth-recipes = 27 個(含 telegram)
- self-host arcrun-cypher-executor.leo21c.workers.dev = 23 個(無 telegram/line_notify/kbdb)

修法(資料/型別,非業務邏輯,守 mindset §1 不建 component):
- auth-recipe-seeds.ts 補 telegram(inject.path bot_token)/line_notify(header Bearer)/kbdb(header Bearer)
- recipes.ts AuthInjectSpec 補 path?(WASM auth_static_key + SDD §六早有此欄、source 介面漏 → tsc 擋)
- google_user(oauth2)暫不回灌:內嵌 client_secret 不可進 git + 介面無 oauth2 欄 → 留 Phase D

形態取自 prod GET /auth-recipes/{service};設計權威 auth-recipe.md §六(telegram path)/§七(kbdb 共用)。
telegram 自此與 notion(header)/gsheets(service_account) 同一條 recipe+auth-recipe 鏈,一致。
tsc 全綠;local 模擬 token-in-path 注入 PASS。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 12:37:19 +08:00
5 changed files with 171 additions and 200 deletions
+71 -23
View File
@@ -1,17 +1,7 @@
/**
* acr parts — 列出所有可用零件(component)」(內建清單,不依賴 registry.arcrun.dev
* acr parts — 列出所有可用零件(內建清單,不依賴 registry.arcrun.dev
* acr parts scaffold <component> — 輸出 config 範本(可直接貼入 workflow.yaml
* acr parts publish <component> — 提交零件至公眾 registryPhase 5,封測後)
*
* ⚠️ 分類原則(2026-06-29issue #13 / component-gatekeeping W3):
* - **零件(component= 靜態清單**WASM,只能走 GitHub PR + 人 merge 新增(mindset §4 人類閘門),
* 固定慢增 → 用 BUILTIN_COMPONENTS hardcode 反映真實,正確。
* - **recipe / auth-recipe / workflow = 動態,存在 store**:任何人 `acr recipe push` 即新增 →
* **絕不可 hardcode 在這裡**(會「submitted = invisible」+ 誤導查錯表)。它們各有動態清單:
* recipe → `acr recipe list`GET /recipes)|auth-recipe → `acr auth-recipe list`GET /auth-recipes)|workflow → `acr list`GET /webhooks/named
* - **跨類找東西用 `acr search <term>`**fan-out 上述 4 個來源,不必先知道它是哪一類)。
* 歷史教訓:本檔曾把 gmail_send/telegram_send/notion 等 5 個 recipe hardcode 進零件清單 →
* 誤導「telegram 是零件 / 走不同機制」。已移除(W3.1)。
*/
import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
@@ -35,7 +25,7 @@ interface ComponentDef {
credentials_required?: CredentialRequirement[];
}
export const BUILTIN_COMPONENTS: ComponentDef[] = [
const BUILTIN_COMPONENTS: ComponentDef[] = [
// ── 控制類(Logic) ────────────────────────────────────────────────────────
{
canonical_id: 'if_control',
@@ -203,9 +193,72 @@ export const BUILTIN_COMPONENTS: ComponentDef[] = [
body:
key: "{{value}}"`,
},
// ⚠️ 此處曾 hardcode gmail_send / google_sheets_append / telegram_send / line_notify_send / notion
// 這 5 個是 **recipe(動態,存 store)不是零件(component**,已於 W3.1 移除(issue #13 根治)。
// 要找它們:`acr search <term>`(跨類)或 `acr recipe list` / `acr auth-recipe list`(動態清單)。
{
canonical_id: 'gmail_send',
display_name: 'Gmail Send',
category: 'api',
description: '透過 recipe 寄送 Gmailrecipe gmail_sendauth: google service_account)。注意:Gmail 讀取尚無對應 recipe(待 seed 補 gmail_list)。',
config_example:
` # 寄信用內建 recipe gmail_sendbody 帶 rawbase64url MIME
mail_node:
component: gmail_send
method: POST
body:
raw: "{{mime_base64url}}"`,
},
{
canonical_id: 'google_sheets_append',
display_name: 'Google Sheets Append',
category: 'api',
description: '透過 recipe 追加一列到 Sheetsrecipe google_sheets_append;讀取用 google_sheets_read。auth: google service_account',
config_example:
` # 追加用內建 recipe google_sheets_append(讀取改用 google_sheets_read
sheet_node:
component: google_sheets_append
method: POST
_path: "/v4/spreadsheets/{{spreadsheet_id}}/values/Sheet1!A:B:append?valueInputOption=RAW"
body:
values:
- ["{{name}}", "{{email}}"]`,
},
{
canonical_id: 'telegram_send',
display_name: 'Telegram Send',
category: 'api',
description: '透過 recipe 發送 Telegram 訊息(recipe telegram_sendauth: static_keytoken 注入 URL path',
config_example:
` # 內建 recipe telegram_sendtoken 由 auth 注入 URL,不寫在這)
tg_node:
component: telegram_send
chat_id: "123456789"
text: "{{message}}"`,
},
{
canonical_id: 'line_notify_send',
display_name: 'LINE Notify',
category: 'api',
description: '透過 recipe 發送 LINE Notify 通知(recipe line_notify_sendauth: static_key Bearer',
config_example:
` # 內建 recipe line_notify_sendPOST notifybody form-urlencoded
line_node:
component: line_notify_send
method: POST
body:
message: "{{notification}}"`,
},
{
canonical_id: 'notion',
display_name: 'Notion',
category: 'api',
description: '透過 recipe 操作 Notion API(需先 acr recipe push',
config_example:
` # 先上傳 recipeacr recipe push notion.yaml
notion_node:
component: rec_xxxxxxxx # acr recipe push 後得到的 hash
database_id: "{{db_id}}"
properties:
Name: "{{title}}"`,
},
];
// ── 指令實作 ──────────────────────────────────────────────────────────────────
@@ -224,7 +277,7 @@ export async function cmdParts(): Promise<void> {
grouped[comp.category].push(comp);
}
console.log(chalk.bold(`\n arcrun 零件庫(${BUILTIN_COMPONENTS.length} 個內建零件 / component,靜態 PR-only\n`));
console.log(chalk.bold(`\n arcrun 零件庫(${BUILTIN_COMPONENTS.length} 個內建零件)\n`));
for (const cat of ['logic', 'data', 'ai', 'api']) {
const comps = grouped[cat];
@@ -241,13 +294,8 @@ export async function cmdParts(): Promise<void> {
}
console.log(chalk.gray(' 使用 acr parts scaffold <component> 取得 config 範本'));
console.log('');
console.log(chalk.bold(' 零件之外(動態,存在 store,不在上面這份靜態清單):'));
console.log(chalk.gray(' • API recipe(打外部服務) acr recipe list'));
console.log(chalk.gray(' • 第三方服務認證(auth-recipe acr auth-recipe list'));
console.log(chalk.gray(' • 已部署的 workflow acr list'));
console.log(chalk.cyan(' • 不確定某能力是哪一類? acr search <關鍵字> ← 跨類一次搜,免先選表'));
console.log('');
console.log(chalk.gray(' 第三方服務整合(Notion/Slack/GitHub 等):acr auth-recipe list'));
console.log(chalk.gray(' API 整合類若需打自訂服務,請用 acr recipe push 建立 recipe\n'));
}
export async function cmdPartsScaffold(componentId: string): Promise<void> {
-170
View File
@@ -1,170 +0,0 @@
/**
* acr search <term> — 跨類統一搜尋(component / recipe / auth-recipe / workflow
*
* 為什麼存在(issue #13leo 2026-06-29):
* 舊世界要找一個能力(如 "telegram")必須**先決定**「它是零件?recipe?還是 workflow?」才能查對的清單。
* 但查的人(人或 AI)在查到答案前**根本不知道**它屬哪一類 →「先知道分類」成了查詢的前置條件,倒因為果。
* leo:「連我都會查錯表。」所以這不是粗心,是搜尋設計缺陷。
* 本指令一次掃全部類別、依類別分組回 counts + 命中 → 查的人不必先選表,看一眼就知道 telegram 是 recipe。
*
* 薄殼原則(rule 07 §2):本指令只做 fan-out 呼叫既有清單來源 + 過濾 + 分組 + 印出(介面層的暴露/格式化)。
* - component:靜態(BUILTIN_COMPONENTSPR-only,複用 parts.ts 同一份,不另存)。
* - recipe / auth-recipe / workflow**動態**,從 store 端點即時抓(GET /recipes、/auth-recipes、/webhooks/named)。
* 不在介面層拼裝任何能力;過濾是對 API 回傳值做的純客戶端格式化(§2.3 允許)。
*/
import chalk from 'chalk';
import { loadConfig, getCypherExecutorUrl } from '../lib/config.js';
import { BUILTIN_COMPONENTS } from './parts.js';
interface Hit {
id: string; // canonical_id / service / workflow name
label: string; // display_name / 描述用名稱
detail?: string; // description(截斷)
}
interface CategoryResult {
key: 'component' | 'recipe' | 'auth-recipe' | 'workflow';
title: string;
dynamic: boolean; // 是否來自 store(動態)
listHint: string; // 列全部的指令
hits: Hit[];
error?: string; // 該來源抓取失敗(離線/服務不可用)→ 降級顯示,不中斷其他類
}
/** term 命中:id / label / detail 任一含 term(不分大小寫) */
function matches(term: string, ...fields: Array<string | undefined>): boolean {
const t = term.toLowerCase();
return fields.some((f) => (f ?? '').toLowerCase().includes(t));
}
function trim(s: string | undefined, n = 70): string | undefined {
if (!s) return undefined;
return s.length > n ? s.slice(0, n - 1) + '…' : s;
}
async function fetchJson(url: string, headers: Record<string, string>): Promise<unknown> {
const res = await fetch(url, { method: 'GET', headers });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
export async function cmdSearch(term: string): Promise<void> {
const q = (term ?? '').trim();
if (!q) {
console.error(chalk.red(' 請提供搜尋關鍵字:acr search <term>'));
process.exit(1);
}
const config = loadConfig();
const baseUrl = getCypherExecutorUrl(config);
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (config.api_key) headers['X-Arcrun-API-Key'] = config.api_key;
// ── component(靜態,本地,永不失敗)──
const componentRes: CategoryResult = {
key: 'component',
title: '零件 component(靜態 · PR-only · WASM',
dynamic: false,
listHint: 'acr parts',
hits: BUILTIN_COMPONENTS
.filter((c) => matches(q, c.canonical_id, c.display_name, c.description))
.map((c) => ({ id: c.canonical_id, label: c.display_name, detail: trim(c.description) })),
};
// ── recipe / auth-recipe / workflow(動態,store;各自獨立 try,互不連坐)──
const dynamicSpecs: Array<{
key: CategoryResult['key'];
title: string;
listHint: string;
url: string;
extract: (data: unknown) => Hit[];
}> = [
{
key: 'recipe',
title: 'API recipe(動態 · store · 打外部服務)',
listHint: 'acr recipe list',
url: `${baseUrl}/recipes`,
extract: (data) => {
const arr = (data as { recipes?: Array<{ canonical_id: string; display_name?: string; description?: string }> }).recipes ?? [];
return arr
.filter((r) => matches(q, r.canonical_id, r.display_name, r.description))
.map((r) => ({ id: r.canonical_id, label: r.display_name ?? r.canonical_id, detail: trim(r.description) }));
},
},
{
key: 'auth-recipe',
title: '第三方服務認證 auth-recipe(動態 · store',
listHint: 'acr auth-recipe list',
url: `${baseUrl}/auth-recipes`,
extract: (data) => {
const arr = (data as { recipes?: Array<{ service: string; display_name?: string; description?: string }> }).recipes ?? [];
return arr
.filter((r) => matches(q, r.service, r.display_name, r.description))
.map((r) => ({ id: r.service, label: r.display_name ?? r.service, detail: trim(r.description) }));
},
},
{
key: 'workflow',
title: '已部署 workflow(動態 · store',
listHint: 'acr list',
url: `${baseUrl}/webhooks/named`,
extract: (data) => {
const arr = (data as { workflows?: Array<{ name: string; description?: string }> }).workflows ?? [];
return arr
.filter((w) => matches(q, w.name, w.description))
.map((w) => ({ id: w.name, label: w.name, detail: trim(w.description) }));
},
},
];
const dynamicResults = await Promise.all(
dynamicSpecs.map(async (spec): Promise<CategoryResult> => {
try {
const data = await fetchJson(spec.url, headers);
return { key: spec.key, title: spec.title, dynamic: true, listHint: spec.listHint, hits: spec.extract(data) };
} catch (e) {
return { key: spec.key, title: spec.title, dynamic: true, listHint: spec.listHint, hits: [], error: e instanceof Error ? e.message : String(e) };
}
}),
);
const all: CategoryResult[] = [componentRes, ...dynamicResults];
// ── 印出:先一行 counts 摘要(看一眼就知道屬哪類),再各類明細 ──
console.log(chalk.bold(`\n 搜尋「${q}\n`));
const countLine = all
.map((r) => {
const n = r.error ? '?' : String(r.hits.length);
const c = r.error ? chalk.yellow(`${r.key}:${n}`) : (r.hits.length > 0 ? chalk.green(`${r.key}:${n}`) : chalk.gray(`${r.key}:0`));
return c;
})
.join(' ');
console.log(' ' + countLine + '\n');
let anyHit = false;
for (const r of all) {
if (r.error) {
console.log(chalk.yellow(` ${r.title} — 無法讀取(${r.error});離線時改用 ${chalk.cyan(r.listHint)}`));
console.log('');
continue;
}
if (r.hits.length === 0) continue;
anyHit = true;
console.log(chalk.bold.underline(` ${r.title}`));
for (const h of r.hits) {
const label = h.label && h.label !== h.id ? ` ${h.label}` : '';
console.log(` ${chalk.cyan(h.id.padEnd(24))}${label}`);
if (h.detail) console.log(chalk.gray(` ${h.detail}`));
}
console.log('');
}
if (!anyHit && !all.some((r) => r.error)) {
console.log(chalk.yellow(` 四類都沒有命中「${q}」。`));
console.log(chalk.gray(' • 想新增打外部服務的能力 → acr recipe push(建 recipe'));
console.log(chalk.gray(' • 服務認證設定 → acr auth-recipe list 找對應服務'));
console.log(chalk.gray(' • 零件(WASM)只能走 GitHub PR 新增(稀有)'));
console.log('');
}
}
-7
View File
@@ -20,7 +20,6 @@ import { cmdValidate } from './commands/validate.js';
import { cmdParts, cmdPartsScaffold, cmdPartsPublish } from './commands/parts.js';
import { cmdRecipePush, cmdRecipeList, cmdRecipeDelete, cmdRecipeSearch, cmdRecipePull, cmdRecipeSubmitP } from './commands/recipe.js';
import { cmdList } from './commands/list.js';
import { cmdSearch } from './commands/search.js';
import { cmdLogs } from './commands/logs.js';
import { cmdUpdate } from './commands/update.js';
import { cmdInstallHarness } from './commands/install-harness.js';
@@ -206,12 +205,6 @@ program
.description('列出 CF KV 中所有已部署的 workflow')
.action(() => cmdList());
// acr search <term> — 跨類統一搜尋(component / recipe / auth-recipe / workflow),免先選表
program
.command('search <term>')
.description('跨類搜尋能力(零件/recipe/auth-recipe/workflow),一次掃全部、依類別回 counts+命中')
.action((term: string) => cmdSearch(term));
// acr logs <workflow_name>
program
.command('logs <workflow>')
@@ -11,6 +11,13 @@ import type { AuthRecipeDefinition } from '../routes/recipes';
const now = Date.now();
// ⚠️ 已知 source/live drift2026-06-29 盤點,未在此檔修):
// prod RECIPES KV 另有 `auth_recipe:google_user`primitive: oauth2)。**故意不回灌 source**,原因:
// (1) 它內嵌 client_secretGOCSPX-...= 機密,不可進 gitwiki-secret-scan / 一般安全);
// (2) 本檔的 AuthRecipeDefinition 介面尚無 oauth2 欄位(client_id/secret/token_endpoint/scopes),
// 回灌前需先擴 schema + 把 secret 改成部署期注入(wrangler secret / env),屬獨立工作。
// → google_user 留待 oauth2 seed 機制(含 secret 注入)獨立處理;本次只修無機密的 static_key 漂移。
export const AUTH_RECIPE_SEEDS: AuthRecipeDefinition[] = [
// ── Static Key 類 ──────────────────────────────────────────────────────────
@@ -556,6 +563,94 @@ export const AUTH_RECIPE_SEEDS: AuthRecipeDefinition[] = [
updated_at: now,
},
// ── 訊息 / URL-path 注入類(static_key)────────────────────────────────────
//
// 2026-06-29 補:以下三個 static_key auth recipe 一直存在於 prod RECIPES KV(手動 seed 過),
// 但**從未進 source seed**auth-recipe-seeds.ts)→ 任何全新 self-hosted `POST /init/seed`
// 只會 seed 23 個、漏掉 telegram/line_notify/kbdb → self-hostmira/leo21c)的 telegram 發訊
// 走不通(telegram_send 的 auth_service:'telegram' 找不到 auth recipe → {{auth.bot_token}} 注入空)。
// 這正是「source vs live drift = 假綠」(總管反覆踩的同一類)。把 prod 現役定義回灌 source
// 讓 official 與 self-host 共用同一份種子。形態取自 prod GET /auth-recipes/{service}2026-06-29)。
// 設計權威:auth-recipe.md §六(line 70-71, telegram path 注入) + §七(line 150-151, kbdb 共用)。
{
kind: 'auth_recipe',
service: 'telegram',
version: 1,
primitive: 'static_key',
base_url: 'https://api.telegram.org',
display_name: 'Telegram Bot',
description: 'Telegram Bot API — sendMessage 等(bot token 注入 URL path /bot{token}/',
required_secrets: [
{
key: 'telegram_bot_token',
label: 'Bot Token(從 @BotFather 取得)',
help: '在 Telegram 對 @BotFather 送 /newbot 建立 bot,取得格式為 123456:ABC... 的 token',
help_url: 'https://core.telegram.org/bots/features#botfather',
},
],
// path 注入:recipe:telegram_send 的 endpoint 用 {{auth.bot_token}} 從 _auth_path 取值
// auth_static_key WASM 解密後輸出 auth_path → auth-dispatcher 帶進 _auth_path
// → makeRecipeRunner interpolate)。token 不落 header/query/body,符合 Telegram 的 URL-path 慣例。
inject: {
path: {
bot_token: '{{secret.telegram_bot_token}}',
},
},
created_at: now,
updated_at: now,
},
{
kind: 'auth_recipe',
service: 'line_notify',
version: 1,
primitive: 'static_key',
base_url: 'https://notify-api.line.me',
display_name: 'LINE Notify',
description: 'LINE Notify — 推播訊息(static_key Bearer',
required_secrets: [
{
key: 'line_token',
label: 'LINE Notify Token',
help: '至 https://notify-bot.line.me/my/ 發行個人存取權杖',
help_url: 'https://notify-bot.line.me/my/',
},
],
inject: {
header: {
Authorization: 'Bearer {{secret.line_token}}',
},
},
created_at: now,
updated_at: now,
},
{
kind: 'auth_recipe',
service: 'kbdb',
version: 1,
primitive: 'static_key',
base_url: 'https://kbdb.finally.click',
display_name: 'KBDB',
description: 'KBDB partner API — block 讀寫(static_key Bearer)。kbdb_* recipe 共用此把 auth。',
required_secrets: [
{
key: 'kbdb_api_key',
label: 'KBDB API Key(至 arcrun 取統一 API Key 當 credential',
help: 'KBDB 採 Supabase 模式:要用 → 去 arcrun 取統一 API Key 當此 credential',
help_url: 'https://arcrun.dev',
},
],
inject: {
header: {
Authorization: 'Bearer {{secret.kbdb_api_key}}',
},
},
created_at: now,
updated_at: now,
},
// ── Service Account 類(Google 家族,共用同一份 service_account_json)────────
{
+5
View File
@@ -460,6 +460,11 @@ 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 {