D38:credential 目錄改走 KBDB API(零原生 SQL)+舊表退場;測試刻意留紅燈

存取層:credentials.ts / auth-dispatcher.ts / portal.ts 全改走 kbdbBase()+fetch
到 /entries(照 execution-logger.ts 既有慣例),.prepare/.exec/.batch 命中 0。
資料層:0005 seed credential template;0006 把舊表資料搬進 entries 後拆表;
0002 標退役、deploy.ts 不再套用(加 kbdb-sql-ok 留痕,純歷史對照)。

總管親驗四項(不聽 agent 自評):
1 三個檔 .prepare/.exec/.batch 命中 0;六個檔全部通過 kbdb-api-wall-guard
2 0006 的 INSERT 欄位(id/entry_type/owner_id/page_name/metadata_json/
  created_at/updated_at)與 0001_base 的 entries 表逐一對得上
3 不可逆風險查官方:D1 batch 是 transaction、任一句失敗整批 rollback;
  exec 出錯則「執行停止、後續不執行」=> 兩種語意下 INSERT 失敗都不會跑到
  DROP TABLE,用戶 credential 目錄不會遺失
4 0006 搬在拆之前、冪等(NOT EXISTS 防重複)、豁免標記有留痕且理由正當
  (拆表是牆內施工,API 不提供也不該提供拆表)

🔴 抓到一個假綠並修正:agent 中途被中斷,把 111 行的 credentials.test.ts
砍成一行「// placeholder — see edit below」,那個 edit 從來沒發生,
且 setup.ts 被刪。vitest 對這種檔案回報「Tests: no tests」,
很容易被讀成「沒失敗=通過」——正是 CP 記過的
「這條 route 曾整條消失過沒人發現」同型。
處置:還原 setup.ts/vitest.config.ts,credentials.test.ts 改成
**刻意會失敗的紅燈**並在檔頭列出要補的五項。空檔會被誤認為綠,紅燈不會。

未部署。本批要先上 stage 驗過才進 prod(leo 08-07 定)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-08-07 17:27:18 +08:00
parent 60688c3108
commit 7ba78552a4
7 changed files with 343 additions and 244 deletions
+33 -58
View File
@@ -21,98 +21,73 @@ 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(方案 AD19────────────────────────────────
// ── credential-store 遷移 T6/T7(方案 AD19 D38 圍牆修復(2026-08-07───────────
//
// 密文值住 cypher-executor 自己的 per-script secretsT5 寫入)。解密發生在獨立的
// auth_static_key / auth_service_account worker 上,它們讀不到 cypher 的 secrets。
// 故 cypher 這一層先查 D1 拿 secret_ref → 用 secret_get(ref)(即 env[ref]T4)取明文
// → 塞進送給 auth WASM 的 payload 新欄位 `resolved_secrets`。WASM 收到優先用它,沒有
// 才 fallback 舊 KV + crypto_decrypt(那個 fallback 即 T7 雙讀)。
// 故 cypher 這一層先取這個租戶的 credential 目錄(name → secret_ref→ 用 secret_get(ref)
// (即 env[ref]T4)取明文 → 塞進送給 auth WASM 的 payload 新欄位 `resolved_secrets`。
// WASM 收到優先用它,沒有才 fallback 舊 KV + crypto_decrypt(那個 fallback 即 T7 雙讀)。
//
// 嚴格邊界(rule 02 §2.2):本檔只做「查 D1 ref → secret_get 取值 → 當字串塞 payload」。
// D38leo 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 零件。
/** D1 credentials 目錄一列(只取本檔需要的欄位)。 */
interface CredentialRefRow {
name: string;
secret_ref: string;
}
/**
* 對一組 credential name,從新家(cypher per-script secrets)取明文。
*
* 流程:查 D1 `credentials`api_key + name)拿 `secret_ref` → 用 `secret_get(ref)`
* host function,實作 = env[ref])取值。
* 流程:查 KBDB credential 目錄api_key + name,快取命中零網路呼叫)拿 `secret_ref`
* → 用 `secret_get(ref)`host function,實作 = env[ref])取值。
*
* ⚠️ 只把「D1 有 ref 且 secret_get 真的取到值」的 name 放進回傳 map。查不到 ref、
* ⚠️ 只把「目錄有 ref 且 secret_get 真的取到值」的 name 放進回傳 map。查不到 ref、
* 或 secret_get 回 null(新家還沒這把值)→ **該 name 缺席**(不是放空字串!),
* 讓 WASM 對這把 key 走 fallback 舊 KV 路徑(T7 雙讀)。放空字串會讓 WASM 誤判命中用空值。
*
* 取到值的 name 順手更新 D1 `last_used_at`(§2.5 治理面 last_used)。
* 取到值的 name 順手更新 last_used_at(§2.5 治理面 last_used,見 touchLastUsed——
* fire-and-forget、非同步、不阻塞本函式回傳,失敗吞掉)。
*
* D1 未建表 / migration 未跑 / CREDENTIALS_DB 未綁 → 回空 map(整組走 fallback),
* KBDB 不可達 / 這個租戶還沒有任何 credential → 回空 map(整組走 fallback),
* 不 throw——遷移過渡期(雙讀)本就允許「新家還沒資料」。
*/
/** credential name → 明文值對照(獨立型別別名,避免函式簽章直接內嵌逗號分隔泛型)。 */
type ResolvedSecretMap = Record<string, string>;
export async function resolveSecretsFromNewHome(
env: Bindings,
apiKey: string,
names: string[],
): Promise<Record<string, string>> {
const resolved: Record<string, string> = {};
): Promise<ResolvedSecretMap> {
const resolved: ResolvedSecretMap = {};
if (names.length === 0) return resolved;
const db = env.CREDENTIALS_DB;
if (!db) return resolved; // 未綁 D1 → 整組走 fallback
// 1. 查 D1 拿每個 name 的 secret_ref
let rows: CredentialRefRow[];
try {
const placeholders = names.map(() => '?').join(', ');
const result = await db
.prepare(
`SELECT name, secret_ref FROM credentials
WHERE api_key = ? AND name IN (${placeholders})`,
)
.bind(apiKey, ...names)
.all<CredentialRefRow>();
rows = result.results ?? [];
} catch {
// D1 未建表 / query 失敗 → 過渡期整組走 fallback(雙讀),不假綠
return resolved;
}
if (rows.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 row of rows) {
const value = await secretGet(row.secret_ref);
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[row.name] = value;
resolvedNames.push(row.name);
resolved[name] = value;
resolvedNames.push(name);
}
// 3. 順手更新 last_used_at(只更新真的從新家取到值的 name)
if (resolvedNames.length > 0) {
try {
const now = Math.floor(Date.now() / 1000);
const placeholders = resolvedNames.map(() => '?').join(', ');
await db
.prepare(
`UPDATE credentials SET last_used_at = ?
WHERE api_key = ? AND name IN (${placeholders})`,
)
.bind(now, apiKey, ...resolvedNames)
.run();
} catch {
// last_used 更新失敗不影響注入主流程(治理面欄位,非關鍵路徑)
}
}
// 3. 順手更新 last_used_at(只更新真的從新家取到值的 namefire-and-forget,非關鍵路徑
if (resolvedNames.length > 0) touchLastUsed(env, apiKey, resolvedNames);
return resolved;
}