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:
uncle6me-web
2026-08-13 00:15:58 +08:00
parent e05518a2b4
commit b223a69884
19 changed files with 1290 additions and 55 deletions
+2 -1
View File
@@ -5,7 +5,8 @@
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"test": "vitest run"
"check:tenant": "node scripts/check-tenant-source.mjs",
"test": "node scripts/check-tenant-source.mjs && vitest run"
},
"dependencies": {
"@hono/zod-openapi": "^1.2.4",
@@ -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;
}
+144
View File
@@ -0,0 +1,144 @@
/**
* 租戶字串的**唯一產地**Arcrun#108)。
*
* ─────────────────────────────────────────────────────────────────────────────
* 這個檔案存在的理由(不是為了整潔,是為了不再犯同一個錯)
* ─────────────────────────────────────────────────────────────────────────────
* #105`ownerNamespace(env) = env.MCP_OWNER_NAMESPACE || "leo"` ——身分來自環境變數。
* #108`portalTenant(env) = env.CONSOLE_TENANT || "leo"` ——同一句話換一個檔案。
*
* 兩次的形狀一模一樣:**「這筆資料是誰的」與「這個請求是誰」來自兩個可以各自漂移的地方**。
* leo 的知識在 `owner_id=bfezv28v`08-11 回灌時定的名,也就是他 `~/.arcrun/config.yaml`
* 的 `api_key`、小幫手上傳時帶的 `X-Arcrun-API-Key`),而 cypher 拿 repo 預設值 `"leo"`
* 去過濾 ⇒ 1854 條三元組被過濾成 0,畫面卻只寫「沒有庫」。
*
* ─────────────────────────────────────────────────────────────────────────────
* 定案:租戶字串從哪裡來
* ─────────────────────────────────────────────────────────────────────────────
* **從「寫入這批知識的那一方」來,而不是從一份手抄的環境變數預設值來。**
*
* 寫入端只有一個真相源:使用者 `~/.arcrun/config.yaml` 的 `api_key`(=實例 namespace)。
* CLI 用它 push workflow`{ns}:wf:*`)、小幫手用它上傳知識(`owner_id=ns`)、
* MCP 用它當 Bearer。**讀取端必須用同一個值**,否則讀寫兩端各說各話。
* 所以 `acr init/update` 把它注入成 `ARCRUN_NAMESPACE`cli/src/lib/deploy.ts
* 與 CF_ACCOUNT_ID / WORKER_SUBDOMAIN / MULTI_TENANT 同一批 CLI 管理值)——
* 它不是「使用者要自己維護的設定」,是**從既有真相源導出的值**,因此不會漂。
*
* 那為什麼不像 #105 一樣「掛在登入者身上」?因為在這個架構裡租戶**不是**每人一個:
* portal 帳號共用同一台實例的知識庫(design D-2,帳號自己住 `{tenant}::portal` 子
* namespace),帳號之間的差別是 `libraries` 權限,不是 owner_id。把 owner_id 複製一份
* 到每個帳號上,只會多一個可以各自過期的副本——那正是本票的病,不是解藥。
* #105 真正的教訓不是「一律搬到帳號上」,而是:
* **過濾用的租戶字串要有單一權威來源、解析不到要誠實失敗、而且要能被機械驗證。**
* 這三件事就是本檔在做的事。
*
* ─────────────────────────────────────────────────────────────────────────────
* 型別即閘(`TenantId`
* ─────────────────────────────────────────────────────────────────────────────
* `TenantId` 是 branded string**只能**由本檔產生(`knowledgeOwner` / `tenantFromApiKey`)。
* 所有資料面 owner_id 過濾一律經 `ownerQuery()` / `ownerField()`,而那兩支只吃 `TenantId`
* ⇒ 想把「隨手一個 env 字串」拿去過濾,`tsc` 當場就不給過。
*
* 配套的機械檢查在 `scripts/check-tenant-source.mjs`(測試 `tests/tenant-gate.test.ts`
* 會同時驗「repo 現況乾淨」與「這道閘真的擋得住壞例子」)。
*/
import type { Bindings } from '../types';
/**
* 可以拿去做資料面過濾的租戶識別。
*
* branded type:外面拿不到建構子,只能從本檔的兩支 minter 取得——
* 一支從實例 namespace 來(`knowledgeOwner`),一支從請求本身來(`tenantFromApiKey`)。
* 兩支都不含字面預設值。
*/
export type TenantId = string & { readonly __tenantId: unique symbol };
/** 實例 namespace 解析不出來 → 誠實炸掉,不拿預設值當答案(#100「讀不到就說讀不到」同源)。 */
export class TenantUnresolvedError extends Error {
constructor(message: string) {
super(message);
this.name = 'TenantUnresolvedError';
}
}
/**
* 這台實例的**知識 owner_id**——三元組 / entries / records / 藏書地圖 / 工作流 KV
* 全部掛在這個字串底下,由 CLI、小幫手、MCP 寫入時決定。
*
* 解析順序(**沒有字面預設值**):
* 1. `ARCRUN_NAMESPACE`——`acr init/update` 從 `~/.arcrun/config.yaml` 的 `api_key` 注入。
* 這是寫入端用的那個值本身,因此永遠對得上。
* 2. `CONSOLE_TENANT`——官方 prod`cypher.arcrun.dev`)與 #108 之前部署的實例走這條。
* 官方 prod 的知識確實寫在 `leo` 底下,所以對它而言這是正解;對跑過 `acr update`
* 的 self-hosted 實例,第 1 條會先命中。
* 3. 兩個都沒有 → **丟 TenantUnresolvedError**。不回 `"leo"`:那個預設值正是把
* 「這台機器沒設定」偽裝成「你沒有資料」的元凶。
*/
export function knowledgeOwner(env: Bindings): TenantId {
const injected = (env.ARCRUN_NAMESPACE ?? '').trim();
if (injected) return injected as TenantId;
const legacy = (env.CONSOLE_TENANT ?? '').trim();
if (legacy) return legacy as TenantId;
throw new TenantUnresolvedError(
'這個部署沒有知識命名空間(ARCRUN_NAMESPACE / CONSOLE_TENANT 都沒設)——' +
'不知道要去哪一格找資料。請跑 `acr update` 讓它從你的 ~/.arcrun/config.yaml 注入。',
);
}
/**
* 請求自帶的租戶(`X-Arcrun-API-Key`namespace 明碼,self-hosted 身分模型)。
*
* 這條路的租戶來自**請求本身**而不是環境變數,本來就沒有 #105/#108 的漂移問題;
* 收進本檔只是為了讓「所有 owner_id 過濾值都是 TenantId」這條型別閘沒有破口。
* 空字串不給過——沒有身分就不該有查詢範圍。
*/
export function tenantFromApiKey(apiKey: string): TenantId {
const key = (apiKey ?? '').trim();
if (!key) throw new TenantUnresolvedError('缺少 X-Arcrun-API-Key,無法決定查詢範圍');
return key as TenantId;
}
/**
* 帳號子 namespace 用的租戶字串(design D-2:帳號資料住 `{tenant}::portal`)。
*
* 🔴 **回傳的是 `string`,不是 `TenantId`——這是刻意的**:帳號那批資料是 cypher 自己
* 寫進去的(用的就是這個值),所以它自洽;但它**不可以**拿去過濾知識資料面,
* 否則就是把 #108 再犯一次。型別上不給過,不必靠人記得。
*
* 保留 `'leo'` 預設值是為了不動既有帳號的落點(改了會讓舊實例登不進去)。
*/
export function accountTenant(env: Bindings): string {
return env.CONSOLE_TENANT || 'leo';
}
/**
* KBDB query string 的 owner_id 過濾片段——**資料面過濾的唯一入口之一**。
* 用法:`kbdbFetch(env, `/map?${ownerQuery(tenant)}`)`
*/
export function ownerQuery(tenant: TenantId): string {
return `owner_id=${encodeURIComponent(tenant)}`;
}
/**
* 要放進 JSON body / URLSearchParams 的 owner_id 值——**資料面過濾的唯一入口之一**。
* 用法:`JSON.stringify({ owner_id: ownerField(tenant) })`
*/
export function ownerField(tenant: TenantId): string {
return tenant;
}
/**
* 🔴 **刻意不帶租戶範圍**的查詢片段(KBDB 慣例:`owner_id` 空值=不過濾)。
*
* 唯一合法用途:#100 的普查——「本租戶查到 0 筆」時再問一次「整台實例到底有沒有」,
* 用來分辨「查不到」與「沒有」。**回傳的是統計數字,不是任何人的內容**;
* 拿它去撈實際資料就是跨租戶外洩。名字取得這麼長就是要讓 review 一眼看見。
*/
export function censusQueryAllTenants(): string {
return 'owner_id=';
}
/** 逐筆核對歸屬(讀回來的 record/entry 是不是這個租戶的)。缺欄位一律視為不是。 */
export function isOwnedBy(value: unknown, tenant: TenantId): boolean {
return typeof value === 'string' && value === (tenant as string);
}
+12 -1
View File
@@ -35,6 +35,8 @@ import {
readAuthStore,
type AuthConsoleRecord,
} from '../lib/portal-auth-store';
// Arcrun#108:租戶字串唯一產地。
import { knowledgeOwner } from '../lib/tenant';
export const consoleAuthRouter = new Hono<{ Bindings: Bindings }>();
@@ -79,8 +81,17 @@ async function hashPassword(password: string, salt: string): Promise<string> {
return h;
}
/**
* console 登入後下發給前端當 api_key 用的租戶字串(舊 console 的設計,與 portal 不同:
* portal 絕不下發,console 會)。
*
* Arcrun#108:這是**知識資料面**的 owner_id(前端拿它直打 `/kbdb/*`),所以必須與寫入端
* (CLI/小幫手/MCP 用的實例 namespace)同源。以前直接讀 `env.CONSOLE_TENANT || 'leo'`
* ⇒ 與 portal 同一個病:資料在 `bfezv28v`、過濾拿 `leo`console 首頁的藏書地圖同樣是空的。
* 現在走唯一產地 `lib/tenant.ts`。
*/
function tenantOf(c: { env: Bindings }): string {
return c.env.CONSOLE_TENANT || 'leo';
return knowledgeOwner(c.env);
}
// ── D61:帳密的家 ─────────────────────────────────────────────────────────────
@@ -72,6 +72,9 @@ import {
taipeiDayKey,
} from '../lib/console-dashboard-model';
import { applyTriageCheck, buildTriageModel, type TriageCheckAction } from '../lib/console-triage-model';
// Arcrun#108:租戶字串唯一產地。console 首頁的規模數字/藏書地圖也曾因為拿 CONSOLE_TENANT
// 過濾而看不到自己的資料——與 portal 同一個病,同一個修法。
import { knowledgeOwner } from '../lib/tenant';
export const consoleDashboardRouter = new Hono<{ Bindings: Bindings }>();
@@ -265,7 +268,7 @@ export async function cachedGiteaSprint(
// GET /console/dashboard-data — 聚合 JSON(無需登入;唯讀、不含機敏值)
consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
const tenant = c.env.CONSOLE_TENANT || 'leo';
const tenant = knowledgeOwner(c.env); // #108:知識資料面的 owner_id 只有一個產地(lib/tenant.ts
const now = Date.now();
const { base: kbdbUrl, headers: kbdbHeaders } = kbdbBase(c.env);
const graphUrl = graphBase(c.env);
@@ -451,7 +454,7 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
// limit=1(只拿 total 欄)或現成 stats 聚合端點——不逐筆掃庫,不撞子請求上限。
// 搜尋功能本身仍可搜全庫(資料不藏),只是規模感不再引用遺產總數。
consoleDashboardRouter.get('/console/kb-scale-data', async (c) => {
const tenant = c.env.CONSOLE_TENANT || 'leo';
const tenant = knowledgeOwner(c.env); // #108:知識資料面的 owner_id 只有一個產地(lib/tenant.ts
const { base, headers } = kbdbBase(c.env);
const now = Date.now();
const [wikiCards, tripletTotal, embedStatus] = await Promise.all([
@@ -499,7 +502,7 @@ consoleDashboardRouter.get('/console/triage-data', async (c) => {
const ok = await validateConsoleSession(c.env, c.req.header('authorization'));
if (!ok) return c.json({ error: '需要登入(console session' }, 401);
const tenant = c.env.CONSOLE_TENANT || 'leo';
const tenant = knowledgeOwner(c.env); // #108:知識資料面的 owner_id 只有一個產地(lib/tenant.ts
const [todoEntries, inboxEntries] = await Promise.all([
fetchEntries(c.env, tenant, 'todo', 500),
fetchEntries(c.env, tenant, 'inbox', 200),
@@ -530,7 +533,7 @@ consoleDashboardRouter.post('/console/triage-check', async (c) => {
if (!entryId) return c.json({ error: 'entry_id 必填' }, 400);
const action: TriageCheckAction = body?.action === 'restore' ? 'restore' : 'check';
const tenant = c.env.CONSOLE_TENANT || 'leo';
const tenant = knowledgeOwner(c.env); // #108:知識資料面的 owner_id 只有一個產地(lib/tenant.ts
const { base, headers } = kbdbBase(c.env);
// 先 GET 原 entry(整串回寫的前提),順便守兩道邊界:
+87 -28
View File
@@ -23,7 +23,10 @@
import { Hono } from 'hono';
import type { Context } from 'hono';
import type { Bindings } from '../types';
import { kbdbFetch, run, requirePortalUser, parseLibraries, portalTenant, hasGraphAccess, workflowsVisible, uploadEnabled, buildDiagnostics } from './portal';
import { kbdbFetch, run, requirePortalUser, parseLibraries, hasGraphAccess, workflowsVisible, uploadEnabled, buildDiagnostics } from './portal';
// Arcrun#108:知識資料面的租戶字串只有一個產地(lib/tenant.ts)。這裡刻意**不再** import
// portalTenant——它是帳號層的值(回 string 不是 TenantId),拿來過濾知識就是本票的病。
import { knowledgeOwner, ownerField, ownerQuery, isOwnedBy, censusQueryAllTenants, type TenantId } from '../lib/tenant';
import { graphBase, graphHeaders } from './kbdb-proxy';
import { executeWebhookGraph } from '../actions/webhook-handlers';
@@ -40,7 +43,9 @@ export const portalDataRouter = new Hono<{ Bindings: Bindings }>();
/** 讀 tenant 的 named workflow graph`{tenant}:wf:{name}`)。不存在/壞 record → null。 */
async function getTenantWorkflowGraph(env: Bindings, name: string): Promise<Record<string, unknown> | null> {
const raw = await env.WEBHOOKS.get(`${portalTenant(env)}:wf:${name}`, 'text');
// #108workflow 是 CLI `acr push` 用實例 namespace 寫進來的(`{ns}:wf:*`),
// 所以讀的時候也要用同一個 namespace,不是帳號層那個字串。
const raw = await env.WEBHOOKS.get(`${knowledgeOwner(env)}:wf:${name}`, 'text');
if (!raw) return null;
try {
const rec = JSON.parse(raw) as { graph?: Record<string, unknown> };
@@ -190,9 +195,11 @@ export function findBestNodeMatch(searchTerm: string, nodeNames: string[]): stri
* 三元組條數(KBDB `/records/triplet-stats` 真 SQL COUNT)。owner 傳 '' =不限租戶(KBDB 端
* `?1 = '' OR e.owner_id = ?1`)。null=讀不到——caller 據此不敢宣稱 0。
*/
async function tripletCount(env: Bindings, owner: string): Promise<number | null> {
async function tripletCount(env: Bindings, owner: TenantId | null): Promise<number | null> {
try {
const res = await kbdbFetch(env, `/records/triplet-stats?owner_id=${encodeURIComponent(owner)}`);
// owner=null 普查全庫(#100 用來分辨「查不到」與「沒有」)。這是唯一一個
// 刻意不帶租戶範圍的查詢,因此走一支名字就在喊「我沒有租戶範圍」的專用 helper。
const res = await kbdbFetch(env, `/records/triplet-stats?${owner === null ? censusQueryAllTenants() : ownerQuery(owner)}`);
if (!res.ok) return null;
const body = (await res.json().catch(() => null)) as { stats?: { triplet_count?: unknown }[] } | null;
if (!body || !Array.isArray(body.stats)) return null;
@@ -219,16 +226,16 @@ async function tripletCount(env: Bindings, owner: string): Promise<number | null
* owned=0 但 any>0 → owner_id / 範圍對不上,不是空庫 → 畫面說讀不到
* owned=null → 讀不到 → 畫面說讀不到
*/
async function tripletCensus(env: Bindings, tenant: string): Promise<{ owned: number | null; any: number | null }> {
async function tripletCensus(env: Bindings, tenant: TenantId): Promise<{ owned: number | null; any: number | null }> {
const owned = await tripletCount(env, tenant);
if (owned !== 0) return { owned, any: null }; // 非 0(含 null)不必多問一次
return { owned, any: await tripletCount(env, '') };
return { owned, any: await tripletCount(env, null) };
}
/** 從 KBDB triplet records 找最佳比對節點名(t96 plugin fuzzy fallback 用)。 */
async function fuzzyFindNode(env: Bindings, tenant: string, searchTerm: string): Promise<string | null> {
async function fuzzyFindNode(env: Bindings, tenant: TenantId, searchTerm: string): Promise<string | null> {
try {
const res = await kbdbFetch(env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant)}`);
const res = await kbdbFetch(env, `/records/by-template/triplet?${ownerQuery(tenant)}`);
if (!res.ok) return null;
const body = (await res.json().catch(() => null)) as { records?: { values?: Record<string, unknown> }[] } | null;
if (!body || !Array.isArray(body.records)) return null;
@@ -263,7 +270,7 @@ portalDataRouter.get('/portal/data/search', (c) =>
return c.json({ success: true, entries: [], count: 0, mode: 'keyword', note: '此帳號尚未被授權任何知識庫,請聯絡管理員。' });
}
const params = new URLSearchParams({ q, owner_id: portalTenant(c.env) });
const params = new URLSearchParams({ q, owner_id: ownerField(knowledgeOwner(c.env)) });
if (!libraries.includes('*')) params.set('library', libraries.join(','));
// 透傳的只有「在權限範圍內再收窄」的 filterowner_id/library 上面已由 server 定死,
// caller 傳什麼都不看(URLSearchParams 是新建的,蓋不掉)。
@@ -334,7 +341,7 @@ portalDataRouter.get('/portal/data/entries/:id', (c) =>
const body = (await res.json()) as { entry?: { owner_id?: string | null; metadata_json?: string | null } };
const entry = body.entry;
if (!entry) return notFound(c);
if ((entry.owner_id ?? '') !== portalTenant(c.env)) return notFound(c);
if (!isOwnedBy(entry.owner_id, knowledgeOwner(c.env))) return notFound(c);
if (!canReadLibrary(libraries, entryLibrary(entry))) return notFound(c);
return c.json({ success: true, entry });
}),
@@ -359,7 +366,7 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) =>
const nodeName = normalizeCjkQuery(c.req.param('name'));
// ① tenant workflow 路徑(存在才走;inputnode=path、depth=query 預設 2、namespace/owner=tenant
const tenant = portalTenant(c.env);
const tenant = knowledgeOwner(c.env);
const wfGraph = await getTenantWorkflowGraph(c.env, 'graph_neighbors');
if (wfGraph) {
const depthRaw = c.req.query('depth') ?? '';
@@ -420,9 +427,9 @@ portalDataRouter.get('/portal/data/graph/overview', (c) =>
if (!(await hasGraphAccess(c.env, libraries))) {
return c.json({ error: '無知識圖譜檢視權限' }, 403);
}
const tenant = portalTenant(c.env);
const tenant = knowledgeOwner(c.env);
const [res, census] = await Promise.all([
kbdbFetch(c.env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant)}&limit=500`),
kbdbFetch(c.env, `/records/by-template/triplet?${ownerQuery(tenant)}&limit=500`),
tripletCensus(c.env, tenant),
]);
const tripletsTotal = census.owned;
@@ -505,7 +512,7 @@ portalDataRouter.get('/portal/data/chat', (c) =>
wfGraph,
{ question },
'rag_chat',
portalTenant(c.env),
knowledgeOwner(c.env),
c.executionCtx,
);
if (!result.success) {
@@ -611,7 +618,7 @@ portalDataRouter.get('/portal/data/workflows', (c) =>
// 資料源與 /webhooks/named /workflows/:name/executions 同一份(WEBHOOKS/ANALYTICS KV)。
// 不經 HTTP 打自己(global_fetch_strictly_public 下 fetch 自己 hostname 會 self-loop),
// 直讀同 worker 的 KV binding;欄位收斂成唯讀展示需要的最小集合。
const tenant = portalTenant(c.env);
const tenant = knowledgeOwner(c.env);
const prefix = `${tenant}:wf:`;
const list = await c.env.WEBHOOKS.list({ prefix });
const workflows = await Promise.all(
@@ -637,7 +644,7 @@ portalDataRouter.get('/portal/data/workflows', (c) =>
let last_execution: { timestamp: string; verdict?: string } | null = null;
const execRes = await kbdbFetch(
c.env,
`/execution-log/latest?${new URLSearchParams({ workflow_id: name, owner_id: tenant }).toString()}`,
`/execution-log/latest?${new URLSearchParams({ workflow_id: name, owner_id: ownerField(tenant) }).toString()}`,
);
const execBody = await execRes.json().catch(() => null) as {
success?: boolean;
@@ -691,25 +698,42 @@ function recordLibrary(values: Record<string, unknown> | undefined): string | nu
/** record 可讀?租戶要對;有標 library 的還要在用戶庫集合內。 */
function canReadRecord(
rec: { values?: Record<string, unknown>; owner_id?: string | null },
tenant: string,
tenant: TenantId,
libraries: string[],
): boolean {
if ((rec.owner_id ?? '') !== tenant) return false;
if (!isOwnedBy(rec.owner_id, tenant)) return false;
const lib = recordLibrary(rec.values);
return lib === null || canReadLibrary(libraries, lib);
}
// GET /portal/data/map — 藏書地圖全館視圖,**只回這個帳號有權限的庫**。
// KBDB 的 /map 對權限無知(它回全館),過濾在這裡做——MCP 不得比 portal 同一個帳號看得更多。
//
// 🔴 Arcrun#108:一張空地圖有四種成因,**判準留在 server,不留給前端猜**
// (沿 #100 總圖那條「讀不到就說讀不到」,同一套 census 機制):
// no_library_grant :這個帳號一個庫都沒被授權 → 是權限問題,不是資料問題
// filtered_out :實例有庫,但都不在這個帳號的權限內 → 正常且正確的隔離
// confirmed_empty :實例真的一條三元組都沒有 → **只有此時**才准說「還沒有知識」
// scope_mismatch :實例有三元組,但本命名空間一條都撈不到 → **命名空間對不上**
// (就是本票:1854 條在 bfezv28v,卻拿 "leo" 去過濾)
// scope_mismatch 這一格以前不存在,所以設定錯誤被畫成「你沒有資料」——leo 看到的空地圖。
//
// ⚠️ 回應**絕不含租戶字串**(design §3.3 紅線:前端拿到租戶字串就能繞過庫過濾直打 /kbdb/*)。
// 只回代碼與數字,文字說明講「請通知管理員」,命名空間本身不下發。
portalDataRouter.get('/portal/data/map', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const libraries = parseLibraries(auth.user.values.libraries);
if (libraries.length === 0) {
return c.json({ success: true, libraries: [], count: 0, note: '此帳號尚未被授權任何知識庫,請聯絡管理員。' });
return c.json({
success: true, libraries: [], count: 0,
empty_confirmed: true, empty_reason: 'no_library_grant',
note: '此帳號尚未被授權任何知識庫,請聯絡管理員。',
});
}
const res = await kbdbFetch(c.env, `/map?owner_id=${encodeURIComponent(portalTenant(c.env))}`);
const tenant = knowledgeOwner(c.env);
const res = await kbdbFetch(c.env, `/map?${ownerQuery(tenant)}`);
if (!res.ok) {
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
}
@@ -720,7 +744,42 @@ portalDataRouter.get('/portal/data/map', (c) =>
const allowed = body.libraries.filter(
(l) => typeof l?.library === 'string' && canReadLibrary(libraries, l.library),
);
return c.json({ success: true, libraries: allowed, count: allowed.length });
if (allowed.length > 0) {
return c.json({ success: true, libraries: allowed, count: allowed.length, empty_confirmed: false, empty_reason: null });
}
// 以下都是「回空」的路徑——多花一次查詢換一個**有根據**的理由,不猜。
if (body.libraries.length > 0) {
// 命名空間對得上(撈得到庫),只是這個帳號沒有那些庫的權限=隔離正常運作。
return c.json({
success: true, libraries: [], count: 0,
empty_confirmed: true, empty_reason: 'filtered_out',
note: '這個帳號目前沒有任何知識庫的檢視權限,請聯絡管理員開通。',
});
}
const census = await tripletCensus(c.env, tenant);
if (census.owned === null || (census.owned === 0 && census.any === null)) {
return c.json({
success: true, libraries: [], count: 0,
empty_confirmed: false, empty_reason: 'unreadable',
note: '讀不到知識庫的統計,無法確認庫裡有沒有東西——這不是「還沒有知識」,是這次讀取失敗。請稍後重整或通知管理員。',
});
}
if (census.owned === 0 && (census.any ?? 0) > 0) {
return c.json({
success: true, libraries: [], count: 0,
empty_confirmed: false, empty_reason: 'scope_mismatch',
instance_triplet_count: census.any,
note:
`讀不到你這個帳號範圍內的藏書——但這台實例裡有 ${census.any} 條知識關聯。` +
'這不是「還沒有知識」,不用去重新上傳;比較像知識的歸屬命名空間對不上。' +
'請通知管理員跑一次 `acr update`(會把你安裝時的命名空間同步給雲端),或檢查 ARCRUN_NAMESPACE 設定。',
});
}
return c.json({
success: true, libraries: [], count: 0,
empty_confirmed: true, empty_reason: 'confirmed_empty',
note: '知識庫還沒有任何內容——上傳文件後就會出現在這裡。',
});
}),
);
@@ -734,7 +793,7 @@ portalDataRouter.get('/portal/data/map/:library', (c) =>
if (!canReadLibrary(libraries, library)) return notFound(c);
const res = await kbdbFetch(
c.env,
`/map/${encodeURIComponent(library)}?owner_id=${encodeURIComponent(portalTenant(c.env))}`,
`/map/${encodeURIComponent(library)}?${ownerQuery(knowledgeOwner(c.env))}`,
);
if (res.status === 404) return notFound(c);
if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status}` }, 502);
@@ -775,7 +834,7 @@ portalDataRouter.post('/portal/data/templates', (c) =>
name: body.name,
slots: body.slots,
description: typeof body.description === 'string' ? body.description : undefined,
created_by: portalTenant(c.env),
created_by: knowledgeOwner(c.env),
}),
});
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
@@ -790,10 +849,10 @@ portalDataRouter.get('/portal/data/records/by-template/:template', (c) =>
if (!auth.ok) return auth.res;
const libraries = parseLibraries(auth.user.values.libraries);
if (libraries.length === 0) return c.json({ success: true, records: [], count: 0 });
const tenant = portalTenant(c.env);
const tenant = knowledgeOwner(c.env);
const res = await kbdbFetch(
c.env,
`/records/by-template/${encodeURIComponent(c.req.param('template'))}?owner_id=${encodeURIComponent(tenant)}`,
`/records/by-template/${encodeURIComponent(c.req.param('template'))}?${ownerQuery(tenant)}`,
);
if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status}` }, 502);
const body = (await res.json().catch(() => null)) as
@@ -824,7 +883,7 @@ portalDataRouter.get('/portal/data/records/:recordId', (c) =>
| null;
const record = body?.record;
if (!record) return notFound(c);
if (!canReadRecord(record, portalTenant(c.env), libraries)) return notFound(c);
if (!canReadRecord(record, knowledgeOwner(c.env), libraries)) return notFound(c);
return c.json({ success: true, record });
}),
);
@@ -856,7 +915,7 @@ portalDataRouter.post('/portal/data/records', (c) =>
const res = await kbdbFetch(c.env, '/records', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ template: body.template, values, owner_id: portalTenant(c.env) }),
body: JSON.stringify({ template: body.template, values, owner_id: ownerField(knowledgeOwner(c.env)) }),
});
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
}),
@@ -884,7 +943,7 @@ portalDataRouter.get('/portal/data/diagnostics', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const tenant = portalTenant(c.env);
const tenant = knowledgeOwner(c.env);
const core = await buildDiagnostics(c.env, tenant);
return c.json({
generated_at: new Date().toISOString(),
+48 -21
View File
@@ -25,6 +25,9 @@ import { kbdbBase } from './kbdb-proxy';
import { validateConsoleSession } from './console-auth';
import { hashPassword, verifyPassword, randomHex, generatePassword, sha256Hex } from '../lib/portal-auth';
import { PORTAL_TEMPLATE_SEEDS } from '../lib/portal-seeds';
// Arcrun#108:租戶字串只有一個產地(lib/tenant.ts)。帳號面用 accountTenant(普通 string),
// 知識資料面用 knowledgeOwnerTenantId)——型別分家,拿錯編不過。
import { accountTenant, knowledgeOwner, ownerField, ownerQuery, tenantFromApiKey, TenantUnresolvedError, type TenantId } from '../lib/tenant';
// arcrun-rag#10/portal/admin/ai 存 Gemini key 走 credentials.ts 的**唯一**寫入路徑,
// 不在 portal 這層另造第二套儲存(D36:值進 Workers SecretD1 只留 ref)。
import { storeCredential, hasCredential } from './credentials';
@@ -58,14 +61,27 @@ export const LIBRARY_TEMPLATE = 'portal_library';
// ── 基礎 helpers ────────────────────────────────────────────────────────────
/** 租戶字串(=知識資料的 owner_id)。預設沿 console-auth 同款 'leo'。**只在 server 側使用,永不下發前端**。 */
/**
* **** owner_idArcrun#108
*
* namespace`{tenant}::portal`design D-2 cypher
* extractor_config / credentials cypher
*
* 🔴 **** / entries / records / /
* CLI namespace #108
* leo 1854 `CONSOLE_TENANT="leo"` 0
* `lib/tenant.ts` `knowledgeOwner(env)` `TenantId` `string`
*
*
* ** server 使**
*/
export function portalTenant(env: Bindings): string {
return env.CONSOLE_TENANT || 'leo';
return accountTenant(env);
}
/** 帳號子 namespacedesign D-2)。 */
function portalNamespace(env: Bindings): string {
return `${portalTenant(env)}::portal`;
return `${accountTenant(env)}::portal`;
}
function sessionTtl(env: Bindings): number {
@@ -102,6 +118,11 @@ export async function run(c: Context<{ Bindings: Bindings }>, fn: () => Promise<
if (e instanceof AuthStoreWriteError) {
return c.json({ error: `認證儲存寫入失敗:${e.message}`, code: 'auth_store_not_writable' }, 502);
}
// Arcrun#108:連「這台實例的知識放在哪一格」都解析不出來 → 誠實講「讀不到」,
// 不拿 repo 預設值當答案然後回一頁空的(那正是本票的病:設定缺失被畫成「你沒有資料」)。
if (e instanceof TenantUnresolvedError) {
return c.json({ error: e.message, code: 'tenant_unresolved' }, 500);
}
if (e instanceof KbdbError) return c.json({ error: `KBDB 不可達或回錯:${e.message}` }, 502);
throw e;
}
@@ -1409,7 +1430,6 @@ portalRouter.post('/portal/daemon/config', (c) =>
return c.json({ error: 'email 或密碼錯誤' }, 401);
}
await clearLoginFail(c.env, email);
const tenant = portalTenant(c.env);
// t176leo 08-03 架構翻案):**不再下發任何 LLM 設定**(extractor/金鑰/模型)。
// 地端用哪個模型、哪把金鑰,由使用者在同步小幫手的托盤「AI 設定…」自己設。
//
@@ -1422,9 +1442,12 @@ portalRouter.post('/portal/daemon/config', (c) =>
//
// ⚠️ 只拔 LLM 欄位——連線欄位(cypher_urlnamespacelibrary)與本 route 本身照舊,
// daemon 靠它上線;資料夾/庫管理(daemon/libraries)也完全不動(leo 明確劃界)。
// #108:這裡下發給小幫手的 namespace 決定了它把知識**寫**到哪一格。
// 以前給的是帳號層字串(CONSOLE_TENANT),與 CLIMCP 用的實例 namespace 是兩個來源
// ⇒ 寫進去的地方和讀出來的地方可以各自漂。改成同一個 knowledgeOwner,一台實例一個值。
const daemonCfg: Record<string, string> = {
cypher_url: new URL(c.req.url).origin,
namespace: tenant,
namespace: knowledgeOwner(c.env),
library: 'kb',
email,
instance_name: String(rec.values.display_name ?? ''),
@@ -1456,7 +1479,7 @@ portalRouter.post('/portal/admin/chat-key', (c) =>
const body = (await c.req.json().catch(() => null)) as { key?: string } | null;
const key = String(body?.key ?? '').trim();
if (!key) return c.json({ error: '請貼上你的 Google AI 金鑰' }, 400);
const tenant = portalTenant(c.env);
const tenant = knowledgeOwner(c.env);
const kvKey = `${tenant}:wf:rag_chat`;
const raw = await c.env.WEBHOOKS.get(kvKey, 'text');
if (!raw) return c.json({ error: '這個實例沒有安裝 AI 問答工作流' }, 404);
@@ -1518,8 +1541,8 @@ portalRouter.get('/portal/admin/libraries', (c) =>
// t142:資料面實際出現的庫+統計數字(卡數、三元組數)並行撈取,避免 N+1。
// 任一端點失敗不擋登記簿列表(誠實降級:stats 保持 0,不炸主流程)。
try {
const tenant = portalTenant(c.env);
const ownerParam = `owner_id=${encodeURIComponent(tenant)}`;
const tenant = knowledgeOwner(c.env);
const ownerParam = ownerQuery(tenant);
const [autoRes, cardRes, tripletRes] = await Promise.all([
kbdbFetch(c.env, `/entries/libraries?${ownerParam}`).catch(() => null),
kbdbFetch(c.env, `/entries/library-stats?${ownerParam}`).catch(() => null),
@@ -1712,8 +1735,8 @@ portalRouter.get('/portal/admin/execution-log-retention', (c) =>
run(c, async () => {
const auth = await requirePortalAdmin(c);
if (!auth.ok) return auth.res;
const ownerId = portalTenant(c.env);
const res = await kbdbFetch(c.env, `/execution-log/retention?owner_id=${encodeURIComponent(ownerId)}`);
const ownerId = knowledgeOwner(c.env);
const res = await kbdbFetch(c.env, `/execution-log/retention?${ownerQuery(ownerId)}`);
if (!res.ok) throw new KbdbError(`GET /execution-log/retention → ${res.status}`);
const data = (await res.json()) as { retention_days?: number | null; default_days?: number };
return c.json({ success: true, retention_days: data.retention_days ?? null, default_days: data.default_days ?? 90 });
@@ -1732,10 +1755,10 @@ portalRouter.put('/portal/admin/execution-log-retention', (c) =>
if (days !== null && days !== undefined && (typeof days !== 'number' || !Number.isFinite(days) || days <= 0)) {
return c.json({ error: 'retention_days 必須是正整數,或 null(代表不刪除)' }, 400);
}
const ownerId = portalTenant(c.env);
const ownerId = knowledgeOwner(c.env);
const res = await kbdbFetch(c.env, '/execution-log/retention', {
method: 'PUT',
body: JSON.stringify({ owner_id: ownerId, retention_days: days === undefined ? null : days }),
body: JSON.stringify({ owner_id: ownerField(ownerId), retention_days: days === undefined ? null : days }),
});
if (!res.ok) throw new KbdbError(`PUT /execution-log/retention → ${res.status}`);
const data = (await res.json()) as { retention_days?: number | null };
@@ -1756,10 +1779,10 @@ portalRouter.delete('/portal/admin/libraries/by-name/:name', (c) =>
const confirm = String(body?.confirm ?? '').trim();
if (!confirm) return c.json({ error: 'body 須帶 { confirm: "<庫名>" } 才執行(移除會影響資料可搜性)' }, 400);
if (confirm !== name) return c.json({ error: `confirm 值「${confirm}」與庫名「${name}」不符` }, 400);
const ownerId = portalTenant(c.env);
const ownerId = knowledgeOwner(c.env);
const res = await kbdbFetch(c.env, '/entries/deprecate-by-library', {
method: 'PATCH',
body: JSON.stringify({ owner_id: ownerId, library: name }),
body: JSON.stringify({ owner_id: ownerField(ownerId), library: name }),
});
if (!res.ok) throw new KbdbError(`PATCH /entries/deprecate-by-library → ${res.status}`);
const data = (await res.json()) as { deprecated_count?: number };
@@ -1850,16 +1873,19 @@ export interface DiagnosticsCore {
notes: string[];
}
/** tenantowner_idsession 版傳 portalTenant(env)daemon 版傳 X-Arcrun-API-Key 原值,見下方呼叫端)。 */
export async function buildDiagnostics(env: Bindings, tenant: string): Promise<DiagnosticsCore> {
/**
* tenantowner_idsession `knowledgeOwner(env)`daemon `tenantFromApiKey(header)`
* #108 `TenantId`
*/
export async function buildDiagnostics(env: Bindings, tenant: TenantId): Promise<DiagnosticsCore> {
const notes: string[] = [];
// ① embed 模組健康狀態(backfillStatus + selfTest,兩支都活在 KBDB 那面牆內)。
let embedding: Record<string, unknown> = { checked: false };
try {
const [statusRes, selftestRes] = await Promise.all([
kbdbFetch(env, `/embed/backfill/status?${new URLSearchParams({ owner_id: tenant }).toString()}`),
kbdbFetch(env, `/embed/selftest?${new URLSearchParams({ owner_id: tenant }).toString()}`),
kbdbFetch(env, `/embed/backfill/status?${ownerQuery(tenant)}`),
kbdbFetch(env, `/embed/selftest?${ownerQuery(tenant)}`),
]);
const statusBody = (await statusRes.json().catch(() => null)) as
| { success?: boolean; enabled?: boolean; pending?: number; embedded?: number }
@@ -1895,7 +1921,7 @@ export async function buildDiagnostics(env: Bindings, tenant: string): Promise<D
// - GET /records/triplet-statsper-library 即時聚合 SQLt142COUNT,非快取)。
let library_count = 0;
let triplet_count = 0;
const ownerParam = new URLSearchParams({ owner_id: tenant }).toString();
const ownerParam = ownerQuery(tenant);
try {
const [registeredLibs, autoRes, tripletRes] = await Promise.all([
listRecordsByTemplate(env, LIBRARY_TEMPLATE).catch(() => []),
@@ -1929,7 +1955,7 @@ export async function buildDiagnostics(env: Bindings, tenant: string): Promise<D
let library_scope_check: Record<string, unknown> = { ran: false };
if (library_count === 0 && triplet_count === 0) {
try {
const probeRes = await kbdbFetch(env, `/entries?${new URLSearchParams({ owner_id: tenant, limit: '1' }).toString()}`);
const probeRes = await kbdbFetch(env, `/entries?${new URLSearchParams({ owner_id: ownerField(tenant), limit: '1' }).toString()}`);
const probeBody = (await probeRes.json().catch(() => null)) as { total?: number } | null;
const total = probeBody?.total ?? 0;
library_scope_check = {
@@ -1976,7 +2002,8 @@ portalRouter.get('/portal/daemon/diagnostics', (c) =>
run(c, async () => {
const apiKey = (c.req.header('X-Arcrun-API-Key') ?? '').trim();
if (!apiKey) return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
const core = await buildDiagnostics(c.env, apiKey);
// 這條路的租戶來自**請求本身**(小幫手帶的 namespace),不是環境變數 → 沒有 #108 的漂移問題。
const core = await buildDiagnostics(c.env, tenantFromApiKey(apiKey));
return c.json({
generated_at: new Date().toISOString(),
instance_url: new URL(c.req.url).origin,
+14
View File
@@ -91,6 +91,20 @@ export type Bindings = {
// console 登入後端一律用這個字串打 /kbdb/*、/workflows/search(不做多租戶,登入系統只擋外人看頁面)。
// 未設 → routes/console-auth.ts 預設 "leo"(發現①已核實:D1 458,357 筆資料實際使用的租戶字串)。
CONSOLE_TENANT?: string;
/**
* ****Arcrun#108使 `~/.arcrun/config.yaml` `api_key`
*
* `acr init/update`cli/src/lib/deploy.ts CLI_MANAGED_VARS**使**
* CLI push workflow`{ns}:wf:*``owner_id=ns`MCP Bearer
*
*
* 沿 `CONSOLE_TENANT` repo toml ** prod **`leo`
* self-hosted #108 1854 0
* `CONSOLE_TENANT` namespace`{tenant}::portal`
* var
* `src/lib/tenant.ts`
*/
ARCRUN_NAMESPACE?: string;
// Console 顯示品牌/實例名(Arcrun#21 rebrand,非機密)。只影響 UI 字樣(title/header/logo),
// 不影響任何行為。未設 → "Arcrun"console 是引擎共用件,不寫死產品名)。
// 實例可覆蓋,例:arcrun-rag demo 可設 --var CONSOLE_BRAND:"Arcrun RAG"。
@@ -0,0 +1,261 @@
/**
* Arcrun#108
*
*
* 1. owner_id `knowledgeOwner(env)``ARCRUN_NAMESPACE` `CONSOLE_TENANT` 退
* TenantUnresolvedError** `|| 'leo'` **
* 2. `/portal/data/map` KBDBleo ARCRUN_NAMESPACE=bfezv28v
* `owner_id=bfezv28v` 9 `owner_id=leo` 0
* 3. **** KBDB ["kb"] kb
* 4. #100
* no_library_grant / filtered_out / scope_mismatch / confirmed_empty
* 5. ****design §3.3 /kbdb/*
*/
import { env, fetchMock } from 'cloudflare:test';
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
import { knowledgeOwner, accountTenant, TenantUnresolvedError, ownerQuery, censusQueryAllTenants } from '../src/lib/tenant';
import { portalDataRouter } from '../src/routes/portal-data';
import type { Bindings } from '../src/types';
const KBDB = 'https://kbdb.test';
/** leo 的真實命名空間(2026-08-11 回灌時定名,見 Leo/mira#8)。 */
const LEO_NS = 'bfezv28v';
beforeAll(() => {
fetchMock.activate();
fetchMock.disableNetConnect();
});
afterEach(() => fetchMock.assertNoPendingInterceptors());
/**
* router env SELF.fetch`cloudflare:test` `env`
* SELF worker ARCRUN_BUNDLE_VERSION /health
* Hono router env
* handler KBDB fetch env
*/
const ctx = { waitUntil: () => {}, passThroughOnException: () => {} } as unknown as ExecutionContext;
async function seedSession(token: string, recordId: string) {
await env.SESSIONS_KV.put(`portal_sess:${token}`, JSON.stringify({ record_id: recordId }));
}
function mockGetRecord(recordId: string, libraries: string) {
fetchMock
.get(KBDB)
.intercept({ path: `/records/${recordId}`, method: 'GET' })
.reply(200, {
success: true,
record: {
record_id: recordId,
template_id: 'tpl_pu',
values: {
email: 'leo@example.com',
display_name: 'leo',
status: 'active',
role: 'admin',
password_hash: 'pbkdf2-sha256$600000$AA$BB',
libraries,
created_at: '2026-08-12T00:00:00.000Z',
updated_at: '2026-08-12T00:00:00.000Z',
},
},
});
}
/** 攔 `/map`,同時把「實際被查詢的 owner_id」記下來給斷言用。 */
function mockMap(libraries: { library: string; triplet_count: number }[], seen: string[]) {
fetchMock
.get(KBDB)
.intercept({
path: (p: string) => {
if (!p.startsWith('/map')) return false;
seen.push(new URL(p, KBDB).searchParams.get('owner_id') ?? '');
return true;
},
method: 'GET',
})
.reply(200, { success: true, libraries, count: libraries.length });
}
function mockTripletStats(match: (ownerId: string) => boolean, tripletCount: number) {
fetchMock
.get(KBDB)
.intercept({
path: (p: string) =>
p.startsWith('/records/triplet-stats') && match(new URL(p, KBDB).searchParams.get('owner_id') ?? ''),
method: 'GET',
})
.reply(200, { success: true, stats: [{ library: 'kb', triplet_count: tripletCount }] });
}
async function getMap(token: string, overrides: Partial<Bindings> = {}) {
const res = await portalDataRouter.fetch(
new Request('http://localhost/portal/data/map', { headers: { authorization: `Bearer ${token}` } }),
{ ...env, ...overrides } as Bindings,
ctx,
);
return { status: res.status, body: (await res.json()) as Record<string, unknown> };
}
/** undici 的 path matcher 可能被呼叫多次 → 比對前先去重(我們在意的是「查了哪些 owner_id」)。 */
const distinct = (xs: string[]): string[] => [...new Set(xs)];
// ── ① 唯一產地的解析順序 ───────────────────────────────────────────────────────
describe('knowledgeOwner:租戶字串只有一個產地,且沒有靜默預設值', () => {
it('ARCRUN_NAMESPACE 優先(=acr update 從 ~/.arcrun/config.yaml 的 api_key 注入的那個值)', () => {
expect(knowledgeOwner({ ARCRUN_NAMESPACE: LEO_NS, CONSOLE_TENANT: 'leo' } as Bindings)).toBe(LEO_NS);
});
it('沒注入 → 回退 CONSOLE_TENANT(官方 prod 與尚未 acr update 的實例,行為一字不變)', () => {
expect(knowledgeOwner({ CONSOLE_TENANT: 'leo' } as Bindings)).toBe('leo');
});
it('空字串不算數(部署把 var 設成空字串 ≠ 有設定)', () => {
expect(knowledgeOwner({ ARCRUN_NAMESPACE: ' ', CONSOLE_TENANT: 'leo' } as Bindings)).toBe('leo');
});
it('兩個都沒有 → 丟 TenantUnresolvedError**不回 "leo"**(靜默預設值正是本票的病)', () => {
expect(() => knowledgeOwner({} as Bindings)).toThrow(TenantUnresolvedError);
});
it('帳號層 accountTenant 不受影響(改它會讓舊實例登不進去,所以刻意不動)', () => {
expect(accountTenant({ ARCRUN_NAMESPACE: LEO_NS, CONSOLE_TENANT: 'leo' } as Bindings)).toBe('leo');
expect(accountTenant({} as Bindings)).toBe('leo');
});
it('過濾片段只有兩種形狀:帶租戶的 ownerQuery,與明著喊全庫的普查', () => {
expect(ownerQuery(knowledgeOwner({ ARCRUN_NAMESPACE: 'a b' } as Bindings))).toBe('owner_id=a%20b');
expect(censusQueryAllTenants()).toBe('owner_id=');
});
});
// ── ② 地圖真的用那個 owner_id 去查 ─────────────────────────────────────────────
describe('GET /portal/data/map — leo 的情境(1854 條 → 看得到,不是 0 個庫)', () => {
it('注入 ARCRUN_NAMESPACE 後,KBDB 收到的 owner_id 是它,而且庫都回得來', async () => {
await seedSession('t-map-1', 'rec_leo');
mockGetRecord('rec_leo', '["*"]');
const seen: string[] = [];
mockMap(
[
{ library: 'kb', triplet_count: 1851 },
{ library: 'general', triplet_count: 3 },
],
seen,
);
const { status, body } = await getMap('t-map-1', { ARCRUN_NAMESPACE: LEO_NS });
expect(status).toBe(200);
expect(distinct(seen)).toEqual([LEO_NS]); // ← 這一行就是本票:以前送出去的是 'leo'
expect(body.count).toBe(2);
expect((body.libraries as { library: string; triplet_count: number }[]).map((l) => l.triplet_count))
.toEqual([1851, 3]);
expect(body.empty_reason).toBeNull();
});
it('回應不含租戶字串(前端拿到就能繞過庫過濾直打 /kbdb/*——design §3.3 紅線)', async () => {
await seedSession('t-map-2', 'rec_leo2');
mockGetRecord('rec_leo2', '["*"]');
mockMap([{ library: 'kb', triplet_count: 1851 }], []);
const { body } = await getMap('t-map-2', { ARCRUN_NAMESPACE: LEO_NS });
expect(JSON.stringify(body)).not.toContain(LEO_NS);
expect(JSON.stringify(body)).not.toContain('ARCRUN_NAMESPACE');
});
it('沒注入時沿用 CONSOLE_TENANT(未跑 acr update 的實例行為不變,這次改動對它是惰性的)', async () => {
await seedSession('t-map-3', 'rec_leo3');
mockGetRecord('rec_leo3', '["*"]');
const seen: string[] = [];
mockMap([{ library: 'kb', triplet_count: 1 }], seen);
await getMap('t-map-3');
expect(distinct(seen)).toEqual(['leo']); // wrangler.test.toml CONSOLE_TENANT
});
});
// ── ③ 權限沒有被拿掉(紅線:修這題不准把 owner_id 過濾或庫過濾拆掉)──────────────
describe('權限:只被授權部分庫的帳號,只看得到那幾個庫', () => {
it('libraries=["kb"] → 同一份 KBDB 回應裡只剩 kb', async () => {
await seedSession('t-perm-1', 'rec_partial');
mockGetRecord('rec_partial', '["kb"]');
mockMap(
[
{ library: 'kb', triplet_count: 1851 },
{ library: 'finance', triplet_count: 42 },
{ library: 'general', triplet_count: 3 },
],
[],
);
const { body } = await getMap('t-perm-1', { ARCRUN_NAMESPACE: LEO_NS });
expect((body.libraries as { library: string }[]).map((l) => l.library)).toEqual(['kb']);
expect(body.count).toBe(1);
});
it('一個庫都沒被授權 → 不打 KBDB,誠實說是權限問題', async () => {
await seedSession('t-perm-2', 'rec_nolib');
mockGetRecord('rec_nolib', '[]');
const { body } = await getMap('t-perm-2'); // 沒有 mockMap:打了就會 assertNoPendingInterceptors 失敗
expect(body.count).toBe(0);
expect(body.empty_reason).toBe('no_library_grant');
expect(body.empty_confirmed).toBe(true);
});
it('實例有庫但都不在權限內 → filtered_out(是隔離正常,不是資料不見)', async () => {
await seedSession('t-perm-3', 'rec_other');
mockGetRecord('rec_other', '["finance"]');
mockMap([{ library: 'kb', triplet_count: 1851 }], []);
const { body } = await getMap('t-perm-3', { ARCRUN_NAMESPACE: LEO_NS });
expect(body.empty_reason).toBe('filtered_out');
expect(body.empty_confirmed).toBe(true);
});
});
// ── ④ 空地圖的四種成因分得出來(不再把設定錯誤畫成「你沒有資料」)────────────────
describe('空地圖:分得出「讀不到」與「沒有」', () => {
it('命名空間對不上(本租戶 0、整台實例有)→ scope_mismatch,並指出該跑 acr update', async () => {
await seedSession('t-empty-1', 'rec_e1');
mockGetRecord('rec_e1', '["*"]');
mockMap([], []);
mockTripletStats((o) => o === 'wrong-ns', 0); // 本租戶 0
mockTripletStats((o) => o === '', 1854); // 全庫普查:有 1854 條
const { body } = await getMap('t-empty-1', { ARCRUN_NAMESPACE: 'wrong-ns' });
expect(body.empty_reason).toBe('scope_mismatch');
expect(body.empty_confirmed).toBe(false); // 🔴 絕不宣稱「你沒有資料」
expect(body.instance_triplet_count).toBe(1854);
expect(String(body.note)).toContain('acr update');
expect(JSON.stringify(body)).not.toContain('wrong-ns'); // 仍不下發租戶字串
});
it('整台實例真的空 → confirmed_empty(此時、也只有此時,才准說「還沒有內容」)', async () => {
await seedSession('t-empty-2', 'rec_e2');
mockGetRecord('rec_e2', '["*"]');
mockMap([], []);
mockTripletStats((o) => o === 'leo', 0);
mockTripletStats((o) => o === '', 0);
const { body } = await getMap('t-empty-2');
expect(body.empty_reason).toBe('confirmed_empty');
expect(body.empty_confirmed).toBe(true);
});
it('連統計都讀不到 → unreadable(不假裝是空庫)', async () => {
await seedSession('t-empty-3', 'rec_e3');
mockGetRecord('rec_e3', '["*"]');
mockMap([], []);
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
.reply(500, { error: 'boom' });
const { body } = await getMap('t-empty-3');
expect(body.empty_reason).toBe('unreadable');
expect(body.empty_confirmed).toBe(false);
});
});
+118
View File
@@ -0,0 +1,118 @@
/**
* ****Arcrun#108
*
*
* CLI exit 1hook 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*['"][^'"]+['"]/);
});
});
+13
View File
@@ -138,6 +138,19 @@ KBDB_BASE_URL = "https://arcrun-kbdb.uncle6-me.workers.dev"
# (登入系統只擋外人看頁面,不做多租戶)。Self-hosted fork:改成你自己資料實際所在的租戶字串。
CONSOLE_TENANT = "leo"
# 這台實例的**知識命名空間**(Arcrun#108)=知識資料(三元組/卡片/藏書地圖/工作流 KV)
# 實際掛在哪個 owner_id 底下。**這裡刻意不寫死**:官方 prod 的知識確實在 `CONSOLE_TENANT`
# (leo)底下,未設就沿用它,行為一字不變。
#
# self-hosted 實例由 `acr update` 自動注入(值=你 `~/.arcrun/config.yaml` 的 `api_key`
# 也就是 CLI push 工作流、小幫手上傳知識、MCP 查詢用的同一個 namespace),
# 而且**只在確認那個 namespace 底下真的查得到知識時才寫**(見 cli/src/lib/deploy.ts
# namespaceHasKnowledge)——避免把一台原本正常的實例指向空的那一格。
#
# 為什麼要跟 CONSOLE_TENANT 分開:CONSOLE_TENANT 同時是帳號子 namespace`{tenant}::portal`
# 的組成,改它會讓舊實例登不進去。兩個不同的事實,兩個 var。
# ARCRUN_NAMESPACE = "your-namespace"
# Portal session TTL 秒數(portal-auth P2#24/#25routes/portal.ts)。預設 7 天(604800)——
# issue 要求比 console 30 天短效。停用帳號的即時性不靠這個 TTL(每請求回讀 user record)。
PORTAL_SESSION_TTL = "604800"