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>
143 lines
7.7 KiB
JavaScript
143 lines
7.7 KiB
JavaScript
/**
|
||
* 「靜態租戶字串不得用於資料面過濾」— 機械閘(Arcrun#108)。
|
||
*
|
||
* ─────────────────────────────────────────────────────────────────────────────
|
||
* 為什麼要有這道閘
|
||
* ─────────────────────────────────────────────────────────────────────────────
|
||
* 同一句話已經寫錯兩次:
|
||
* #105 `ownerNamespace(env) = env.MCP_OWNER_NAMESPACE || "leo"`
|
||
* #108 `portalTenant(env) = env.CONSOLE_TENANT || "leo"`
|
||
* 兩次都是「拿一個部署環境變數的字面預設值,當成使用者資料的歸屬」。規則早就在(rule 07
|
||
* 薄殼、design §3.3 租戶不下發),但**沒有任何機制會擋**,所以它每隔幾週就長回來一次。
|
||
* leo 2026-08-12:「做一個平台要減少 hotfix。」⇒ 修掉 bug 不算完成,要留下會擋的東西。
|
||
*
|
||
* ─────────────────────────────────────────────────────────────────────────────
|
||
* 判準:看「有沒有在做那件事」,不是看「有沒有出現那個詞」
|
||
* ─────────────────────────────────────────────────────────────────────────────
|
||
* 誤攔比漏攔更容易殺死一道閘(被擋煩了就有人把它關掉),所以三條規則全部盯**行為**:
|
||
*
|
||
* T1 租戶環境變數只有一個產地
|
||
* `env.CONSOLE_TENANT` / `env.ARCRUN_NAMESPACE` 只能在 src/lib/tenant.ts 被讀取。
|
||
* 盯的是「你在把部署設定讀成身分」這個動作本身。註解裡寫這兩個字不算(只看 `env.X` 取值)。
|
||
*
|
||
* T2 資料面租戶識別不得憑空捏造
|
||
* `as TenantId` 只能出現在 src/lib/tenant.ts,且不得套在字面字串上。
|
||
* 盯的是「繞過唯一產地自己造一個租戶」。
|
||
*
|
||
* T3 帳號層字串不得流進知識資料面
|
||
* 同一行同時「在組 owner_id」且「值來自 portalTenant()/accountTenant()」→ 擋。
|
||
* 這正是 #108 那一行的形狀:`owner_id=${encodeURIComponent(portalTenant(c.env))}`。
|
||
* `owner_id: ns`(帳號子 namespace,合法)不命中;`x.owner_id` 這種讀取也不命中。
|
||
*
|
||
* ─────────────────────────────────────────────────────────────────────────────
|
||
* 這道閘自己要能被測試
|
||
* ─────────────────────────────────────────────────────────────────────────────
|
||
* 核心是純函式 `scanSource(relPath, text)`(不碰檔案系統),測試餵好例子/壞例子驗它會不會叫
|
||
* (tests/tenant-gate.test.ts)——當晚有一道閘連讀自己的原始碼都擋,導致沒人驗得了它。
|
||
* 本檔只掃 `src/`,測試與 fixture 都不在掃描範圍內,所以**不會擋到自己**。
|
||
*
|
||
* 本檔是**純規則**(零 node 相依),所以 Workers runtime 的 vitest 也 import 得動;
|
||
* 走檔案系統的那半在 check-tenant-source.mjs。
|
||
*/
|
||
|
||
/** 唯一允許產出租戶識別的檔案(相對 cypher-executor/)。 */
|
||
export const TENANT_SOURCE_FILE = 'src/lib/tenant.ts';
|
||
/** 只宣告型別、不取值的檔案(`CONSOLE_TENANT?: string` 這種)。 */
|
||
const TYPE_DECL_FILES = new Set(['src/types.ts']);
|
||
|
||
/** 被視為「租戶來源」的環境變數——讀它們=在決定使用者資料的歸屬。 */
|
||
const TENANT_ENV_VARS = ['CONSOLE_TENANT', 'ARCRUN_NAMESPACE'];
|
||
|
||
/** 帳號層租戶字串的取得方式(回的是 string 不是 TenantId,不得用於知識資料面)。 */
|
||
const ACCOUNT_TENANT_CALLS = ['portalTenant(', 'accountTenant('];
|
||
|
||
const ENV_READ = new RegExp(String.raw`\benv\s*\.\s*(${TENANT_ENV_VARS.join('|')})\b`);
|
||
const AS_TENANT_ID = /\bas\s+TenantId\b/;
|
||
const LITERAL_AS_TENANT_ID = /(['"`][^'"`]*['"`])\s*as\s+TenantId\b/;
|
||
|
||
/**
|
||
* 「這一行在組 owner_id 嗎?」——**構造**才算,**讀取**不算。
|
||
* 算:`owner_id=` 出現在字串/樣板裡、`owner_id:` 當成物件屬性在賦值
|
||
* 不算:`x.owner_id`(讀)、`owner_id?:`(型別宣告)、`owner_id` 單獨出現在註解句子裡
|
||
*/
|
||
function buildsOwnerFilter(line) {
|
||
const code = stripComment(line);
|
||
if (!code.includes('owner_id')) return false;
|
||
if (/owner_id\s*=/.test(code) && !/[.\w]owner_id\s*=/.test(code)) return true; // `?owner_id=` / `owner_id=${...}`
|
||
if (/(^|[^.\w])owner_id\s*:/.test(code) && !/owner_id\s*\?\s*:/.test(code)) return true; // `owner_id: X`
|
||
return false;
|
||
}
|
||
|
||
/** 去掉行末 `//` 註解(不處理跨行 /* *\/——那種行本來就不含可執行的取值)。 */
|
||
function stripComment(line) {
|
||
const i = line.indexOf('//');
|
||
return i === -1 ? line : line.slice(0, i);
|
||
}
|
||
|
||
/** 整行是註解?(`//` 開頭或位於 JSDoc 區塊的 ` *` 行) */
|
||
function isCommentLine(line) {
|
||
const t = line.trim();
|
||
return t.startsWith('//') || t.startsWith('*') || t.startsWith('/*');
|
||
}
|
||
|
||
/**
|
||
* 掃一份原始碼,回傳違規清單(純函式,測試直接餵字串)。
|
||
* @param {string} relPath 相對 cypher-executor/ 的路徑,例如 'src/routes/portal-data.ts'
|
||
* @param {string} text 檔案內容
|
||
* @returns {{rule: string, line: number, text: string, message: string}[]}
|
||
*/
|
||
export function scanSource(relPath, text) {
|
||
const rel = relPath.split('\\').join('/');
|
||
const violations = [];
|
||
const lines = text.split('\n');
|
||
|
||
lines.forEach((line, idx) => {
|
||
const n = idx + 1;
|
||
const push = (rule, message) =>
|
||
violations.push({ rule, line: n, text: line.trim(), message });
|
||
|
||
if (isCommentLine(line)) return;
|
||
const code = stripComment(line);
|
||
|
||
// T1:租戶環境變數只有一個產地
|
||
if (rel !== TENANT_SOURCE_FILE && !TYPE_DECL_FILES.has(rel) && ENV_READ.test(code)) {
|
||
push(
|
||
'T1',
|
||
`租戶環境變數只能在 ${TENANT_SOURCE_FILE} 讀取。` +
|
||
'在別處讀它=又一次「身分來自環境變數」(#105/#108 同形),' +
|
||
'請改呼叫 knowledgeOwner(env)(知識資料面)或 accountTenant(env)(帳號層)。',
|
||
);
|
||
}
|
||
|
||
// T2:資料面租戶識別不得憑空捏造
|
||
if (AS_TENANT_ID.test(code)) {
|
||
if (rel !== TENANT_SOURCE_FILE) {
|
||
push(
|
||
'T2',
|
||
`TenantId 只能由 ${TENANT_SOURCE_FILE} 產生。自己 cast 一個等於繞過唯一產地——` +
|
||
'請用 knowledgeOwner(env) 或 tenantFromApiKey(header)。',
|
||
);
|
||
} else if (LITERAL_AS_TENANT_ID.test(code)) {
|
||
push(
|
||
'T2',
|
||
'不得把**字面字串**當成租戶識別(那就是 `|| "leo"` 那個預設值的原形)。' +
|
||
'解析不到請丟 TenantUnresolvedError,誠實說讀不到。',
|
||
);
|
||
}
|
||
}
|
||
|
||
// T3:帳號層字串不得流進知識資料面
|
||
if (buildsOwnerFilter(line) && ACCOUNT_TENANT_CALLS.some((fn) => code.includes(fn))) {
|
||
push(
|
||
'T3',
|
||
'這一行拿**帳號層**租戶字串去組知識資料面的 owner_id 過濾——' +
|
||
'正是 #108 那一行(1854 條三元組被過濾成 0)。' +
|
||
'知識資料面請用 knowledgeOwner(env) + ownerQuery()/ownerField()。',
|
||
);
|
||
}
|
||
});
|
||
|
||
return violations;
|
||
}
|
||
|