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>
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 「靜態租戶字串不得用於資料面過濾」機械閘的**執行殼**(Arcrun#108)。
|
||||
*
|
||||
* 規則本體(純函式、零 node 相依)在 `tenant-source-rules.mjs`——拆開的理由是
|
||||
* **這道閘自己要能被測試**:Workers runtime 的 vitest 沒有 node:fs,規則若和走檔案系統的
|
||||
* 程式碼綁在一起就 import 不動,測試也就寫不出來(當晚有一道閘連讀自己的原始碼都擋,
|
||||
* 結果沒人驗得了它會不會誤攔)。現在 tests/tenant-gate.test.ts 直接餵字串驗規則。
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/check-tenant-source.mjs [projectRoot] # 掃 src/,有違規 → exit 1
|
||||
* node scripts/check-tenant-source.mjs --stdin <相對路徑> # 從 stdin 讀「即將寫入的內容」
|
||||
* npm run check:tenant
|
||||
*
|
||||
* `--stdin` 是給 `.claude/hooks/pre-write-guard.sh`(規則 8.1)用的:在檔案**還沒寫下去之前**
|
||||
* 就擋,這樣違規根本進不了工作區。Edit 只給片段也沒關係——規則是逐行的,正好只看新寫的那幾行。
|
||||
*/
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { join, relative, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { scanSource } from './tenant-source-rules.mjs';
|
||||
|
||||
/** 遞迴列出目錄下的 .ts 檔(相對 root 的路徑)。 */
|
||||
function listTsFiles(root, dir = root, out = []) {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const full = join(dir, name);
|
||||
if (statSync(full).isDirectory()) listTsFiles(root, full, out);
|
||||
else if (name.endsWith('.ts')) out.push(relative(root, full));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 掃整個 cypher-executor/src。回傳違規清單。 */
|
||||
export function scanProject(projectRoot) {
|
||||
const srcRoot = join(projectRoot, 'src');
|
||||
const all = [];
|
||||
for (const rel of listTsFiles(projectRoot, srcRoot)) {
|
||||
const relPosix = rel.split(sep).join('/');
|
||||
all.push(
|
||||
...scanSource(relPosix, readFileSync(join(projectRoot, rel), 'utf8')).map((v) => ({
|
||||
...v,
|
||||
file: relPosix,
|
||||
})),
|
||||
);
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
/** stdin 模式:讀「即將寫入的內容」,印違規、有違規 → exit 1。 */
|
||||
async function runStdin(relPath) {
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) chunks.push(chunk);
|
||||
const violations = scanSource(relPath, Buffer.concat(chunks).toString('utf8'));
|
||||
if (violations.length === 0) return 0;
|
||||
for (const v of violations) {
|
||||
console.error(`[${v.rule}] ${relPath}(新寫入的第 ${v.line} 行):${v.text}`);
|
||||
console.error(` → ${v.message}`);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
if (process.argv[2] === '--stdin') {
|
||||
process.exit(await runStdin(process.argv[3] ?? 'src/unknown.ts'));
|
||||
}
|
||||
const projectRoot = process.argv[2] ?? process.cwd();
|
||||
const violations = scanProject(projectRoot);
|
||||
if (violations.length === 0) {
|
||||
console.log('✓ 租戶來源檢查通過:資料面 owner_id 全部來自 src/lib/tenant.ts');
|
||||
process.exit(0);
|
||||
}
|
||||
console.error('❌ 租戶來源檢查失敗(Arcrun#108 的閘)\n');
|
||||
for (const v of violations) {
|
||||
console.error(` [${v.rule}] ${v.file}:${v.line}`);
|
||||
console.error(` ${v.text}`);
|
||||
console.error(` → ${v.message}\n`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* 「靜態租戶字串不得用於資料面過濾」— 機械閘(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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user