/** * 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 `secret_get` 等) * - 本檔只做「查 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. 沒命中 → ctx 原樣往下(T10 起舊的 injectCredentials 雙讀 fallback 已移除) */ import type { Bindings } from '../types'; import { resolveAuthRecipe, resolveRecipe } from '../routes/recipes'; import { wasmWorkerUrl } from '../lib/component-loader'; import { createArcrunHostFunctions } from '../lib/wasi-shim'; import { getCredentialSecretRefs, touchLastUsed } from '../routes/credentials'; // ── credential-store 遷移 T6/T7(方案 A,D19)+ D38 圍牆修復(2026-08-07)─────────── // // 密文值住 cypher-executor 自己的 per-script secrets(T5 寫入)。解密發生在獨立的 // auth_static_key / auth_service_account worker 上,它們讀不到 cypher 的 secrets。 // 故 cypher 這一層先取這個租戶的 credential 目錄(name → secret_ref)→ 用 secret_get(ref) // (即 env[ref],T4)取明文 → 塞進送給 auth WASM 的 payload 新欄位 `resolved_secrets`。 // WASM 收到優先用它,沒有才 fallback 舊 KV + crypto_decrypt(那個 fallback 即 T7 雙讀)。 // // D38(leo 2026-06-14 立、2026-08-07 擴大):目錄不再直連 D1,改走 KBDB HTTP API // (`credentials.ts` 的 `getCredentialSecretRefs`,內建 60 秒租戶級快取——這是熱路徑, // 每次 workflow 執行都會呼叫,映射「幾乎不變」故快取後多數命中零網路呼叫,效能不因改走 // API 而變差,見 credentials.ts 檔頭「效能」段的實測數字)。 // // 嚴格邊界(rule 02 §2.2):本檔只做「查目錄拿 ref → secret_get 取值 → 當字串塞 payload」。 // **不解密、不展開模板、不組 JWT**——secret_get 的實作(env[ref])在 wasi-shim host function // 內,解密/注入邏輯仍全在 WASM 零件。 /** * 對一組 credential name,從新家(cypher per-script secrets)取明文。 * * 流程:查 KBDB credential 目錄(api_key + name,快取命中零網路呼叫)拿 `secret_ref` * → 用 `secret_get(ref)`(host function,實作 = env[ref])取值。 * * ⚠️ 只把「目錄有 ref 且 secret_get 真的取到值」的 name 放進回傳 map。查不到 ref、 * 或 secret_get 回 null(新家還沒這把值)→ **該 name 缺席**(不是放空字串!), * 讓 WASM 對這把 key 走 fallback 舊 KV 路徑(T7 雙讀)。放空字串會讓 WASM 誤判命中用空值。 * * 取到值的 name 順手更新 last_used_at(§2.5 治理面 last_used,見 touchLastUsed—— * fire-and-forget、非同步、不阻塞本函式回傳,失敗吞掉)。 * * KBDB 不可達 / 這個租戶還沒有任何 credential → 回空 map(整組走 fallback), * 不 throw——遷移過渡期(雙讀)本就允許「新家還沒資料」。 */ /** credential name → 明文值對照(獨立型別別名,避免函式簽章直接內嵌逗號分隔泛型)。 */ type ResolvedSecretMap = Record; export async function resolveSecretsFromNewHome( env: Bindings, apiKey: string, names: string[], ): Promise { const resolved: ResolvedSecretMap = {}; if (names.length === 0) return resolved; // 1. 拿這個租戶的 credential 目錄(name → secret_ref,快取層見 credentials.ts) const refs = await getCredentialSecretRefs(env, apiKey); if (Object.keys(refs).length === 0) return resolved; // 目錄空 / KBDB 不可達 → 整組走 fallback // 2. 用 secret_ref 從新家取值(host function secret_get = env[ref]) const secretGet = createArcrunHostFunctions(env, apiKey).secret_get; if (!secretGet) return resolved; // host function 未就緒 → 走 fallback const resolvedNames: string[] = []; for (const name of names) { const ref = refs[name]; if (!ref) continue; // 目錄沒這個 name → 缺席,走 fallback const value = await secretGet(ref); // null(新家沒這把值 / 非 CRED_ 前綴被拒)→ 不放進 map,讓 WASM fallback 舊 KV if (value === null) continue; resolved[name] = value; resolvedNames.push(name); } // 3. 順手更新 last_used_at(只更新真的從新家取到值的 name;fire-and-forget,非關鍵路徑) if (resolvedNames.length > 0) touchLastUsed(env, apiKey, resolvedNames); return resolved; } /** 對應 Phase 1-4 會部署的 auth primitive Worker */ const SUPPORTED_PRIMITIVES = new Set(['static_key', 'service_account', 'oauth2']); /** 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, env: Bindings, apiKey: string, ): Promise | 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; // T6:先從新家(cypher per-script secrets)取 recipe 需要的 credential 明文。 // 拿到的塞進 `resolved_secrets`,WASM 優先用;沒拿到的 name 缺席 → WASM fallback 舊 KV(T7)。 const secretNames = recipe.required_secrets .filter(s => !s.optional) .map(s => s.key); const resolvedSecrets = await resolveSecretsFromNewHome(env, apiKey, secretNames); // 走新路徑:HTTP POST 到對應 auth primitive Worker // 走 workers.dev 避開同 zone 死鎖(P0 #9) const primitiveUrl = wasmWorkerUrl(`auth_${recipe.primitive}`, env.WORKER_SUBDOMAIN); const res = await fetch(primitiveUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'authenticate', api_key: apiKey, service, // 只在有取到值時帶上(空物件也無妨,WASM 對 nil/空 map 同樣 fallback) resolved_secrets: resolvedSecrets, }), }); 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; auth_query?: Record; auth_body?: Record; auth_path?: Record; } | 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 ?? {}, }; } // ── 用戶面 {{credential.NAME}} 注入(design §8)──────────────────────────────── /** 匹配 {{credential.NAME}}(NAME 為 word 字元) */ const CREDENTIAL_REF = /\{\{credential\.(\w+)\}\}/g; /** 遞迴收集任意值(string / 物件 / 陣列)裡所有 {{credential.NAME}} 的 NAME */ function collectCredentialNames(value: unknown, out: Set): void { if (typeof value === 'string') { for (const m of value.matchAll(CREDENTIAL_REF)) out.add(m[1]); } else if (Array.isArray(value)) { for (const v of value) collectCredentialNames(v, out); } else if (value && typeof value === 'object') { for (const v of Object.values(value as Record)) collectCredentialNames(v, out); } } /** 遞迴把 {{credential.NAME}} 替換成 resolved[NAME](未知 name 原樣保留) */ function replaceCredentialRefs(value: unknown, resolved: Record): unknown { if (typeof value === 'string') { return value.replace(CREDENTIAL_REF, (orig, name: string) => Object.prototype.hasOwnProperty.call(resolved, name) ? resolved[name] : orig, ); } if (Array.isArray(value)) return value.map((v) => replaceCredentialRefs(v, resolved)); if (value && typeof value === 'object') { const out: Record = {}; for (const [k, v] of Object.entries(value as Record)) { out[k] = replaceCredentialRefs(v, resolved); } return out; } return value; } /** * 展開節點 data 裡用戶寫的 `{{credential.NAME}}`(design §8)。 * * 嚴格邊界(rule 02 §2.2):本函式**不解密**。偵測到 {{credential.X}} 後,把 names 交給 * auth_static_key WASM 的 `resolve_credentials` action(WASM 內 kv_get + crypto_decrypt), * 拿回明文後只做字串回填。本檔不解密、不持有任何金鑰。 * * - 無 {{credential.}} → 原樣回傳(不打 WASM,零開銷) * - 解密失敗 / 缺 credential → throw(誠實報錯,不假綠) */ export async function resolveCredentialRefs( data: Record, env: Bindings, apiKey: string, ): Promise> { const names = new Set(); collectCredentialNames(data, names); if (names.size === 0) return data; const nameList = [...names]; // T6:先從新家取這些 credential 的明文,塞 `resolved_secrets`;WASM 優先用它, // 沒取到的 name 缺席 → WASM 的 resolve_credentials 分支 fallback 舊 KV(T7)。 const resolvedSecrets = await resolveSecretsFromNewHome(env, apiKey, nameList); const url = wasmWorkerUrl('auth_static_key', env.WORKER_SUBDOMAIN); const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'resolve_credentials', api_key: apiKey, names: nameList, resolved_secrets: resolvedSecrets, }), }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`credential resolve 回傳 ${res.status}: ${text.slice(0, 200)}`); } const result = (await res.json().catch(() => null)) as { success?: boolean; error?: string; credentials?: Record; } | null; if (!result || result.success === false) { throw new Error(`credential resolve 失敗: ${result?.error ?? '未知錯誤'}`); } return replaceCredentialRefs(data, result.credentials ?? {}) as Record; }