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,261 @@
|
||||
/**
|
||||
* Arcrun#108 — 藏書地圖看得到自己的知識(租戶字串來源收斂)。
|
||||
*
|
||||
* 釘住的事實:
|
||||
* 1. 資料面 owner_id 來自 `knowledgeOwner(env)`:`ARCRUN_NAMESPACE` 優先、`CONSOLE_TENANT` 回退、
|
||||
* 兩者皆無 → 丟 TenantUnresolvedError(**沒有 `|| 'leo'` 這種靜默預設值**)。
|
||||
* 2. `/portal/data/map` 真的拿那個值去打 KBDB(leo 的情境: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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user