b223a69884
leo 2026-08-12 實撞:藏書地圖回 0 個庫,同一分鐘 KBDB 裡有 1854 條三元組,
`arcrun_whoami` 顯示 admin/全部知識庫、`kbdb_search` 也查得到——只有地圖那格是空的。
病根(不是資料掉了,是讀寫兩端各拿一個來源):
寫入端 owner_id = `~/.arcrun/config.yaml` 的 `api_key`(CLI push/小幫手上傳/MCP,
leo = `bfezv28v`)
讀取端過濾 = `portalTenant(env) = env.CONSOLE_TENANT || "leo"`
——repo toml 帶的**官方 prod 值**,而 `acr` 從來不注入 CONSOLE_TENANT
⇒ 那個 `"leo"` 不是理論邊角,是每台 self-hosted 實例的實際行為,1854 條全被濾掉。
與 #105(`env.MCP_OWNER_NAMESPACE || "leo"`)同一句話,換一個檔案。
租戶字串該從哪裡來(本票的核心判斷):
**從「寫入這批知識的那一方」來,不是從一份手抄的環境變數預設值來。**
不是「掛到每個帳號上」——portal 帳號共用同一台實例的知識庫(design D-2),
帳號之間的差別是 libraries 權限不是 owner_id;複製一份到帳號上只是多一個會過期的副本。
#105 真正的教訓是:過濾用的租戶字串要有單一權威來源、解析不到要誠實失敗、且要能機械驗證。
修法:
1. 唯一產地 `cypher-executor/src/lib/tenant.ts`
- `knowledgeOwner(env)` → branded `TenantId`:`ARCRUN_NAMESPACE` → `CONSOLE_TENANT` →
丟 `TenantUnresolvedError`。**沒有字面預設值**——`|| 'leo'` 正是把「這台機器沒設定」
偽裝成「你沒有資料」的元凶。
- `accountTenant(env)` → 普通 `string`(帳號子 namespace `{tenant}::portal` 與 cypher
自己寫的設定用它)。**回 string 是刻意的**:型別上就不可能流進知識資料面。
- 資料面過濾一律經 `ownerQuery()` / `ownerField()`,只吃 `TenantId`。
2. 值的正解由 CLI 從真相源導出:`acr update` 把 config 的 `api_key` 注入成 `ARCRUN_NAMESPACE`,
但**先驗再寫**(`GET /kbdb/map?owner_id=<api_key>` 查得到庫才寫;查不到/問不到就一個字
都不動)。無條件覆蓋會把「知識本來就在 CONSOLE_TENANT 底下」的一鍵安裝實例指向空的那一格
——那是 #97/#106 那類「更新一次把人家的東西弄不見」,比原本的 bug 更糟。
未注入時回退 CONSOLE_TENANT ⇒ 對官方 prod 與未更新的實例,這次改動是惰性的。
3. 空地圖分四態(沿 #100「讀不到就說讀不到」):no_library_grant/filtered_out/
scope_mismatch/confirmed_empty。scope_mismatch 以前不存在,所以設定錯誤被畫成
「你沒有資料」。回應仍不含租戶字串(design §3.3 紅線)。
4. 同族一起修(同一道閘一次抓到):console-dashboard 4 處、console-auth 1 處
——console 首頁的規模數字與藏書地圖對 leo 也一直是空的。
留下的閘(規則存在但沒機制驗證=會再犯第三次):
· 型別閘:TenantId 只能由 tenant.ts 產出 → 拿隨手一個 string 去過濾,tsc 當場不給過。
· 出貨閘:scripts/build-worker-artifacts.mjs 編 tier2 成品前先掃,違規 → 編不出成品。
· 閘自己可測:規則是純函式(tenant-source-rules.mjs),tests/tenant-gate.test.ts
逐條驗「5 種壞例子會擋」+「11 種合法寫法零誤攔」;掃描範圍只有 src/,擋不到自己。
規範寫入 .claude/rules/02-forbidden.md 第六類、system-dev/wiki/mistakes.md #26。
沒動:庫權限過濾(一字未改,回歸測試釘住)、帳號資料落點、任何金鑰、租戶字串仍不下發前端。
驗證:
cypher vitest 441 綠 / 14 紅,14 紅與 base commit e05518a 逐字相同(既有)
tsc 5 個既有錯誤,零新增
cli node:test 60/60 綠(含本次新增 12 條);tsc 零錯誤
閘 壞例子實跑 exit 1;build 實跑「建置中止」;乾淨時實跑通過
端到端 ◐ 未驗:需部署到 leo21c,那道閘要 leo 親手解(見 PR ③)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
119 lines
6.7 KiB
TypeScript
119 lines
6.7 KiB
TypeScript
/**
|
||
* 「靜態租戶字串不得用於資料面過濾」這道閘**自己**的測試(Arcrun#108)。
|
||
*
|
||
* 收工標準明列三條,這裡逐條釘:
|
||
* ① 會擋,不是只提醒 → 壞例子必須產出違規(CLI 端據此 exit 1、hook 據此 exit 2)
|
||
* ② 判準看「有沒有在做那件事」 → 一整組「長得像但沒在做」的合法寫法必須零誤攔
|
||
* ③ 閘自己要能被測試 → 規則是純函式,這裡直接餵字串;不需要跑檔案系統、也擋不到自己
|
||
*
|
||
* 外加一條回歸:現行 src/ 必須是乾淨的(`?raw` 讀真原始碼,不是讀我編的假字串)。
|
||
*/
|
||
import { describe, it, expect } from 'vitest';
|
||
// @ts-expect-error -- 純規則模組(.mjs,零 node 相依),型別非本檔關注重點
|
||
import { scanSource, TENANT_SOURCE_FILE } from '../scripts/tenant-source-rules.mjs';
|
||
// @ts-expect-error -- vite ?raw:build-time 讀檔,runtime 是純字串(Workers 沒有 node:fs)
|
||
import tenantLibSource from '../src/lib/tenant.ts?raw';
|
||
// @ts-expect-error -- 同上
|
||
import portalDataSource from '../src/routes/portal-data.ts?raw';
|
||
// @ts-expect-error -- 同上
|
||
import portalSource from '../src/routes/portal.ts?raw';
|
||
// @ts-expect-error -- 同上
|
||
import consoleAuthSource from '../src/routes/console-auth.ts?raw';
|
||
// @ts-expect-error -- 同上
|
||
import consoleDashboardSource from '../src/routes/console-dashboard.ts?raw';
|
||
|
||
type Violation = { rule: string; line: number; text: string; message: string };
|
||
const scan = (path: string, text: string): Violation[] => scanSource(path, text) as Violation[];
|
||
const rulesOf = (v: Violation[]): string[] => [...new Set(v.map((x) => x.rule))].sort();
|
||
|
||
const FILE = 'src/routes/portal-data.ts';
|
||
|
||
describe('閘會擋:三種「靜態租戶字串進資料面」的真實形狀', () => {
|
||
it('T1 — 在 tenant.ts 以外讀租戶環境變數(#105/#108 的原句)', () => {
|
||
const bad = `export function portalTenant(env: Bindings): string {\n return env.CONSOLE_TENANT || 'leo';\n}`;
|
||
const v = scan('src/routes/portal.ts', bad);
|
||
expect(rulesOf(v)).toContain('T1');
|
||
expect(v[0].message).toContain('knowledgeOwner');
|
||
});
|
||
|
||
it('T1 — `c.env.ARCRUN_NAMESPACE` 也一樣(換一個變數名不是換一個做法)', () => {
|
||
const v = scan('src/routes/console-dashboard.ts', `const t = c.env.ARCRUN_NAMESPACE || 'leo';`);
|
||
expect(rulesOf(v)).toEqual(['T1']);
|
||
});
|
||
|
||
it('T2 — 繞過唯一產地自己 cast 一個 TenantId', () => {
|
||
const v = scan(FILE, `const tenant = (c.env.SOMETHING ?? '') as TenantId;`);
|
||
expect(rulesOf(v)).toContain('T2');
|
||
});
|
||
|
||
it('T2 — 連在 tenant.ts 裡都不准把「字面字串」當成租戶識別(那就是 `|| "leo"` 的原形)', () => {
|
||
const v = scan(TENANT_SOURCE_FILE, ` return 'leo' as TenantId;`);
|
||
expect(rulesOf(v)).toEqual(['T2']);
|
||
expect(v[0].message).toContain('TenantUnresolvedError');
|
||
});
|
||
|
||
it('T3 — 拿帳號層字串去組知識資料面的 owner_id(#108 那一行,逐字)', () => {
|
||
const bad = " const res = await kbdbFetch(c.env, `/map?owner_id=${encodeURIComponent(portalTenant(c.env))}`);";
|
||
const v = scan(FILE, bad);
|
||
expect(rulesOf(v)).toContain('T3');
|
||
expect(v[0].message).toContain('1854');
|
||
});
|
||
|
||
it('T3 — 換成物件屬性寫法一樣擋(`owner_id: portalTenant(c.env)`)', () => {
|
||
const v = scan(FILE, ` body: JSON.stringify({ values, owner_id: portalTenant(c.env) }),`);
|
||
expect(rulesOf(v)).toContain('T3');
|
||
});
|
||
|
||
it('T3 — accountTenant() 這個新名字也擋(規則盯的是「這是帳號層的值」,不是某個函式名字的拼法)', () => {
|
||
const v = scan(FILE, " kbdbFetch(env, `/entries?owner_id=${accountTenant(env)}`);");
|
||
expect(rulesOf(v)).toContain('T3');
|
||
});
|
||
});
|
||
|
||
describe('閘不誤攔:長得像、但沒有在做那件事的合法寫法', () => {
|
||
const legit: [string, string, string][] = [
|
||
['讀取別人回傳的 owner_id(不是在組過濾)', FILE, ` if (!isOwnedBy(entry.owner_id, knowledgeOwner(c.env))) return notFound(c);`],
|
||
['型別宣告裡的 owner_id 欄位', FILE, ` | { record?: { values?: Record<string, unknown>; owner_id?: string | null } }`],
|
||
['走唯一入口組過濾', FILE, " const res = await kbdbFetch(c.env, `/map?${ownerQuery(tenant)}`);"],
|
||
['走唯一入口填 body', FILE, ` body: JSON.stringify({ template, values, owner_id: ownerField(tenant) }),`],
|
||
['帳號子 namespace 的過濾(`{tenant}::portal`,那是 cypher 自己寫的資料)', 'src/routes/portal.ts', ` const res = await kbdbFetch(env, \`/records/by-template/x?owner_id=\${encodeURIComponent(ns)}\`);`],
|
||
['請求自帶的租戶(webhooks-named 慣例:呼叫端就是租戶)', 'src/routes/webhooks-named.ts', ` owner_id: apiKey,`],
|
||
['註解裡整句在講 CONSOLE_TENANT 與 owner_id(文件不是行為)', FILE, `// 之前的病:owner_id 拿 env.CONSOLE_TENANT,portalTenant(c.env) 那條路整個空掉`],
|
||
['JSDoc 區塊裡出現同樣的字', FILE, ` * 舊寫法 owner_id=\${portalTenant(env)} 已廢除,改走 knowledgeOwner。`],
|
||
['行末註解裡出現(程式碼本身乾淨)', FILE, ` const tenant = knowledgeOwner(c.env); // 不是 portalTenant(c.env),也不是 owner_id=leo`],
|
||
['tenant.ts 自己讀環境變數(它就是唯一產地)', TENANT_SOURCE_FILE, ` const injected = (env.ARCRUN_NAMESPACE ?? '').trim();`],
|
||
['types.ts 只宣告型別不取值', 'src/types.ts', ` CONSOLE_TENANT?: string;`],
|
||
];
|
||
|
||
for (const [name, path, line] of legit) {
|
||
it(`零誤攔:${name}`, () => {
|
||
expect(scan(path, line)).toEqual([]);
|
||
});
|
||
}
|
||
});
|
||
|
||
describe('回歸:現行原始碼是乾淨的(讀真檔,不是讀我編的字串)', () => {
|
||
const files: [string, string][] = [
|
||
[TENANT_SOURCE_FILE, tenantLibSource as string],
|
||
['src/routes/portal-data.ts', portalDataSource as string],
|
||
['src/routes/portal.ts', portalSource as string],
|
||
['src/routes/console-auth.ts', consoleAuthSource as string],
|
||
['src/routes/console-dashboard.ts', consoleDashboardSource as string],
|
||
];
|
||
for (const [path, text] of files) {
|
||
it(`${path} 零違規`, () => {
|
||
expect(scan(path, text)).toEqual([]);
|
||
});
|
||
}
|
||
|
||
it('唯一產地本身沒有字面預設值(knowledgeOwner 解析不到要用丟的,不是回 "leo")', () => {
|
||
const body = (tenantLibSource as string).slice(
|
||
(tenantLibSource as string).indexOf('export function knowledgeOwner'),
|
||
(tenantLibSource as string).indexOf('export function tenantFromApiKey'),
|
||
);
|
||
expect(body).toContain('TenantUnresolvedError');
|
||
// 解析路徑只准回 env 讀到的值;任何 `|| '...'` / `?? '...'` 形式的字面 fallback 都是本票的病本身
|
||
expect(body).not.toMatch(/(\|\||\?\?)\s*['"][^'"]+['"]/);
|
||
});
|
||
});
|