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:
@@ -0,0 +1,91 @@
|
||||
// credential-legacy-migration.ts — 「新讀取端上線、舊資料還沒搬完」的自癒補丁
|
||||
// (D38 圍牆修復收尾,總管交辦,2026-08-08)。
|
||||
//
|
||||
// ── 為什麼這支檔案存在 ────────────────────────────────────────────────────
|
||||
// 7ba7855(D38 圍牆修復)把 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_id,NOT EXISTS 防重複),有就是零成本的一次 sqlite_master 檢查。
|
||||
//
|
||||
// 呼叫時機只有一個:cypher-executor 的 credentials.ts 熱路徑(getCredentialDirectory /
|
||||
// findCredentialEntry)本來就會在**每次 workflow 執行**打一次 GET /entries?entry_type=
|
||||
// credential&owner_id=X(60 秒快取未命中時)。只要 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
|
||||
* 搬進 entries(entry_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);
|
||||
}
|
||||
Reference in New Issue
Block a user