10d150ac2b
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>
158 lines
7.3 KiB
TypeScript
158 lines
7.3 KiB
TypeScript
/**
|
||
* 藏書地圖(library-map)共用邏輯 — SDD M4(system-dev/docs/3-specs/library-map/design.md §4)
|
||
*
|
||
* 兩個 consumer 共用本檔:
|
||
* 1. kbdb_get_map MCP tool(tools/kbdb_map.ts)— 薄殼呼 GET /map//map/:library
|
||
* 2. MCP server instructions 注入(mcp-handler.ts)— 連線時把全館地圖渲染成緊湊文字嵌入
|
||
*
|
||
* 走既有 KBDB service binding(kbdbFetch),不新增 binding、不碰 D1(薄殼鐵律)。
|
||
*
|
||
* 鐵律(design §4):注入失敗絕不能讓 MCP 連線失敗——buildLibraryMapInstructions 任何錯誤
|
||
* (超時/HTTP 錯/空庫/JSON 壞)一律回 null,caller 靜默略過,instructions 沒地圖照常可用。
|
||
*
|
||
* 快取抉擇(design §4 給了「快取+TTL 或每次現拉」兩個選項,這裡選 isolate 內 TTL 快取):
|
||
* 本 MCP 用 stateless StreamableHTTP(sessionIdGenerator: undefined)——每個 HTTP request 都
|
||
* 重建 McpServer,也就是說「每次現拉」實際上是每個 tool call 都多打一次 /map,不是每條連線一次。
|
||
* 地圖只在 ingest recompute 後才變,所以用 module 層(isolate 內)TTL 快取:成功 5 分鐘、
|
||
* 失敗 1 分鐘(避免 kbdb 掛掉時每個 request 都白等 timeout)。isolate 回收即自然失效,無需失效協議。
|
||
*/
|
||
|
||
import type { Env } from "../types.js";
|
||
import { kbdbFetch } from "./kbdb-client.js";
|
||
import { portalFetch, type KnowledgeIdentity } from "./portal-client.js";
|
||
|
||
/** 全館視圖一行(kbdb GET /map 的 libraries[] 元素;top_entities 已是 top-3 名字)。 */
|
||
export interface LibraryMapRow {
|
||
library: string;
|
||
narrative: string | null;
|
||
top_entities: unknown; // 正常是 string[];防禦:舊部署可能回 JSON 字串形
|
||
triplet_count: number | string;
|
||
updated_at?: number;
|
||
}
|
||
|
||
/**
|
||
* slot 值容錯 parse:kbdb 正常已回 parsed 陣列,但 slot 底層存的是 JSON 字串
|
||
* (live 曾觀測到字串形直出)——遇字串就 JSON.parse,parse 失敗/非陣列一律回空陣列(不 crash)。
|
||
*/
|
||
export function parseSlotArray<T>(raw: unknown): T[] {
|
||
if (Array.isArray(raw)) return raw as T[];
|
||
if (typeof raw === "string") {
|
||
try {
|
||
const v = JSON.parse(raw);
|
||
return Array.isArray(v) ? (v as T[]) : [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
return [];
|
||
}
|
||
|
||
/** top_entities 統一成名字清單(元素可能是 "name" 字串或 {name, degree} 物件)。 */
|
||
export function entityNames(raw: unknown, limit: number): string[] {
|
||
return parseSlotArray<unknown>(raw)
|
||
.map((e) => {
|
||
if (typeof e === "string") return e;
|
||
if (e && typeof e === "object" && typeof (e as { name?: unknown }).name === "string") {
|
||
return (e as { name: string }).name;
|
||
}
|
||
return null;
|
||
})
|
||
.filter((n): n is string => !!n)
|
||
.slice(0, limit);
|
||
}
|
||
|
||
/** 全館地圖渲染上限:庫行數與 narrative 截斷長度(instructions 總量控制在數百 token,R3)。 */
|
||
const MAX_LIBRARY_LINES = 30;
|
||
const MAX_NARRATIVE_CHARS = 60;
|
||
|
||
/** 把 GET /map 的 libraries[] 渲染成緊湊文字(每庫一行,design §4 指定格式)。空清單回 null。 */
|
||
export function renderLibraryMapLines(libraries: LibraryMapRow[]): string | null {
|
||
const rows = libraries.filter((l) => l && typeof l.library === "string" && l.library);
|
||
if (rows.length === 0) return null;
|
||
const lines = rows.slice(0, MAX_LIBRARY_LINES).map((l) => {
|
||
const narrative = (l.narrative ?? "").trim() || "(narrative 待補)";
|
||
const clipped =
|
||
narrative.length > MAX_NARRATIVE_CHARS ? `${narrative.slice(0, MAX_NARRATIVE_CHARS)}…` : narrative;
|
||
const core = entityNames(l.top_entities, 3);
|
||
const count = Number(l.triplet_count ?? 0) || 0;
|
||
return `- ${l.library}:${clipped}|核心:${core.length ? core.join("、") : "(尚無)"}|${count} triplets`;
|
||
});
|
||
const omitted = rows.length > MAX_LIBRARY_LINES ? `\n(其餘 ${rows.length - MAX_LIBRARY_LINES} 庫略,kbdb_get_map 可看全部)` : "";
|
||
return lines.join("\n") + omitted;
|
||
}
|
||
|
||
/** 拉 /map 的逾時上限(ms):instructions 是加分不是依賴,不值得讓連線多等。 */
|
||
const MAP_FETCH_TIMEOUT_MS = 1500;
|
||
/** 快取 TTL:成功 5 分鐘(地圖只在 ingest recompute 後變)、失敗/空 1 分鐘(別每 request 白等)。 */
|
||
const CACHE_TTL_OK_MS = 5 * 60 * 1000;
|
||
const CACHE_TTL_FAIL_MS = 60 * 1000;
|
||
|
||
/**
|
||
* 快取以「身分」分格(2026-08-12)。
|
||
*
|
||
* 為什麼不能共用一格:地圖本身就是情報(哪些庫存在、各有多少關聯、核心 entity 是誰)。
|
||
* 以帳密連線時只該看到自己有權限的庫;若跟服務級連線共用同一格快取,先連上的那個人
|
||
* 會把自己的視野留給下一個人——那是跨帳號外洩,不是效能問題。
|
||
*/
|
||
const instructionsCache = new Map<string, { text: string | null; expiresAt: number }>();
|
||
|
||
/** 測試用:清掉 isolate 內快取(prod 不呼叫)。 */
|
||
export function __resetLibraryMapInstructionsCacheForTests(): void {
|
||
instructionsCache.clear();
|
||
}
|
||
|
||
/**
|
||
* 組 MCP server instructions 的藏書地圖段(design §4 / §6「session 啟動 → instructions 已含
|
||
* 全館地圖(push 零查詢)」)。任何失敗(超時/HTTP 錯/空庫/壞 JSON)→ null(caller 靜默略過)。
|
||
*
|
||
* 以帳密連線(identity.kind === 'portal')時走 cypher `/portal/data/map`——只拿得到這個
|
||
* 帳號有權限的庫;服務級憑據維持既有 KBDB `/map` 直連。舊 token(stale)不給地圖。
|
||
*/
|
||
export async function buildLibraryMapInstructions(
|
||
env: Env,
|
||
identity: KnowledgeIdentity,
|
||
): Promise<string | null> {
|
||
if (identity.kind === "stale") return null;
|
||
// 快取 key:portal 用 session(=這個人這次登入),service 用固定字串。
|
||
// session token 只當 Map 的 key 活在 isolate 記憶體內,不落地、不寫 log。
|
||
const cacheKey = identity.kind === "portal" ? `portal:${identity.portal.session}` : "service";
|
||
const now = Date.now();
|
||
const hit = instructionsCache.get(cacheKey);
|
||
if (hit && hit.expiresAt > now) return hit.text;
|
||
|
||
let text: string | null = null;
|
||
try {
|
||
const res = await Promise.race([
|
||
identity.kind === "portal"
|
||
? portalFetch(env, identity.portal.session, "/portal/data/map")
|
||
: kbdbFetch(env, "/map"),
|
||
new Promise<never>((_, reject) =>
|
||
setTimeout(() => reject(new Error("library map fetch timeout")), MAP_FETCH_TIMEOUT_MS),
|
||
),
|
||
]);
|
||
if (res.ok) {
|
||
const data = (await res.json()) as { libraries?: LibraryMapRow[] };
|
||
const body = renderLibraryMapLines(Array.isArray(data.libraries) ? data.libraries : []);
|
||
if (body) {
|
||
text =
|
||
"【藏書地圖】KBDB 全館現況(每庫一行)。查資料前先看這裡定位該進哪個庫;" +
|
||
"需要某庫細節呼叫 kbdb_get_map(library),不確定該查什麼時先呼叫 kbdb_get_map。\n" +
|
||
body;
|
||
}
|
||
}
|
||
} catch {
|
||
// 鐵律:地圖是加分不是依賴——任何錯誤都不往外拋,instructions 沒地圖照常可用。
|
||
text = null;
|
||
}
|
||
|
||
instructionsCache.set(cacheKey, {
|
||
text,
|
||
expiresAt: now + (text ? CACHE_TTL_OK_MS : CACHE_TTL_FAIL_MS),
|
||
});
|
||
// isolate 內的快取,不做失效協議;但別讓不同帳號的格子無上限長大(isolate 可活很久)。
|
||
if (instructionsCache.size > 64) {
|
||
for (const [k, v] of instructionsCache) if (v.expiresAt <= now) instructionsCache.delete(k);
|
||
}
|
||
return text;
|
||
}
|