Files
uncle6me-web b223a69884 fix(portal): 藏書地圖看得到自己的知識——租戶字串改從「寫入端」來,不再拿環境變數預設值(Arcrun#108)
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>
2026-08-13 00:15:58 +08:00

119 lines
6.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 「靜態租戶字串不得用於資料面過濾」這道閘**自己**的測試(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 ?rawbuild-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_TENANTportalTenant(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*['"][^'"]+['"]/);
});
});