Files
Arcrun/cypher-executor/src/actions/auth-dispatcher.ts
T
uncle6me-web d2048e26a7 fix(cypher): auth primitive 改走 service binding(解 self-hosted CF 1042)
壓測階段 11:self-hosted 帳號 cypher 用 fetch(workers.dev) 打同帳號
auth worker 被 CF 子請求限制回 1042,service account token 換不到 →
表單寫不進 Google Sheets。token/解密鏈本身正常(直打 auth worker 回真 ya29)。

架構演化(richblack 2026-06-06 拍板):用戶產生的是 recipe(KV 資料,不 deploy),
primitive 是平台固定基礎設施、用戶不新增 → 解除「auth primitive 禁 service binding」
舊禁令。service binding 是 CF 內部 RPC,繞開同 zone 522 + 同帳號 workers.dev 1042。

- wrangler.toml:加 SVC_AUTH_STATIC_KEY/SERVICE_ACCOUNT/OAUTH2(已部署者;mtls 未部署留註解)
- auth-dispatcher.ts:binding 優先 svc.fetch(),無 binding fallback fetch(workers.dev)
- types.ts:4 個 optional SVC_AUTH_*
- deploy.ts 無需改:stripOfficialOnlyBindings 不碰 services,tier1 auth 先於 tier2 cypher
- 已驗證 self-hosted(leo21c)13 邏輯零件 binding 實綁成功,auth binding 走同路

規範同步:rule 02 / 03 / CLAUDE.md / pre-bash-guard 例外。SDD: Phase 7。tsc exit 0。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 21:09:49 +08:00

130 lines
4.9 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.
/**
* Auth Dispatcher
*
* 對需要認證的零件,在執行前 HTTP POST 到對應的 auth primitive Worker,
* 取回 auth_headers / auth_query / auth_body 合併進節點 context。
*
* 嚴格邊界(rule 02 §2.2):
* - 本檔**不做**任何 credential 解密 / template 展開 / JWT 簽章
* - 那些全部在 auth primitive WASM 零件內執行(透過 host function `crypto_decrypt` 等)
* - 本檔只做「查 recipe 決定走哪個 primitive Worker」+「HTTP fetch 取回注入結果」
*
* 目前階段接上 `auth_static_key` + `auth_service_account` + `auth_oauth2`,
* Phase 4 剩 `auth_mtls`mTLS handshake 在 Worker runtime 層)。
*
* 執行時機:graph-executor 在節點 runner 執行前呼叫,取回的 ctx 會:
* 1. 先試本 dispatcher(命中才 return enriched ctx)
* 2. 沒命中 fallback 到 `injectCredentials`(Phase 1.9 才刪除)
*/
import type { Bindings } from '../types';
import { resolveAuthRecipe, resolveRecipe } from '../routes/recipes';
import { wasmWorkerUrl } from '../lib/component-loader';
import type { ServiceBinding } from '../types';
/** 對應 Phase 1-4 會部署的 auth primitive Worker */
const SUPPORTED_PRIMITIVES = new Set(['static_key', 'service_account', 'oauth2']);
/**
* primitive 名 → service binding keyPhase 72026-06-06)。
* 比照 component-loader 的邏輯零件:有 binding 走 CF 內部 RPC(繞開同 zone 522 + 同帳號 workers.dev 1042),
* 無 binding(如 self-hosted 未綁、或 mtls 未部署)fallback 到 fetch(workers.dev)。
*/
const AUTH_BINDING_MAP: Record<string, keyof import('../types').Bindings> = {
static_key: 'SVC_AUTH_STATIC_KEY',
service_account: 'SVC_AUTH_SERVICE_ACCOUNT',
oauth2: 'SVC_AUTH_OAUTH2',
mtls: 'SVC_AUTH_MTLS',
};
/** auth primitive 本身的 componentId(避免自引用) */
const AUTH_PRIMITIVE_IDS = new Set([
'auth_static_key',
'auth_service_account',
'auth_oauth2',
'auth_mtls',
]);
/**
* 試著對零件做 auth 注入。
* - 命中(有對應 auth recipe 且 primitive 已支援)→ 回傳注入後的 ctx
* - 未命中 → 回傳 null(呼叫端繼續跑舊路徑)
*/
export async function tryAuthDispatch(
componentId: string,
input: Record<string, unknown>,
env: Bindings,
apiKey: string,
): Promise<Record<string, unknown> | null> {
if (AUTH_PRIMITIVE_IDS.has(componentId)) {
// auth primitive 本身不需要再做 auth
return null;
}
// 決定 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;
// 呼叫對應 auth primitive WorkerPhase 72026-06-06):
// binding 優先(CF 內部 RPC,繞開同 zone 522 + 同帳號 workers.dev 子請求 1042,壓測階段 11),
// 無 bindingself-hosted 未綁 / mtls 未部署)fallback 到 fetch(workers.dev)。比照 component-loader makeLogicRunner。
const reqInit = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'authenticate', api_key: apiKey, service }),
};
const bindingKey = AUTH_BINDING_MAP[recipe.primitive];
const svc = bindingKey ? (env[bindingKey] as ServiceBinding | undefined) : undefined;
let res: Response;
if (svc) {
// service binding:用任意 URLCF 內部 RPC 直送目標 Worker(不經公網)
res = await svc.fetch(new Request('https://auth-primitive/', reqInit));
} else {
// fallback:公網 workers.dev(自架未綁 binding / 開發環境 / mtls
const primitiveUrl = wasmWorkerUrl(`auth_${recipe.primitive}`, env.WORKER_SUBDOMAIN);
res = await fetch(primitiveUrl, reqInit);
}
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(
`auth primitive "${recipe.primitive}" 回傳 ${res.status}: ${text.slice(0, 200)}`,
);
}
const result = await res.json().catch(() => null) as {
success?: boolean;
error?: string;
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) {
throw new Error(
`auth primitive 失敗: ${result?.error ?? '未知錯誤'}`,
);
}
return {
...input,
_auth_headers: result.auth_headers ?? {},
_auth_query: result.auth_query ?? {},
_auth_body: result.auth_body ?? {},
_auth_path: result.auth_path ?? {},
};
}