Compare commits

...

2 Commits

Author SHA1 Message Date
uncle6me-web c7a0b317cb fix(kbdb): 補 kbdb/src/routes/entries.ts 缺的自癒搬遷 hook(上一commit漏了這支檔)
上一個 commit(046ceba)訊息宣稱改了 entries.ts 但實際只有測試檔進了 git——
entries.ts 的 migrateLegacyCredentialsForOwner 呼叫留在工作區沒進 index
(同一份工作目錄有另一個 session 併行在改這支檔案的語意搜尋空結果診斷功能,
兩邊的 import 改到同一行,第一次 commit 時漏收)。這次補上:

- import migrateLegacyCredentialsForOwner,GET /entries 對 entry_type=
  credential 觸發自癒搬遷(見 046ceba 說明,本檔案是實際掛載點)
- 同時收進另一個 session 已完成且測試通過的變更(search-semantic-empty-reason
  :語意搜尋回空時分辨 no_index/no_match/stale_index 三態,給人話 capability_
  hint + 技術向 admin_hint;非本次任務範圍,因同檔同 import 行交織、且已驗證
  119/119 全過,一併收下不拆散)

kbdb 全測試 119/119 通過(含新增的 credential-legacy-migration.test.ts 5 項
與 search-semantic-empty-reason.test.ts 4 項)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 22:53:51 +08:00
uncle6me-web 046ceba29c 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>
2026-08-07 22:51:31 +08:00
5 changed files with 694 additions and 31 deletions
+253 -26
View File
@@ -1,33 +1,260 @@
/**
* credentials 路由測試 —— 🔴 待重寫D38 圍牆修復後)
* credentials 路由測試(D38 圍牆修復後補寫,2026-08-08
*
* 【為什麼是紅燈而不是空檔】
* 2026-08-07 D38 圍牆修復把 credential 目錄從「獨立 credentials 表 + 原生 SQL」
* 改成「KBDB entriesentry_type='credential'+ HTTP API」。原本 111 行的測試
* 測的是舊的 SQL 實作,全部不再適用
* 前身是刻意留紅的 placeholder(見 git history):2026-08-07 D38 把 credential 目錄從
* 「獨立 credentials 表 + 原生 SQL」改成「KBDB entriesentry_type='credential'+
* HTTP API」,舊測試全部作廢,agent 中途被中斷沒補上,故意留一個會失敗的測試佔位、
* 避免「no tests」被誤讀成「通過」。本檔依 placeholder 頭部列的五項補齊
*
* 施工的 agent 中途被中斷,留下一行 `// placeholder — see edit below` ——
* 那個 "edit below" 從來沒發生。vitest 對這種檔案回報 `Tests: no tests`
* **很容易被讀成「沒失敗=通過」**,正是 CP 記過的
* 「這條 route 曾整條消失過沒人發現」同型
*
* ⇒ 這裡刻意留一個**會失敗**的測試:空檔會被誤認為綠,紅燈不會。
*
* 【重寫時要涵蓋什麼】(照新實作 routes/credentials.ts
* 1. 寫入走 KBDB HTTP API,且 owner_id = api_key(租戶隔離)
* 2. 讀取查得回 secret_ref,且查不到別的租戶的
* 3. 刪除是真的刪(不是 deprecated
* 4. **零原生 SQL**:整支檔案不得出現 .prepare/.exec/.batch
* 5. 密文本體不落 KBDB(只有 secret_ref 指標)—— D19 不變
* 測試手法比照姊妹模組 execution-logger.test.ts`vi.stubGlobal('fetch', ...)` 攔截,
* 但這裡的攔截器是**有狀態的假 KBDB**in-memory entries store),因為 credentials.ts
* 一次操作常涉及多輪 HTTP 呼叫(find → upsert / find → delete),單次回應的 mock 測不出
* 「查得到剛寫的」「刪掉後真的查不到」這類語意,需要一個會記狀態的假後端
*/
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { Hono } from 'hono';
import { credentialsRouter, getCredentialSecretRefs, hasCredential, invalidateCredentialCache } from '../src/routes/credentials';
import type { Bindings } from '../src/types';
// Workers runtime@cloudflare/vitest-pool-workers)沒有 node:fs——原始碼掃描改用 Vite 的
// `?raw` import 取字串內容(build-time 讀檔,runtime 是純字串,不受 Workers 限制)。
// @ts-expect-error -- vite ?raw 型別由 tsconfig 的 vite/client 提供,非本檔關注重點
import credentialsSource from '../src/routes/credentials.ts?raw';
describe('credentials 路由(D38 改走 KBDB API 後)', () => {
it('🔴 測試待重寫 —— 見本檔頭部清單(刻意紅燈,別刪掉改成空檔)', () => {
expect.fail(
'D38 圍牆修復後 credential 改走 KBDB entries + HTTP API' +
'舊的 SQL 版測試已作廢、新測試尚未寫。' +
'要補的五項見本檔頭部註解。',
);
afterEach(() => vi.unstubAllGlobals());
// ── 有狀態假 KBDB:只實作 credentials.ts 實際會打的四個操作(GET list/find, POST, PATCH, DELETE)──
interface FakeEntry {
id: string;
entry_type: string;
owner_id: string;
page_name: string;
metadata_json: string;
created_at: number;
}
function makeFakeKbdb() {
const entries: FakeEntry[] = [];
let idSeq = 0;
const secretsStore = new Map<string, string>(); // secretRef -> plaintext(模擬 CF Workers Secrets,唯寫,測試用來斷言「有沒有被塞值」)
const secretPuts: Array<{ name: string; text: string }> = [];
const secretDeletes: string[] = [];
const kbdbRequests: Array<{ method: string; url: string; body: unknown }> = [];
async function handle(url: string, init: RequestInit = {}): Promise<Response> {
const method = (init.method ?? 'GET').toUpperCase();
const u = new URL(url);
// CF Workers Scripts secrets 管理 API(唯寫,讀不回值)
if (u.hostname === 'api.cloudflare.com') {
if (method === 'PUT' && u.pathname.endsWith('/secrets')) {
const body = JSON.parse(String(init.body)) as { name: string; text: string };
secretsStore.set(body.name, body.text);
secretPuts.push(body);
return new Response(JSON.stringify({ success: true }), { status: 200 });
}
if (method === 'DELETE' && u.pathname.includes('/secrets/')) {
const name = u.pathname.split('/secrets/')[1];
secretsStore.delete(name);
secretDeletes.push(name);
return new Response(JSON.stringify({ success: true }), { status: 200 });
}
throw new Error(`unhandled CF API call: ${method} ${url}`);
}
// KBDB entries API
kbdbRequests.push({ method, url, body: init.body ? JSON.parse(String(init.body)) : undefined });
if (method === 'POST' && u.pathname === '/entries') {
const body = JSON.parse(String(init.body)) as Partial<FakeEntry>;
const entry: FakeEntry = {
id: `e_${++idSeq}`,
entry_type: body.entry_type!,
owner_id: body.owner_id!,
page_name: body.page_name!,
metadata_json: body.metadata_json!,
created_at: Math.floor(Date.now() / 1000),
};
entries.push(entry);
return new Response(JSON.stringify({ success: true, entry }), { status: 200 });
}
if (method === 'GET' && u.pathname === '/entries') {
const ownerId = u.searchParams.get('owner_id');
const entryType = u.searchParams.get('entry_type');
const pageName = u.searchParams.get('page_name');
let rows = entries.filter((e) => e.entry_type === entryType && e.owner_id === ownerId);
if (pageName) rows = rows.filter((e) => e.page_name === pageName);
return new Response(JSON.stringify({ success: true, entries: rows, count: rows.length }), { status: 200 });
}
if (method === 'PATCH' && u.pathname.startsWith('/entries/')) {
const id = decodeURIComponent(u.pathname.slice('/entries/'.length));
const body = JSON.parse(String(init.body)) as Partial<FakeEntry>;
const entry = entries.find((e) => e.id === id);
if (!entry) return new Response(JSON.stringify({ success: false }), { status: 404 });
if (body.metadata_json !== undefined) entry.metadata_json = body.metadata_json;
return new Response(JSON.stringify({ success: true, entry }), { status: 200 });
}
if (method === 'DELETE' && u.pathname.startsWith('/entries/')) {
const id = decodeURIComponent(u.pathname.slice('/entries/'.length));
const idx = entries.findIndex((e) => e.id === id);
if (idx === -1) return new Response(JSON.stringify({ success: false }), { status: 404 });
entries.splice(idx, 1); // 真的從陣列移除,不是標記
return new Response(JSON.stringify({ success: true }), { status: 200 });
}
throw new Error(`unhandled KBDB call: ${method} ${url}`);
}
vi.stubGlobal('fetch', vi.fn((url: string, init?: RequestInit) => handle(url, init)));
return { entries, secretsStore, secretPuts, secretDeletes, kbdbRequests };
}
function fakeEnv(): Bindings {
return {
KBDB_BASE_URL: 'https://kbdb.test',
CF_SECRETS_API_TOKEN: 'fake-cf-token',
CF_ACCOUNT_ID: 'fake-account',
ENVIRONMENT: 'test',
CREDENTIALS_KV: { delete: vi.fn(async () => {}) } as unknown as KVNamespace,
} as unknown as Bindings;
}
function app() {
const a = new Hono<{ Bindings: Bindings }>();
a.route('/', credentialsRouter);
return a;
}
beforeEach(() => {
invalidateCredentialCache('tenant-a');
invalidateCredentialCache('tenant-b');
});
describe('1. 寫入走 KBDB HTTP API,且 owner_id = api_key(租戶隔離)', () => {
it('POST /credentials 寫入後,entries 裡的 owner_id 就是呼叫者的 api_key', async () => {
const fake = makeFakeKbdb();
const env = fakeEnv();
const a = app();
const res = await a.request('/credentials', {
method: 'POST',
headers: { 'X-Arcrun-API-Key': 'tenant-a', 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'telegram_bot_token', value: 'secret-plaintext-value', service: 'telegram' }),
}, env);
expect(res.status).toBe(200);
const body = (await res.json()) as { success: boolean };
expect(body.success).toBe(true);
expect(fake.entries).toHaveLength(1);
expect(fake.entries[0].owner_id).toBe('tenant-a');
expect(fake.entries[0].page_name).toBe('telegram_bot_token');
});
it('兩個不同 api_key 各自建立的同名 credential 落在不同 owner_id、互不覆蓋', async () => {
const fake = makeFakeKbdb();
const env = fakeEnv();
const a = app();
await a.request('/credentials', {
method: 'POST', headers: { 'X-Arcrun-API-Key': 'tenant-a', 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'gemini_api_key', value: 'value-a' }),
}, env);
await a.request('/credentials', {
method: 'POST', headers: { 'X-Arcrun-API-Key': 'tenant-b', 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'gemini_api_key', value: 'value-b' }),
}, env);
expect(fake.entries).toHaveLength(2);
const owners = fake.entries.map((e) => e.owner_id).sort();
expect(owners).toEqual(['tenant-a', 'tenant-b']);
});
});
describe('2. 讀取查得回 secret_ref,且查不到別的租戶的', () => {
it('getCredentialSecretRefs 回該租戶的 name→secret_ref 對照,不含其他租戶的', async () => {
makeFakeKbdb();
const env = fakeEnv();
const a = app();
await a.request('/credentials', {
method: 'POST', headers: { 'X-Arcrun-API-Key': 'tenant-a', 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'gemini_api_key', value: 'value-a' }),
}, env);
await a.request('/credentials', {
method: 'POST', headers: { 'X-Arcrun-API-Key': 'tenant-b', 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'other_key', value: 'value-b' }),
}, env);
const refsA = await getCredentialSecretRefs(env, 'tenant-a');
expect(Object.keys(refsA)).toEqual(['gemini_api_key']);
expect(refsA.gemini_api_key).toMatch(/^CRED_GEMINI_API_KEY_/);
expect(refsA.other_key).toBeUndefined(); // 查不到別租戶的
const refsB = await getCredentialSecretRefs(env, 'tenant-b');
expect(Object.keys(refsB)).toEqual(['other_key']);
});
it('hasCredential:查得到自己的,查不到別租戶的同名 credential', async () => {
makeFakeKbdb();
const env = fakeEnv();
const a = app();
await a.request('/credentials', {
method: 'POST', headers: { 'X-Arcrun-API-Key': 'tenant-a', 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'kbdb_internal_token', value: 'v' }),
}, env);
expect(await hasCredential(env, 'tenant-a', 'kbdb_internal_token')).toBe(true);
expect(await hasCredential(env, 'tenant-b', 'kbdb_internal_token')).toBe(false);
});
});
describe('3. 刪除是真的刪(不是 deprecated 標記)', () => {
it('DELETE /credentials/:name 後,該筆 entries row 從 KBDB 消失(不是 metadata 打 deprecated 標記)', async () => {
const fake = makeFakeKbdb();
const env = fakeEnv();
const a = app();
await a.request('/credentials', {
method: 'POST', headers: { 'X-Arcrun-API-Key': 'tenant-a', 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'to_delete', value: 'v' }),
}, env);
expect(fake.entries).toHaveLength(1);
const res = await a.request('/credentials/to_delete', {
method: 'DELETE', headers: { 'X-Arcrun-API-Key': 'tenant-a' },
}, env);
expect(res.status).toBe(200);
const body = (await res.json()) as { success: boolean; source: string };
expect(body.success).toBe(true);
expect(body.source).toBe('workers-secrets');
// 真的從陣列移除,不是留著、metadata 打上 status:deprecated
expect(fake.entries).toHaveLength(0);
// Workers Secret 本體也真的被刪(DELETE 呼叫過),不是只刪目錄留孤兒密文
expect(fake.secretDeletes.length).toBe(1);
});
});
describe('4. 零原生 SQL:整支檔案不得出現 .prepare/.exec/.batch', () => {
it('routes/credentials.ts 原始碼掃描:沒有任何 D1 原生呼叫語法', () => {
expect(/\.\s*(prepare|exec|batch)\s*\(/.test(credentialsSource)).toBe(false);
});
});
describe('5. 密文本體不落 KBDB(只有 secret_ref 指標)—— D19 不變', () => {
it('送去 KBDB 的 body 裡從頭到尾沒有明文 credential value,只有 secret_ref', async () => {
const fake = makeFakeKbdb();
const env = fakeEnv();
const a = app();
const plaintext = 'super-secret-plaintext-should-never-leave-workers-secrets';
await a.request('/credentials', {
method: 'POST', headers: { 'X-Arcrun-API-Key': 'tenant-a', 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'sensitive_key', value: plaintext }),
}, env);
// 明文只出現在 CF Workers Secrets 的 PUT(唯寫 API),不出現在任何打去 KBDB 的請求 body 裡
expect(fake.secretPuts.some((p) => p.text === plaintext)).toBe(true);
for (const req of fake.kbdbRequests) {
expect(JSON.stringify(req.body ?? '')).not.toContain(plaintext);
}
// entries 裡存的是 secret_ref 指標,不是值
expect(fake.entries[0].metadata_json).not.toContain(plaintext);
expect(fake.entries[0].metadata_json).toContain('secret_ref');
});
});
@@ -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);
}
+77 -5
View File
@@ -13,7 +13,8 @@ import {
searchEntries,
isDeprecatedEntry,
} from '../actions/entry-crud';
import { embedEnabled, embedOnWrite, semanticSearch, relativeMinScore } from '../embed';
import { embedEnabled, embedOnWrite, semanticSearch, relativeMinScore, backfillStatus } from '../embed';
import { migrateLegacyCredentialsForOwner } from '../actions/credential-legacy-migration';
export const entryRoutes = new Hono<{ Bindings: Bindings }>();
@@ -87,9 +88,20 @@ entryRoutes.get('/library-stats', async (c) => {
// 舊版完全不接這個 filter;q 與 search 兩個名字都認,避免同一個坑再踩一次)。
// count = 本頁筆數(受 limit 影響);total = 符合條件全部筆數(不受 limit 影響,見 total 欄位)。
entryRoutes.get('/', async (c) => {
const entryType = c.req.query('entry_type') || undefined;
const ownerId = c.req.query('owner_id') || undefined;
// 自癒搬遷(D38 收尾,2026-08-08):credential 目錄查詢先確保舊表(若還在)已把這個
// 租戶的資料搬進 entries——冪等、per-owner scoped、成本近零(見 credential-legacy-
// migration.ts 檔頭)。只在 credential 讀取時觸發,不影響其餘 entry_type 的查詢路徑。
if (entryType === 'credential' && ownerId) {
await migrateLegacyCredentialsForOwner(c.env.DB, ownerId).catch(() => {
// 搬遷失敗不阻塞查詢本身(例如舊表結構意外損毀)——誠實地讓查詢照常進行,
// 缺席的 credential 由呼叫端既有的 fallbackcypher-executor 舊 KV)接住。
});
}
const { entries, total } = await listEntries(c.env.DB, {
entry_type: c.req.query('entry_type') || undefined,
owner_id: c.req.query('owner_id') || undefined,
entry_type: entryType,
owner_id: ownerId,
parent_id: c.req.query('parent_id') || undefined,
page_name: c.req.query('page_name') || undefined,
source: c.req.query('source') || undefined,
@@ -103,7 +115,10 @@ entryRoutes.get('/', async (c) => {
// GET /entries/search?q=...&owner_id=...&source=...&entry_type=...&library=...&mode=keyword|semantic
// - mode=keyword(預設):D1 LIKEbase,永遠可用)。
// - mode=semantic:需 embed 模組開(Vectorize+AI binding)。未開 → 降級 keyword + capability_hint 告知缺能力(#7 發現閉環)。
// - mode=semantic:需 embed 模組開(Vectorize+AI binding)。未開 → 降級 keyword +
// capability_hint 告知缺能力(#7 發現閉環)。capability_hint 是講給非技術使用者聽的人話
// 2026-08-08 修:曾經直接透傳到封測用戶眼前的工程師導向文字,見該欄位旁註);
// 技術細節另放 admin_hint 給維運者/CC 看。
// - entry_typebase 通用 filtercaller 傳任意 type,如 workflowbase 不寫死語意,workflow-discovery Q4)。
// - library:多值庫 filter(逗號分隔,portal-auth P1)。keyword 走 json_extractNULL→general
// semantic 走 Vectorize $in。未帶=全庫(行為不變)。
@@ -147,7 +162,15 @@ entryRoutes.get('/search', async (c) => {
owner_id, source, entry_type, library, topK: fetchTopK, min_score,
});
if (hits === null) {
// 模組沒開:誠實降級 keyword + 告知「叫 CC 幫你開 vectorize」(不假裝有語義)。
// 模組沒開:誠實降級 keyword(不假裝有語義)。
// 🔴 2026-08-08(總管交辦,Oscar 封測回報「語義搜尋搜不到」的根因修復):
// capability_hint 是「誠實透傳」鏈路(cypher-executor portal-data.ts → portal 前端)
// 唯一一次主動告訴非技術使用者「發生什麼事+下一步」的機會,故預設文案改成人話:
// - 不假設讀者懂 vectorize / binding / redeploy / 「叫 CC」這些我們內部的修法指令
// - 誠實承認這次是降級(不是「一樣好,只是換個名字」)
// - 給一個他自己做得到的下一步(換字重試 / 聯絡我們開通),不是要他自己修系統
// 技術細節不丟掉,換到 admin_hint(機器可讀,給真正的維運者/CC 看)——薄殼原則:
// 這裡是 base API 的一個回應形狀,兩個欄位並存,caller 自己挑要顯示哪一個。
const entries = await searchEntries(c.env.DB, q, owner_id, entry_type, undefined, library, source, include_deprecated);
return c.json({
success: true,
@@ -156,6 +179,8 @@ entryRoutes.get('/search', async (c) => {
mode: 'keyword',
requested_mode: 'semantic',
capability_hint:
'語意搜尋還沒開通,這次顯示的是關鍵字比對結果,不是用「意思」找的——你打的字要盡量貼近資料裡實際出現的詞才容易搜到。想啟用語意搜尋,請聯絡我們協助開通。',
admin_hint:
'語義查詢需先開 vectorizeembed 模組)。叫 CC「幫我開語義查詢」即可(設 kbdb_embed:true + redeploy)。本次已降級關鍵字搜尋。',
});
}
@@ -182,6 +207,53 @@ entryRoutes.get('/search', async (c) => {
}
// 補位後截斷回 caller 實際要的量(多撈的餘量只用來墊背,不多回傳超過請求的筆數)。
entries = entries.slice(0, requestedTopK);
// 🔴 2026-08-08(總管交辦二修,Oscar 封測案:模組有開、但語意搜尋回空——回報後才發現
// 這條路徑比「模組沒開」的 capability_hint 更常撞到,卻完全沒有 hint,是「誠實但沉默」):
// count:0 對用戶而言是無資訊的——「我打的字不對」跟「這個庫的索引根本沒建好」需要的下一步
// 完全不同,系統卻兩種都回同一句「找不到」。分辨依據(不新開一套覆蓋率查詢,共用 embed.ts
// 既有的 backfillStatus——2026-08-07 檢修孔/診斷聚合端點已在用同一支,同一件事只留一套
// 實作,2026-08-08 credential 那次「兩套並存必然漂移」的教訓不重踩):
// - hits.length===0Vectorize 端零命中,含 embed.ts 內建絕對門檻)
// → 查 backfillStatus(owner_id).embedded
// 0 筆 → 'no_index'(這個租戶根本沒有索引資料,不是使用者的問題)
// >0 筆 → 'no_match'(有索引,這次查詢正常沒撞到——換句話說再搜)
// - hits.length>0 但濾光 → 'stale_index'。誠實核算過機制:relativeMinScore 的 cut
// 必然 <= 最高分(cut = max(絕對下限, top×0.8) <= top),所以「最高分那筆」永遠會
// 自己活下來,相對門檻**不可能**把非空結果砍成 0——這裡不能寫「相似度不夠」這種
// 不符合實際機制的話(誠實限制,mindset §7)。真正會讓 hits>0 卻 entries=0 的只有
// 兩種:命中的向量對應的資料**已下架**isDeprecatedEntry 濾掉)、或**已被刪除**
// (getEntry 找不到,孤兒向量)——兩者都是「索引裡有,但實際資料不在了」,故稱
// stale_index(索引與資料兩邊不同步),不誤導使用者去猜「換個字」。
// 三態都給人話 capability_hint(給使用者)+ admin_hint(技術細節,給維運者/CC)。
// 正常有結果(entries.length>0)完全不受影響,回應形狀不變。
if (entries.length === 0) {
let empty_reason: 'no_index' | 'no_match' | 'stale_index';
let capability_hint: string;
let admin_hint: string;
if (hits.length === 0) {
const status = await backfillStatus(c.env, { owner_id });
if (status.embedded === 0) {
empty_reason = 'no_index';
capability_hint =
'這個知識庫目前還沒有可供語意搜尋的資料,所以搜不到——不是你打的字有問題。請聯絡我們確認索引有沒有建好。';
admin_hint = `owner_id=${owner_id ?? '(all)'} 範圍 backfillStatus.embedded=0:從未 embed,或 backfill 未跑過。`;
} else {
empty_reason = 'no_match';
capability_hint = '沒有找到符合的內容,換個說法或更具體的關鍵字再試試看。';
admin_hint = `owner_id=${owner_id ?? '(all)'} 已有 ${status.embedded} 筆嵌入資料,但本次查詢在 Vectorize 端零命中(含 embed.ts 絕對門檻過濾)。`;
}
} else {
empty_reason = 'stale_index';
capability_hint =
'找到的內容都已經被下架或移除了,所以沒有可顯示的結果——換個關鍵字再試試看,或聯絡我們確認索引有沒有過期。';
admin_hint = `Vectorize 命中 ${hits.length} 筆,但 hydrate 後全部是已下架或找不到對應資料(孤兒向量),非分數門檻造成——相對門檻數學上不可能砍光非空結果(cut<=top)。`;
}
return c.json({
success: true, entries, count: entries.length, mode: 'semantic',
empty_reason, capability_hint, admin_hint,
});
}
return c.json({ success: true, entries, count: entries.length, mode: 'semantic' });
}
@@ -0,0 +1,164 @@
// credential-legacy-migration.test.ts — 「新讀取端上線、舊資料還沒搬完」自癒補丁的迴歸測試
// (D38 圍牆修復收尾,總管交辦,2026-08-08youlin 測試實例 2026-08-07 事故的根因修復)。
//
// 測試策略比照既有 execution-log.test.ts / library-map.test.ts:真 SQLitenode:sqlite
// 套 migration 原檔,比 mock DB 更硬——驗的是真實 SQL 語意,不是「以為 SQL 長這樣」。
// 本檔對 D1 介面的直接呼叫全是測試灌資料/驗證用(與上述兩份既有測試同一慣例),
// 不是牆外業務程式碼繞過 API,逐行標 kbdb-sql-ok。
//
// ── 這份測試在證明什麼(對應 leo 08-08 追加的三個安全性質)─────────────────
// 1. 反向驗證(禁假綠的核心):先重建 2026-08-07 事故的確切狀態——0002 舊表有資料、
// entries 沒有——直接呼叫 cypher-executor 熱路徑會打的同一個端點(GET /entries?
// entry_type=credential&owner_id=X),**在補丁加入之前這裡本該回空陣列**(就是
// 事故當天「缺少 credential: kbdb_internal_token」的成因)。本檔驗證補丁讓它改回
// 找得到,等於把事故重現一次、再證明修好。
// 2. 冪等:同一個 owner 呼叫兩次、三次,entries 筆數不重複增加。
// 3. 對「已搬過」與「還沒搬」的實例都正確:不同 owner 各自獨立、互不干擾;已無舊表
// (模擬清理步驟做完之後)時查詢仍正常運作、不報錯。
import { describe, it, expect } from 'vitest';
import { DatabaseSync } from 'node:sqlite';
import { readFileSync } from 'node:fs';
import { Hono } from 'hono';
import { entryRoutes } from '../src/routes/entries';
import { migrateLegacyCredentialsForOwner } from '../src/actions/credential-legacy-migration';
import type { Bindings } from '../src/types';
// ── node:sqlite → D1 介面最小 adapter(同 execution-log.test.ts / library-map.test.ts 手法)──
function makeSqliteD1(): D1Database {
const raw = new DatabaseSync(':memory:');
raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8')); // kbdb-sql-ok: 測試 adapter 套 migration 原檔,比照 execution-log.test.ts
raw.exec(readFileSync(new URL('../migrations/0002_credentials.sql', import.meta.url), 'utf8')); // kbdb-sql-ok: 測試 adapter 套 migration 原檔
raw.exec(readFileSync(new URL('../migrations/0005_credential_template.sql', import.meta.url), 'utf8')); // kbdb-sql-ok: 測試 adapter 套 migration 原檔
function stmt(sql: string, params: unknown[]) {
const s = {
bind(...args: unknown[]) { return stmt(sql, args); },
async all<T>() { return { results: raw.prepare(sql).all(...params) as T[] }; }, // kbdb-sql-ok: 測試 adapter,比照 execution-log.test.ts
async first<T>() { return (raw.prepare(sql).get(...params) ?? null) as T | null; }, // kbdb-sql-ok: 測試 adapter
async run() { raw.prepare(sql).run(...params); return { success: true }; }, // kbdb-sql-ok: 測試 adapter
};
return s;
}
return { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database; // kbdb-sql-ok: 測試 adapter 的 D1 介面實作本身
}
function envWith(db: D1Database): Bindings {
return { DB: db, ENVIRONMENT: 'test' } as unknown as Bindings;
}
function app(db: D1Database) {
const a = new Hono<{ Bindings: Bindings }>();
a.route('/entries', entryRoutes);
return { fetch: (path: string, init?: RequestInit) => a.request(path, init, envWith(db)) };
}
describe('credential-legacy-migration — 反向驗證:重現 2026-08-07 youlin 事故並證明修好', () => {
it('事故前置狀態(舊表有資料、entries 沒有)下,GET /entries 一樣能讀到 credential(自癒生效)', async () => {
const db = makeSqliteD1();
// 重建事故現場:舊表寫一筆 kbdb_internal_tokenentries 完全沒有對應列
// (新 code 部署了、migration 沒跑——2026-08-07 youlin 的確切狀態)。
await db
.prepare( // kbdb-sql-ok: 測試重建舊表資料現場,比照 execution-log.test.ts
`INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at)
VALUES (?, ?, ?, ?, ?, ?, NULL)`,
)
.bind('yuga3bse', 'kbdb_internal_token', 'kbdb', 'high', 'CRED_KBDB_INTERNAL_TOKEN_DEADBEEF', Math.floor(Date.now() / 1000))
.run();
// 事故當天的確切呼叫形狀:cypher-executor credentials.ts 的 findCredentialEntry /
// getCredentialDirectory 都是打這個端點。
const a = app(db);
const res = await a.fetch('/entries?owner_id=yuga3bse&entry_type=credential&page_name=kbdb_internal_token&limit=1');
const body = (await res.json()) as { success: boolean; entries: Array<{ page_name: string; metadata_json: string }> };
expect(body.success).toBe(true);
expect(body.entries.length).toBe(1); // 補丁加入前這裡是 0——2026-08-07 事故的確切失敗形狀
expect(body.entries[0].page_name).toBe('kbdb_internal_token');
const meta = JSON.parse(body.entries[0].metadata_json) as { secret_ref: string; service: string };
expect(meta.secret_ref).toBe('CRED_KBDB_INTERNAL_TOKEN_DEADBEEF');
expect(meta.service).toBe('kbdb');
});
it('搬移後 KBDB 核心三表結構不變,舊表刻意保留(本檔不清舊表,交由之後的清理步驟)', async () => {
const db = makeSqliteD1();
await db
.prepare(`INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at) VALUES (?, ?, ?, ?, ?, ?, NULL)`) // kbdb-sql-ok: 測試寫入
.bind('t1', 'x', null, 'standard', 'CRED_X_AAAA', 1)
.run();
await migrateLegacyCredentialsForOwner(db, 't1');
const tables = await db
.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`) // kbdb-sql-ok: 測試查詢
.all<{ name: string }>();
const names = (tables.results ?? []).map((t) => t.name).sort();
// entries/templates/entry_values 三張核心表 + credentials(舊表,尚未清理)——沒有第五張表。
expect(names).toEqual(['credentials', 'entries', 'entry_values', 'templates']);
});
});
describe('credential-legacy-migration — 冪等(同一 owner 呼叫多次不重複搬)', () => {
it('連呼叫三次,entries 裡該租戶的 credential 筆數固定為 1', async () => {
const db = makeSqliteD1();
await db
.prepare(`INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at) VALUES (?, ?, ?, ?, ?, ?, NULL)`) // kbdb-sql-ok: 測試寫入
.bind('owner-idem', 'telegram_bot_token', 'telegram', 'standard', 'CRED_TELEGRAM_BOT_TOKEN_BEEF', 1000)
.run();
const n1 = await migrateLegacyCredentialsForOwner(db, 'owner-idem');
const n2 = await migrateLegacyCredentialsForOwner(db, 'owner-idem');
const n3 = await migrateLegacyCredentialsForOwner(db, 'owner-idem');
expect(n1).toBe(1); // 第一次:真的搬了一筆
expect(n2).toBe(0); // 第二次起:NOT EXISTS 擋下,不重複
expect(n3).toBe(0);
const rows = await db
.prepare(`SELECT COUNT(*) AS n FROM entries WHERE entry_type='credential' AND owner_id=?1`) // kbdb-sql-ok: 測試查詢
.bind('owner-idem')
.first<{ n: number }>();
expect(rows?.n).toBe(1);
});
});
describe('credential-legacy-migration — 多租戶互不干擾,且對「已搬過」與「還沒搬」同時安全', () => {
it('兩個 owner 各自的 credential 不互相污染;沒有資料的 owner 查詢回空、不報錯', async () => {
const db = makeSqliteD1();
await db
.prepare(`INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at) VALUES (?, ?, ?, ?, ?, ?, NULL)`) // kbdb-sql-ok: 測試寫入
.bind('tenant-a', 'gemini_api_key', 'gemini', 'high', 'CRED_GEMINI_API_KEY_A1', 1)
.run();
await db
.prepare(`INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at) VALUES (?, ?, ?, ?, ?, ?, NULL)`) // kbdb-sql-ok: 測試寫入
.bind('tenant-b', 'gemini_api_key', 'gemini', 'high', 'CRED_GEMINI_API_KEY_B2', 1)
.run();
await migrateLegacyCredentialsForOwner(db, 'tenant-a');
// tenant-b 完全沒觸發過搬遷(模擬「還沒走到這個租戶的下一次 workflow 執行」)。
const a = app(db);
const resA = await a.fetch('/entries?owner_id=tenant-a&entry_type=credential&page_name=gemini_api_key&limit=1');
const bodyA = (await resA.json()) as { entries: Array<{ metadata_json: string }> };
expect(JSON.parse(bodyA.entries[0].metadata_json).secret_ref).toBe('CRED_GEMINI_API_KEY_A1');
// tenant-b 第一次讀取才觸發自己的搬遷(GET /entries 路由本身會呼叫,不需要呼叫端先知道)。
const resB = await a.fetch('/entries?owner_id=tenant-b&entry_type=credential&page_name=gemini_api_key&limit=1');
const bodyB = (await resB.json()) as { entries: Array<{ metadata_json: string }> };
expect(JSON.parse(bodyB.entries[0].metadata_json).secret_ref).toBe('CRED_GEMINI_API_KEY_B2');
// 沒有任何資料的第三個 owner:不報錯、乾淨回空。
const resC = await a.fetch('/entries?owner_id=tenant-c&entry_type=credential&limit=200');
const bodyC = (await resC.json()) as { success: boolean; entries: unknown[] };
expect(bodyC.success).toBe(true);
expect(bodyC.entries).toEqual([]);
});
it('舊表已被清理(不存在)時查詢照常運作(模擬所有租戶搬完後的最終清理狀態)', async () => {
const db = makeSqliteD1();
await db.prepare(`DROP TABLE credentials`).run(); // kbdb-sql-ok: 測試模擬「清理步驟已執行」的終態,非牆外存取
const n = await migrateLegacyCredentialsForOwner(db, 'anyone');
expect(n).toBe(0); // 短路,不報錯
const a = app(db);
const res = await a.fetch('/entries?owner_id=anyone&entry_type=credential&limit=200');
const body = (await res.json()) as { success: boolean; entries: unknown[] };
expect(body.success).toBe(true);
expect(body.entries).toEqual([]);
});
});
@@ -0,0 +1,109 @@
// 語意搜尋回空時「為什麼空」的回歸測試(2026-08-08,總管交辦二修,Oscar 封測案更正後的真因)。
//
// 背景:原本以為 Oscar 撞到的是「capability_hint 文案太工程師」,後來查出模組其實有開,
// 真正發生的是 GET /entries/search?mode=semantic 零命中時回 {success:true, entries:[], count:0}
// ——誠實(沒假裝有結果)但完全不說為什麼,用戶看到的是「這裡沒有這筆資料」,
// 真相可能是「索引根本沒建好」。三態:
// - no_index :這個 owner 範圍從沒 embed 過(backfillStatus.embedded===0
// - no_match :有索引,這次查詢在 Vectorize 端零命中(正常的「找不到」)
// - stale_index Vectorize 端有命中,但 hydrate 後全部是已下架/找不到對應資料(孤兒向量)
// (不是「相對門檻濾光」——relativeMinScore 的 cut 數學上 <= 最高分,不可能讓非空結果變空)
// 正常有結果(count>0)不受影響,不應該出現 empty_reason 欄位。
import { describe, it, expect } from 'vitest';
import { Hono } from 'hono';
import { entryRoutes } from '../src/routes/entries';
import type { Bindings, Entry } from '../src/types';
function mkEntry(id: string, opts: { deprecated?: boolean } = {}): Entry {
return {
id, content: '一些內容', entry_type: 'block', owner_id: 'oscar-tenant', parent_id: null,
page_name: null, refs_json: '[]', tags_json: '[]', task_status: null, content_hash: null,
is_embedded: 1, confidence: null,
metadata_json: opts.deprecated ? JSON.stringify({ status: 'deprecated' }) : JSON.stringify({ embed: true }),
created_at: 1, updated_at: 1,
};
}
// fake D1COUNT 查詢回傳可配置的 embeddedCount`WHERE id = ?`getEntry)回傳可配置的 entry。
function makeFakeDB(opts: { embeddedCount?: number; hydrateEntry?: Entry | null } = {}) {
const embeddedCount = opts.embeddedCount ?? 0;
const prepare = (sql: string) => {
let bound: unknown[] = [];
const stmt = {
bind(...args: unknown[]) { bound = args; return stmt; },
async first<T>() {
if (sql.includes('WHERE id = ?')) {
return (opts.hydrateEntry ?? null) as unknown as T;
}
return { c: embeddedCount } as unknown as T;
},
async all<T>() { return { results: [] as T[] }; },
async run() { return { success: true }; },
};
return stmt;
};
return { prepare } as unknown as D1Database;
}
function makeApp() {
const app = new Hono<{ Bindings: Bindings }>();
app.route('/entries', entryRoutes);
return app;
}
function makeEnv(dbOpts: Parameters<typeof makeFakeDB>[0], matches: { id: string; score: number }[]): Bindings {
return {
DB: makeFakeDB(dbOpts),
ENVIRONMENT: 'test',
AI: { async run() { return { data: [[0.1, 0.2, 0.3]] }; } },
VECTORIZE: { async query() { return { matches }; } },
} as unknown as Bindings;
}
describe('GET /entries/search?mode=semantic — 零命中時分辨「為什麼空」', () => {
it('embedded=0(從沒 embed 過)→ empty_reason=no_index,人話不含 vectorize/redeploy/CC', async () => {
const app = makeApp();
const env = makeEnv({ embeddedCount: 0 }, []);
const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=oscar-tenant', {}, env);
const body = (await res.json()) as Record<string, unknown>;
expect(body.mode).toBe('semantic');
expect(body.count).toBe(0);
expect(body.empty_reason).toBe('no_index');
const hint = body.capability_hint as string;
expect(hint).toBeTruthy();
expect(/vectorize|redeploy|CC「|binding|kbdb_embed/i.test(hint)).toBe(false);
expect(body.admin_hint).toBeTruthy();
});
it('embedded>0 但這次零命中 → empty_reason=no_match(正常的「找不到」,非故障)', async () => {
const app = makeApp();
const env = makeEnv({ embeddedCount: 42 }, []);
const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=oscar-tenant', {}, env);
const body = (await res.json()) as Record<string, unknown>;
expect(body.mode).toBe('semantic');
expect(body.count).toBe(0);
expect(body.empty_reason).toBe('no_match');
});
it('Vectorize 有命中但對應資料已下架 → empty_reason=stale_index(非分數門檻)', async () => {
const app = makeApp();
// 命中一筆,但 hydrate 回來的 entry 是已下架的 → 濾光 → entries=0hits.length=1(>0)。
const env = makeEnv({ hydrateEntry: mkEntry('e1', { deprecated: true }) }, [{ id: 'e1', score: 0.6 }]);
const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=oscar-tenant', {}, env);
const body = (await res.json()) as Record<string, unknown>;
expect(body.mode).toBe('semantic');
expect(body.count).toBe(0);
expect(body.empty_reason).toBe('stale_index');
});
it('正常有結果(count>0)不受影響:無 empty_reason 欄位', async () => {
const app = makeApp();
const env = makeEnv({ hydrateEntry: mkEntry('e1') }, [{ id: 'e1', score: 0.6 }]);
const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=oscar-tenant', {}, env);
const body = (await res.json()) as Record<string, unknown>;
expect(body.mode).toBe('semantic');
expect(body.count).toBe(1);
expect(body.empty_reason).toBeUndefined();
expect(body.capability_hint).toBeUndefined();
});
});