/** * 「靜態租戶字串不得用於資料面過濾」— 機械閘(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; }