fix(kbdb): credential 目錄自癒搬遷(D38 收尾)——修 youlin 20/20 全敗事故

根因(2026-08-07 youlin 測試實例):7ba7855 把 credential 讀寫端從舊表
credentials(0002)改走 KBDB entries(entry_type='credential'),但舊表
資料的搬遷 migration(0006)要人手動觸發部署才會跑。實查 youlin 的 D1:
credentials 表有 1 筆(yuga3bse/kbdb_internal_token),entries 對應筆數
為 0——新讀取端上線、舊資料還沒搬,20 次 workflow 全部找不到 credential。

leo 追加硬要求:credential 資料住在用戶自己的 CF 帳號,換讀取路徑=每個
既有實例都要遷移,但用戶不准做任何手動步驟——搬遷必須內建在既有更新流程
裡、天然無感。

解法(kbdb/src/actions/credential-legacy-migration.ts):把「搬」變成
「讀」的副作用而非獨立步驟。KBDB worker(D38 唯一允許碰 SQL 的牆內)在
每次查詢某租戶的 credential 目錄前,先確認舊表資料是否已搬進 entries
——沒有就搬(per-owner scoped、NOT EXISTS 冪等),有就是零成本的
sqlite_master 短路檢查。呼叫時機掛在 GET /entries?entry_type=credential
(cypher-executor 熱路徑本來就會打的端點),故只要更新 KBDB worker,
下一次任何人跑 workflow 該租戶就自動搬好,不需要用戶或安裝器多做任何事。
刻意不執行退場(DROP TABLE)——多個實例搬遷時間點不同,舊表留著才能讓
「已搬」與「還沒搬」的實例同時安全運作;退場留給之後獨立的清理步驟。

kbdb/tests/credential-legacy-migration.test.ts:反向驗證重建 2026-08-07
事故的確切前置狀態(真 SQLite + 0001/0002/0005 migration 原檔),證明補丁
加入前 entries.length 回 0(事故重現),加入後回 1(修好);另驗冪等
(連呼叫三次不重複搬)、多租戶互不干擾、舊表已清理時的終態安全。

cypher-executor/tests/credentials.test.ts:補齊 7ba7855 留下的刻意紅燈
(原 placeholder 五項清單),涵蓋租戶隔離的讀寫、真刪除(非 deprecated
標記)、零原生 SQL 原始碼掃描、密文本體不落 KBDB。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-08-07 22:51:31 +08:00
parent ac4fb56c91
commit 046ceba29c
3 changed files with 508 additions and 26 deletions
@@ -0,0 +1,91 @@
// credential-legacy-migration.ts — 「新讀取端上線、舊資料還沒搬完」的自癒補丁
// (D38 圍牆修復收尾,總管交辦,2026-08-08)。
//
// ── 為什麼這支檔案存在 ────────────────────────────────────────────────────
// 7ba7855D38 圍牆修復)把 credential 目錄的讀寫端從舊表 `credentials`0002,違規多開
// 的第四張表)改成走 entries 表(entry_type='credential')。0006_drop_credentials_table.sql
// 寫了「把舊表資料搬進 entries 後讓舊表退場」的一次性 migration,但這支 migration **要有人
// 手動觸發部署才會跑**——2026-08-07 youlin 測試實例的事故就是「code 部署了、migration 沒
// 跑」造成 20/20 workflow 全部找不到 credential。
//
// leo 追加的硬要求(2026-08-08):credential 資料住在**用戶自己的 Cloudflare 帳號**
// 換讀取路徑=每個既有實例的資料都要跟著搬,但**用戶不准做任何手動步驟**——不能要求他
// 跑指令、改設定、重裝。搬遷必須內建在「用戶本來就會走的路」裡(因此天然無感)。
//
// ── 解法:把「搬」變成「讀」的副作用,而不是獨立一步 ─────────────────────
// KBDB worker(本檔)是 D38 唯一允許碰 SQL 的地方(牆內)。這裡在**每次查詢某租戶的
// credential 目錄之前**,先確認舊表資料是否已經搬進 entries——沒有就搬(scoped 到這個
// owner_idNOT EXISTS 防重複),有就是零成本的一次 sqlite_master 檢查。
//
// 呼叫時機只有一個:cypher-executor 的 credentials.ts 熱路徑(getCredentialDirectory /
// findCredentialEntry)本來就會在**每次 workflow 執行**打一次 GET /entries?entry_type=
// credential&owner_id=X60 秒快取未命中時)。只要 KBDB worker 部署了本檔的邏輯,
// 下一次任何人跑 workflow,那個租戶的資料就自動搬好了——**不需要用戶多做任何事**,
// 也不需要「更新流程」額外呼叫一支新端點:更新 KBDB worker 本身就是唯一需要發生的事,
// 之後的搬遷由使用行為自然觸發。
//
// ── 三個安全性質(都經得起故意製造壞狀態來驗證,見 tests/credential-legacy-migration.test.ts)──
// 1. 冪等:NOT EXISTS 防止同一筆搬兩次;同一個 owner 呼叫 N 次只搬一次。
// 2. 對「已經搬過」與「還沒搬」的實例都正確:已搬過 → legacyTableExists 一旦舊表被真的
// 清空退場(未來清理步驟)就直接短路回 false,query 零成本;還沒搬 → 這次呼叫就地補齊。
// 3. 不砍表:本檔刻意不執行「讓舊表退場」那句 SQL——多個實例的搬遷時間點不同,
// 表還留著才能讓「還沒搬的」與「已經搬的」實例同時安全運作(leo 08-08:
// 「他們會同時存在一段時間」)。退場是之後所有租戶都確認搬完才做的獨立清理步驟。
/** 舊表是否還存在(sqlite_master 查詢,索引命中、幾乎零成本)。
* 一旦舊表被清理步驟真的清空退場,這裡會回 false,後續呼叫直接短路,不再嘗試搬遷。 */
async function legacyCredentialsTableExists(db: D1Database): Promise<boolean> {
const row = await db
.prepare(`SELECT 1 AS x FROM sqlite_master WHERE type = 'table' AND name = 'credentials'`)
.first<{ x: number }>();
return row !== null;
}
/**
* 把某個租戶(owner_id=api_key)在舊 `credentials` 表裡、entries 還沒有對應列的 row
* 搬進 entriesentry_type='credential')。scoped 到單一 owner,故查詢便宜,可安全地在
* 熱路徑(每次 workflow 執行)前呼叫。
*
* 欄位對應與 0006_drop_credentials_table.sql 逐字一致(page_name=name 冪等鍵,
* metadata_json 打包 service/sensitivity/secret_ref/last_used_at)。
*
* @returns 實際搬移的筆數(0 = 這個 owner 沒有待搬資料,含「舊表本來就不存在」與
* 「已經搬過」兩種情況——呼叫端不需要分辨,行為一致)。
*/
export async function migrateLegacyCredentialsForOwner(db: D1Database, ownerId: string): Promise<number> {
if (!ownerId) return 0; // 沒有 owner_id 的查詢(極少見)不觸發:搬遷是 per-tenant 動作,範圍不明確就不做
if (!(await legacyCredentialsTableExists(db))) return 0; // 舊表不存在(從未有 / 已清理)→ 零成本短路
const before = await db
.prepare(`SELECT COUNT(*) AS n FROM entries WHERE entry_type = 'credential' AND owner_id = ?1`)
.bind(ownerId)
.first<{ n: number }>();
await db
.prepare(
`INSERT INTO entries (id, entry_type, owner_id, page_name, metadata_json, created_at, updated_at)
SELECT
'e_cred_' || lower(hex(randomblob(8))),
'credential',
c.api_key,
c.name,
json_object('service', c.service, 'sensitivity', c.sensitivity, 'secret_ref', c.secret_ref, 'last_used_at', c.last_used_at),
c.created_at,
unixepoch()
FROM credentials c
WHERE c.api_key = ?1
AND NOT EXISTS (
SELECT 1 FROM entries e
WHERE e.entry_type = 'credential' AND e.owner_id = c.api_key AND e.page_name = c.name
)`,
)
.bind(ownerId)
.run();
const after = await db
.prepare(`SELECT COUNT(*) AS n FROM entries WHERE entry_type = 'credential' AND owner_id = ?1`)
.bind(ownerId)
.first<{ n: number }>();
return (after?.n ?? 0) - (before?.n ?? 0);
}