fix(mcp): MCP 用登入者的身分查詢,不再去找一把服務內部金鑰

leo 2026-08-12:「人類進 Portal 輸入帳密表示你是主人,可以查到你權限所有東西;
AI 透過輸入帳密的 MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」
「掛上 MCP 並輸入帳密,那個動作本身就是授權」⇒ 下游不得再要求第二次認證。

病根(不是金鑰沒同步,是身分沒接住):
  oauth/routes.ts 驗完 Portal 帳密只留下 `loginOk = res.ok` 一個布林值,身分當場丟棄,
  namespace 改從 `MCP_OWNER_NAMESPACE || "leo"` 拿。於是查詢時手上沒有身分可帶,
  只好用 KBDB_INTERNAL_TOKEN 直打 KBDB——那條路繞過 portal 所有庫過濾,
  而且不管誰登入都看到同一格、看到全部。CLI 也從不注入 MCP_OWNER_NAMESPACE,
  所以那個 "leo" 預設值是每台實例的實際行為,不是理論上的邊角。

修法(走既有那條路,不發明新的):
1. 接住身分:/authorize 解析 /portal/login 回應,把 portal session token +
   display_name/role/libraries 存進 authorization code → access token。
   /portal/login 補回 session_expires_in,access_token TTL 夾成
   min(自己的 TTL, portal session TTL)——不讓「MCP 還連著、底下 session 早死」。
   cypher 回 200 但沒給 session_token(舊版)→ 不發碼,不簽一張沒有身分的 token。
2. 攜帶身分:kbdb_* 全部改走 cypher `/portal/data/*`,Authorization 帶登入者的
   session。庫過濾/租戶注入/停用即時生效全在 server 側,與人類走 portal 網頁同一道閘。
   kbdb_graph_neighbors 因此不再需要 kbdb_base(server 自己知道查哪個庫)。
   藏書地圖(含連線時注入 instructions 的那份)同樣只回有權限的庫,快取改 per-session
   分格——地圖本身就是情報,不能讓先連上的人把視野留給下一個。
3. fail-closed:舊 token 沒有身分 → 誠實要求重新連線,不偷偷退回服務金鑰那條老路。
   服務級憑據(static token / partner key)維持既有 KBDB 直連,arcrun_* 零回歸。

新增 cypher portal 資料面端點(能力長在 API,MCP 只暴露;rule 07):
  GET  /portal/data/map、/portal/data/map/:library
  GET  /portal/data/templates、POST /portal/data/templates
  GET  /portal/data/records/by-template/:t、GET /portal/data/records/:id
  POST /portal/data/records
全部:呼叫端自帶 owner_id 一律不生效;越權與不存在同回 404;寫入 owner_id 由 server 定死。

KBDB base:`GET /records/:id` 與 by-template 補回 owner_id 欄位——原本不回,
呼叫端無從判斷「這筆是不是我的」,按 id 直讀等於沒有租戶邊界。

沒動:KBDB fail-closed 閘、任何金鑰、租戶字串仍不下發給呼叫端。

驗證:
  mcp        tsc 綠;vitest 113/113 綠(改前 48 綠 29 紅)
  cypher     vitest 400 綠 / 14 紅,14 紅與 base commit a24f291 逐條相同(既有)
  kbdb       vitest 208 綠 / 5 紅,5 紅同為既有(migrations/*.sql 被 gitignore)
  端到端     ◐ 未驗:需部署到 leo21c,那道閘要 leo 親手解(見 PR)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-08-12 19:33:12 +08:00
parent a24f2912eb
commit 10d150ac2b
21 changed files with 1695 additions and 161 deletions
+209
View File
@@ -653,6 +653,215 @@ portalDataRouter.get('/portal/data/workflows', (c) =>
}),
);
// ═══════════════════════════════════════════════════════════════════════════
// 授權的 AIarcrun-mcp)走的資料面 — 與人類 portal 同一道閘、同一份權限
// ═══════════════════════════════════════════════════════════════════════════
//
// leo 2026-08-12:「人類進 Portal 輸入帳密表示你是主人,可以查到你權限所有東西;
// AI 透過輸入帳密的 MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」
// 「掛上 MCP 並輸入帳密,那個動作本身就是授權」⇒ **下游不得再要求第二次認證**。
//
// 之前的病:MCP 驗完帳密只留下一個布林值,身分當場丟掉(oauth/routes.ts 舊 `loginOk = res.ok`),
// 於是查詢時只好去找一把**服務內部金鑰**KBDB_INTERNAL_TOKEN)直打 KBDB——
// 那條路繞過了本檔上半部所有的庫過濾,等於「誰登入都看到同一格、而且是全部」。
//
// 修法=MCP 改帶**登入者的 portal session token** 打本段端點。所以本段的每一支:
// ① 一律 requirePortalUsersession → 回讀 user record → 停用即時生效),
// ② owner_id / library 由 server 注入,**呼叫端傳什麼都不看**(與上半部同一條紅線:
// 呼叫端自己帶租戶字串=繞過庫過濾),
// ③ 越權與不存在同回 404(不洩存在性)。
//
// 薄殼(rule 07):這裡沒有新能力——template/record/map 的真身都在 KBDB 基本盤,
// 本段只做「權限注入+轉發」,與上半部 search/entries 一模一樣的做法。
/**
* record 的庫歸屬。與 entry 不同:**沒有 `library` slot 的 record 不套庫過濾**。
*
* 為什麼不比照 entry 用 'general' fallbackentry 是知識內容(庫是它的第一屬性,沒標就歸
* general 是對的);record 是結構化資料列(contact / workflow_metadata / triplet…),
* 「庫」只對 triplet 這種有標 library slot 的才有意義。若照抄 general fallback
* 一個庫權限是 ["kb"] 的帳號會連自己建的 contact 都讀不回——那是誤殺,不是隔離。
* 租戶邊界仍然守著(owner_id 由 server 注入/逐筆比對),這裡只多守「有標庫的別越庫」。
*/
function recordLibrary(values: Record<string, unknown> | undefined): string | null {
const lib = values?.library;
return typeof lib === 'string' && lib.trim() ? lib.trim() : null;
}
/** record 可讀?租戶要對;有標 library 的還要在用戶庫集合內。 */
function canReadRecord(
rec: { values?: Record<string, unknown>; owner_id?: string | null },
tenant: string,
libraries: string[],
): boolean {
if ((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 同一個帳號看得更多。
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: '此帳號尚未被授權任何知識庫,請聯絡管理員。' });
}
const res = await kbdbFetch(c.env, `/map?owner_id=${encodeURIComponent(portalTenant(c.env))}`);
if (!res.ok) {
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
}
const body = (await res.json().catch(() => null)) as { libraries?: { library?: string }[] } | null;
if (!body || !Array.isArray(body.libraries)) {
return c.json({ error: '藏書地圖讀取失敗:KBDB 回應不是預期的 libraries 清單' }, 502);
}
const allowed = body.libraries.filter(
(l) => typeof l?.library === 'string' && canReadLibrary(libraries, l.library),
);
return c.json({ success: true, libraries: allowed, count: allowed.length });
}),
);
// GET /portal/data/map/:library — 單庫詳圖。無權該庫 → 與不存在同回 404(不洩存在性)。
portalDataRouter.get('/portal/data/map/:library', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const libraries = parseLibraries(auth.user.values.libraries);
const library = c.req.param('library');
if (!canReadLibrary(libraries, library)) return notFound(c);
const res = await kbdbFetch(
c.env,
`/map/${encodeURIComponent(library)}?owner_id=${encodeURIComponent(portalTenant(c.env))}`,
);
if (res.status === 404) return notFound(c);
if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status}` }, 502);
return new Response(res.body, { status: 200, headers: { 'Content-Type': 'application/json' } });
}),
);
// GET /portal/data/templates — template 清單。
// template=虛擬表定義(schema),**全域共享不分租戶**kbdb-proxy 同一裁定,leo 2026-06-14):
// 它描述「資料長什麼形狀」,不含任何人的內容。內容的隔離在 records/entries 那層。
portalDataRouter.get('/portal/data/templates', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const res = await kbdbFetch(c.env, '/templates');
if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status}` }, 502);
return new Response(res.body, { status: 200, headers: { 'Content-Type': 'application/json' } });
}),
);
// POST /portal/data/templates — 建 templatename + slots)。
// 鐵律:這是「虛擬表定義」,不是建真的資料表;KBDB 不提供建表/SQL。
// created_by 記租戶(溯源),template 本身全域可見可用。
portalDataRouter.post('/portal/data/templates', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const body = (await c.req.json().catch(() => null)) as
| { name?: unknown; slots?: unknown; description?: unknown }
| null;
if (!body || typeof body.name !== 'string' || !body.name.trim() || !Array.isArray(body.slots)) {
return c.json({ error: 'name 與 slots[] 必填' }, 400);
}
const res = await kbdbFetch(c.env, '/templates', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: body.name,
slots: body.slots,
description: typeof body.description === 'string' ? body.description : undefined,
created_by: portalTenant(c.env),
}),
});
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
}),
);
// GET /portal/data/records/by-template/:template — 某 template 底下的 record。
// server 注入 owner_id(呼叫端傳的一律忽略);有標 library 的再逐筆過濾。
portalDataRouter.get('/portal/data/records/by-template/:template', (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, records: [], count: 0 });
const tenant = portalTenant(c.env);
const res = await kbdbFetch(
c.env,
`/records/by-template/${encodeURIComponent(c.req.param('template'))}?owner_id=${encodeURIComponent(tenant)}`,
);
if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status}` }, 502);
const body = (await res.json().catch(() => null)) as
| { records?: { values?: Record<string, unknown>; owner_id?: string | null }[] }
| null;
if (!body || !Array.isArray(body.records)) {
return c.json({ error: 'record 讀取失敗:KBDB 回應不是預期的 records 清單' }, 502);
}
// KBDB 已按 owner_id 過濾;這裡再守一次庫(縱深防禦,且舊部署若回多了不會外洩)。
const records = body.records.filter((r) => canReadRecord(r, tenant, libraries));
return c.json({ success: true, records, count: records.length });
}),
);
// GET /portal/data/records/:recordId — 單筆 record。
// 逐筆驗歸屬(owner_id 必須是本實例租戶)+ 驗庫;兩者不符與不存在同回 404。
portalDataRouter.get('/portal/data/records/:recordId', (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 notFound(c);
const res = await kbdbFetch(c.env, `/records/${encodeURIComponent(c.req.param('recordId'))}`);
if (res.status === 404) return notFound(c);
if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status}` }, 502);
const body = (await res.json().catch(() => null)) as
| { record?: { values?: Record<string, unknown>; owner_id?: string | null } }
| null;
const record = body?.record;
if (!record) return notFound(c);
if (!canReadRecord(record, portalTenant(c.env), libraries)) return notFound(c);
return c.json({ success: true, record });
}),
);
// POST /portal/data/records — 依 template 填一筆 record。
// owner_id **一律由 server 定死成本實例租戶**(呼叫端傳的忽略)——寫入端若讓呼叫端挑歸屬,
// 等於開一扇「把資料寫進別人格子」的門。要寫進某個庫(values.library)必須有該庫權限。
portalDataRouter.post('/portal/data/records', (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({ error: '此帳號尚未被授權任何知識庫,無法寫入' }, 403);
}
const body = (await c.req.json().catch(() => null)) as
| { template?: unknown; values?: unknown }
| null;
if (!body || typeof body.template !== 'string' || !body.template.trim() || !body.values || typeof body.values !== 'object') {
return c.json({ error: 'template 與 values 必填' }, 400);
}
const values = body.values as Record<string, unknown>;
const targetLib = recordLibrary(values);
if (targetLib !== null && !canReadLibrary(libraries, targetLib)) {
// 寫入越庫是**明確拒絕**(403),不套讀取那條 404 不洩存在性的規則:
// 庫名是呼叫端自己指定的,這裡沒有「洩漏某庫存在」的問題,講清楚才可修正。
return c.json({ error: `無「${targetLib}」庫的權限,不能寫入該庫` }, 403);
}
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) }),
});
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
}),
);
// GET /portal/data/diagnostics — 檢修孔(2026-08-07 leo 直接指令):
//
// 「可以很簡單,就是一顆按鈕在設定裡,他按鈕下載一個檔案,把檔案發給我,你看那個檔。」
+5
View File
@@ -677,6 +677,11 @@ portalRouter.post('/portal/login', (c) =>
display_name: rec.values.display_name ?? '',
role: rec.values.role ?? 'user',
libraries: parseLibraries(rec.values.libraries),
// session 還能活多久(秒)。**非機密**(是這台實例的 TTL 設定,不是任何人的憑據),
// 但呼叫端需要它才能把自己發的憑證對齊這個上限——arcrun-mcp 用它把 OAuth
// access_token 的 TTL 夾到 min(自己的 TTL, 這個值):否則 MCP token 活 30 天、
// 底下的 portal session 7 天就死,使用者會在第 8 天遇到「連著卻查不到」的鬼打牆。
session_expires_in: sessionTtl(c.env),
// 絕不回租戶字串(design §3.3portal_user 拿到租戶字串就能繞過庫 filter 直打 /kbdb/*
});
}),
+208
View File
@@ -232,6 +232,214 @@ describe('GET /portal/data/entries/:id(逐筆驗庫)', () => {
});
});
// ═══════════════ 3b. 授權的 AI(arcrun-mcp)走的資料面 ═══════════════
//
// leo 2026-08-12:「AI 透過輸入帳密的 MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」
// ⇒ 這幾支端點與人類走的 search/entries 是同一道閘:同一個 session、同一份庫權限、
// 同樣「呼叫端自帶 owner_id 一律不生效」、同樣「越權與不存在同一句 404」。
describe('藏書地圖 /portal/data/mapMCP 走的那條)', () => {
it('只回這個帳號有權限的庫;全館其他庫不出現在回應裡', async () => {
await seedSession('tok-m1', 'rec_1');
mockGetRecord('rec_1', userValues({ libraries: '["finance"]' }));
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/map?'), method: 'GET' })
.reply(200, {
success: true,
libraries: [
{ library: 'finance', narrative: '財務', top_entities: [], triplet_count: 3 },
{ library: 'hr', narrative: '人資', top_entities: [], triplet_count: 9 },
],
count: 2,
});
const res = await get('/portal/data/map', { Authorization: 'Bearer tok-m1' });
expect(res.status).toBe(200);
const data = (await res.json()) as { libraries: { library: string }[]; count: number };
expect(data.libraries.map((l) => l.library)).toEqual(['finance']);
expect(data.count).toBe(1);
});
it('["*"] 全庫 → 全部庫都回', async () => {
await seedSession('tok-m2', 'rec_2');
mockGetRecord('rec_2', userValues({ libraries: '["*"]' }));
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/map?'), method: 'GET' })
.reply(200, {
success: true,
libraries: [
{ library: 'finance', narrative: '', top_entities: [], triplet_count: 3 },
{ library: 'hr', narrative: '', top_entities: [], triplet_count: 9 },
],
count: 2,
});
const res = await get('/portal/data/map', { Authorization: 'Bearer tok-m2' });
const data = (await res.json()) as { libraries: { library: string }[] };
expect(data.libraries.map((l) => l.library)).toEqual(['finance', 'hr']);
});
it('庫集合為空 → 誠實空結果+說明,不打 KBDB', async () => {
await seedSession('tok-m3', 'rec_3');
mockGetRecord('rec_3', userValues({ libraries: '[]' }));
const res = await get('/portal/data/map', { Authorization: 'Bearer tok-m3' });
expect(res.status).toBe(200);
const data = (await res.json()) as { count: number; note?: string };
expect(data.count).toBe(0);
expect(data.note).toContain('尚未被授權');
});
it('單庫詳圖:無權該庫 → 404 同一句(不打 KBDB,不洩該庫存不存在)', async () => {
await seedSession('tok-m4', 'rec_4');
mockGetRecord('rec_4', userValues({ libraries: '["finance"]' }));
const res = await get('/portal/data/map/hr', { Authorization: 'Bearer tok-m4' });
expect(res.status).toBe(404);
expect(((await res.json()) as { error: string }).error).toBe('找不到這筆資料');
});
it('單庫詳圖:有權該庫 → 200 轉發', async () => {
await seedSession('tok-m5', 'rec_5');
mockGetRecord('rec_5', userValues({ libraries: '["finance"]' }));
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/map/finance'), method: 'GET' })
.reply(200, { success: true, map: { library: 'finance', triplet_count: 3 } });
const res = await get('/portal/data/map/finance', { Authorization: 'Bearer tok-m5' });
expect(res.status).toBe(200);
});
it('未登入 → 401', async () => {
expect((await get('/portal/data/map')).status).toBe(401);
});
});
describe('結構化資料 /portal/data/records、/portal/data/templatesMCP 走的那條)', () => {
it('by-templateserver 注入 owner_idcaller 自帶的被靜默覆蓋(繞不過)', async () => {
await seedSession('tok-r1', 'rec_1');
mockGetRecord('rec_1', userValues({ libraries: '["*"]' }));
let captured = '';
fetchMock
.get(KBDB)
.intercept({
path: (p: string) => {
if (!p.startsWith('/records/by-template/contact')) return false;
captured = p;
return true;
},
method: 'GET',
})
.reply(200, { success: true, records: [], count: 0 });
const res = await get('/portal/data/records/by-template/contact?owner_id=someone-else', {
Authorization: 'Bearer tok-r1',
});
expect(res.status).toBe(200);
expect(new URL(`http://x${captured}`).searchParams.get('owner_id')).toBe(TENANT);
});
it('by-template:有標 library 的 record 越庫的被濾掉;沒標 library 的照回', async () => {
await seedSession('tok-r2', 'rec_2');
mockGetRecord('rec_2', userValues({ libraries: '["finance"]' }));
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/by-template/triplet'), method: 'GET' })
.reply(200, {
success: true,
records: [
{ record_id: 'r1', owner_id: TENANT, values: { library: 'finance', subject: 'A' } },
{ record_id: 'r2', owner_id: TENANT, values: { library: 'hr', subject: 'B' } },
{ record_id: 'r3', owner_id: TENANT, values: { subject: 'C' } }, // 沒標庫=結構化資料列
],
count: 3,
});
const res = await get('/portal/data/records/by-template/triplet', { Authorization: 'Bearer tok-r2' });
const data = (await res.json()) as { records: { record_id: string }[] };
expect(data.records.map((r) => r.record_id)).toEqual(['r1', 'r3']);
});
it('單筆:別的租戶的 record → 404 同一句(就算全庫權限也擋)', async () => {
await seedSession('tok-r3', 'rec_3');
mockGetRecord('rec_3', userValues({ libraries: '["*"]' }));
fetchMock
.get(KBDB)
.intercept({ path: '/records/r_other', method: 'GET' })
.reply(200, { success: true, record: { record_id: 'r_other', owner_id: 'other-tenant', values: {} } });
const res = await get('/portal/data/records/r_other', { Authorization: 'Bearer tok-r3' });
expect(res.status).toBe(404);
expect(((await res.json()) as { error: string }).error).toBe('找不到這筆資料');
});
it('單筆:越庫的 record → 404 同一句;有權的 → 200', async () => {
await seedSession('tok-r4', 'rec_4');
mockGetRecord('rec_4', userValues({ libraries: '["finance"]' }));
fetchMock
.get(KBDB)
.intercept({ path: '/records/r_hr', method: 'GET' })
.reply(200, { success: true, record: { record_id: 'r_hr', owner_id: TENANT, values: { library: 'hr' } } });
expect((await get('/portal/data/records/r_hr', { Authorization: 'Bearer tok-r4' })).status).toBe(404);
await seedSession('tok-r5', 'rec_5');
mockGetRecord('rec_5', userValues({ libraries: '["finance"]' }));
fetchMock
.get(KBDB)
.intercept({ path: '/records/r_fin', method: 'GET' })
.reply(200, { success: true, record: { record_id: 'r_fin', owner_id: TENANT, values: { library: 'finance' } } });
expect((await get('/portal/data/records/r_fin', { Authorization: 'Bearer tok-r5' })).status).toBe(200);
});
it('寫入:owner_id 由 server 定死,呼叫端塞的不算', async () => {
await seedSession('tok-r6', 'rec_6');
mockGetRecord('rec_6', userValues({ libraries: '["*"]' }));
let body: Record<string, unknown> = {};
fetchMock
.get(KBDB)
.intercept({
path: '/records',
method: 'POST',
body: (b: string) => {
body = JSON.parse(b) as Record<string, unknown>;
return true;
},
})
.reply(200, { success: true, record: { record_id: 'r_new' } });
const res = await SELF.fetch('http://localhost/portal/data/records', {
method: 'POST',
headers: { Authorization: 'Bearer tok-r6', 'Content-Type': 'application/json' },
body: JSON.stringify({ template: 'contact', values: { name: 'Leo' }, owner_id: 'someone-else' }),
});
expect(res.status).toBe(200);
expect(body.owner_id).toBe(TENANT);
});
it('寫入越庫 → 403(明確拒絕,庫名是呼叫端自己指定的,沒有存在性可洩)', async () => {
await seedSession('tok-r7', 'rec_7');
mockGetRecord('rec_7', userValues({ libraries: '["finance"]' }));
const res = await SELF.fetch('http://localhost/portal/data/records', {
method: 'POST',
headers: { Authorization: 'Bearer tok-r7', 'Content-Type': 'application/json' },
body: JSON.stringify({ template: 'note', values: { library: 'hr', body: 'x' } }),
});
expect(res.status).toBe(403);
});
it('templates 全域共享(schema 非內容):登入即可列', async () => {
await seedSession('tok-t1', 'rec_t1');
mockGetRecord('rec_t1', userValues({ libraries: '["finance"]' }));
fetchMock
.get(KBDB)
.intercept({ path: '/templates', method: 'GET' })
.reply(200, { success: true, templates: [{ id: 'tpl1', name: 'contact' }], count: 1 });
const res = await get('/portal/data/templates', { Authorization: 'Bearer tok-t1' });
expect(res.status).toBe(200);
expect(((await res.json()) as { count: number }).count).toBe(1);
});
it('未登入 → 401records / templates 都是)', async () => {
expect((await get('/portal/data/templates')).status).toBe(401);
expect((await get('/portal/data/records/by-template/contact')).status).toBe(401);
expect((await get('/portal/data/records/r1')).status).toBe(401);
});
});
// ═══════════════ 4. graph D-4 粗閘 ═══════════════
describe('GET /portal/data/graph/neighbors/:nameD-4 粗閘)', () => {