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
+202 -12
View File
@@ -59,14 +59,55 @@ async function pkcePair() {
return { verifier, challenge };
}
/**
* cypher `/portal/login` 的假替身(2026-08-12 起 MCP 的把關就是這支——用使用者自己的
* Portal 帳密,沒有另一把 owner secret)。帳密對 → 回 session_token 身分欄位;不對 → 401。
*/
const GOOD_EMAIL = "leo@example.com";
const GOOD_PASSWORD = "correct horse";
function cypherMock(
over: {
/** null 登入成功但**不回** session_token(舊版 cypher);預設回 "sess-abc" */
sessionToken?: string | null;
displayName?: string;
role?: string;
libraries?: string[];
sessionExpiresIn?: number;
} = {},
): { fetcher: Fetcher; calls: Array<{ email: string; password: string }> } {
const calls: Array<{ email: string; password: string }> = [];
const fetcher = {
async fetch(req: Request) {
const body = (await req.json()) as { email: string; password: string };
calls.push(body);
if (body.email !== GOOD_EMAIL || body.password !== GOOD_PASSWORD) {
return new Response(JSON.stringify({ error: "email 或密碼錯誤" }), { status: 401 });
}
const sessionToken = over.sessionToken === undefined ? "sess-abc" : over.sessionToken;
return new Response(
JSON.stringify({
success: true,
...(sessionToken ? { session_token: sessionToken } : {}),
display_name: over.displayName ?? "Leo",
role: over.role ?? "admin",
libraries: over.libraries ?? ["*"],
session_expires_in: over.sessionExpiresIn ?? 604800,
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
},
} as unknown as Fetcher;
return { fetcher, calls };
}
function baseEnv(over: Partial<Env> = {}): Env {
return {
COMPONENT_REGISTRY: {} as Fetcher,
CYPHER_EXECUTOR: {} as Fetcher,
CYPHER_EXECUTOR: cypherMock().fetcher,
KBDB: {} as Fetcher,
KBDB_INTERNAL_TOKEN: "internal",
OAUTH_KV: makeKV(),
MCP_OWNER_SECRET: "s3cr3t-owner",
MCP_OWNER_NAMESPACE: "leo",
...over,
} as Env;
@@ -121,6 +162,8 @@ describe("oauth/store", () => {
scope: "mcp",
resource: "https://mcp/mcp",
namespace: "leo",
portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["*"] },
portal_session_expires_in: 604800,
});
const first = await consumeAuthCode(kv, "code-1");
expect(first?.namespace).toBe("leo");
@@ -271,7 +314,11 @@ describe("oauth flow (整合)", () => {
)}&code_challenge=${challenge}&code_challenge_method=S256&state=xyz&scope=mcp`,
);
expect(ok.status).toBe(200);
expect(await ok.text()).toContain("Owner 祕密");
const consentHtml = await ok.text();
// 同意頁問的是 Portal 帳密(不是另一把 owner secret
expect(consentHtml).toContain("Portal");
expect(consentHtml).toContain('name="email"');
expect(consentHtml).toContain('name="password"');
// 缺 PKCE → 400
const bad = await app.req(
`/authorize?response_type=code&client_id=c1&redirect_uri=${encodeURIComponent(
@@ -281,18 +328,19 @@ describe("oauth flow (整合)", () => {
expect(bad.status).toBe(400);
});
it("GET /authorizeMCP_OWNER_SECRET 未設 → 503(不留不安全預設", async () => {
const app = buildApp(baseEnv({ MCP_OWNER_SECRET: undefined }));
it("GET /authorize不需要任何 owner 祕密就看得到同意頁(封測者接自己的 AI 不會死在這頁", async () => {
// 舊行為:未設 MCP_OWNER_SECRET → 503 ⇒ 每個封測者都卡住。現在把關是 Portal 帳密。
const app = buildApp(baseEnv());
const { challenge } = await pkcePair();
const r = await app.req(
`/authorize?response_type=code&client_id=c1&redirect_uri=${encodeURIComponent(
"https://claude.ai/cb",
)}&code_challenge=${challenge}&code_challenge_method=S256`,
);
expect(r.status).toBe(503);
expect(r.status).toBe(200);
});
it("完整 code→token:正確 owner 祕密 + 正確 verifier → access_token", async () => {
it("完整 code→token:正確 Portal 帳密 + 正確 verifier → access_token", async () => {
const env = baseEnv();
const app = buildApp(env);
const { verifier, challenge } = await pkcePair();
@@ -310,7 +358,8 @@ describe("oauth flow (整合)", () => {
code_challenge_method: "S256",
scope: "mcp",
resource: "https://mcp.arcrun.dev/mcp",
owner_secret: "s3cr3t-owner",
email: GOOD_EMAIL,
password: GOOD_PASSWORD,
}).toString(),
redirect: "manual",
});
@@ -344,6 +393,143 @@ describe("oauth flow (整合)", () => {
expect(at?.aud).toBe("https://mcp.arcrun.dev/mcp");
});
// ── 2026-08-12:身分要接住並攜帶(本次修的病根)─────────────────────────────
describe("登入者身分跟著 token 走(leo:掛上 MCP 並輸入帳密=授權,下游不得再問一次)", () => {
it("驗完帳密不是只留布林值:token 帶得出 portal session 與該帳號的可用知識庫", async () => {
const env = baseEnv({ CYPHER_EXECUTOR: cypherMock({ libraries: ["kb"], displayName: "小明", role: "user" }).fetcher });
const app = buildApp(env);
const { verifier, challenge } = await pkcePair();
const redirect = "https://claude.ai/cb";
const authRes = await app.req("/authorize", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: "c1",
redirect_uri: redirect,
code_challenge: challenge,
code_challenge_method: "S256",
email: GOOD_EMAIL,
password: GOOD_PASSWORD,
}).toString(),
redirect: "manual",
});
const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
const tokRes = await app.req("/token", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
code_verifier: verifier,
redirect_uri: redirect,
}).toString(),
});
const at = await getAccessToken(env.OAUTH_KV!, (await tokRes.json()).access_token);
expect(at?.portal?.session).toBe("sess-abc");
expect(at?.portal?.display_name).toBe("小明");
expect(at?.portal?.role).toBe("user");
expect(at?.portal?.libraries).toEqual(["kb"]);
});
it("**不同帳號登入 → token 帶的身分跟著換**(不是不管誰登入都同一格)", async () => {
// 兩個帳號權限不同:一個全庫、一個只有 kb。token 裡的身分必須各自不同。
const envA = baseEnv({ CYPHER_EXECUTOR: cypherMock({ sessionToken: "sess-A", displayName: "Leo", libraries: ["*"] }).fetcher });
const envB = baseEnv({ CYPHER_EXECUTOR: cypherMock({ sessionToken: "sess-B", displayName: "小明", libraries: ["kb"] }).fetcher });
async function tokenFor(env: Env) {
const app = buildApp(env);
const { verifier, challenge } = await pkcePair();
const redirect = "https://claude.ai/cb";
const a = await app.req("/authorize", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: "c1",
redirect_uri: redirect,
code_challenge: challenge,
code_challenge_method: "S256",
email: GOOD_EMAIL,
password: GOOD_PASSWORD,
}).toString(),
redirect: "manual",
});
const code = new URL(a.headers.get("location")!).searchParams.get("code")!;
const t = await app.req("/token", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
code_verifier: verifier,
redirect_uri: redirect,
}).toString(),
});
return getAccessToken(env.OAUTH_KV!, (await t.json()).access_token);
}
const a = await tokenFor(envA);
const b = await tokenFor(envB);
expect(a?.portal?.session).not.toBe(b?.portal?.session);
expect(a?.portal?.libraries).toEqual(["*"]);
expect(b?.portal?.libraries).toEqual(["kb"]);
});
it("access_token 活不過它底下的 portal sessionTTL 取兩者較小)", async () => {
const env = baseEnv({
MCP_TOKEN_TTL: "2592000", // 30 天
CYPHER_EXECUTOR: cypherMock({ sessionExpiresIn: 3600 }).fetcher, // session 只有 1 小時
});
const app = buildApp(env);
const { verifier, challenge } = await pkcePair();
const redirect = "https://claude.ai/cb";
const a = await app.req("/authorize", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: "c1",
redirect_uri: redirect,
code_challenge: challenge,
code_challenge_method: "S256",
email: GOOD_EMAIL,
password: GOOD_PASSWORD,
}).toString(),
redirect: "manual",
});
const code = new URL(a.headers.get("location")!).searchParams.get("code")!;
const t = await app.req("/token", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
code_verifier: verifier,
redirect_uri: redirect,
}).toString(),
});
expect((await t.json()).expires_in).toBe(3600);
});
it("cypher 回 200 但沒給 session_token(舊版 cypher)→ 不發碼(不發一張沒有身分的 token)", async () => {
const app = buildApp(baseEnv({ CYPHER_EXECUTOR: cypherMock({ sessionToken: null }).fetcher }));
const { challenge } = await pkcePair();
const r = await app.req("/authorize", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: "c1",
redirect_uri: "https://claude.ai/cb",
code_challenge: challenge,
code_challenge_method: "S256",
email: GOOD_EMAIL,
password: GOOD_PASSWORD,
}).toString(),
redirect: "manual",
});
expect(r.status).toBe(401);
expect(r.headers.get("location")).toBeNull();
});
});
it("錯誤 owner 祕密 → 401、不發 code", async () => {
const app = buildApp(baseEnv());
const { challenge } = await pkcePair();
@@ -355,7 +541,8 @@ describe("oauth flow (整合)", () => {
redirect_uri: "https://claude.ai/cb",
code_challenge: challenge,
code_challenge_method: "S256",
owner_secret: "WRONG",
email: GOOD_EMAIL,
password: "WRONG",
}).toString(),
redirect: "manual",
});
@@ -377,7 +564,8 @@ describe("oauth flow (整合)", () => {
redirect_uri: redirect,
code_challenge: challenge,
code_challenge_method: "S256",
owner_secret: "s3cr3t-owner",
email: GOOD_EMAIL,
password: GOOD_PASSWORD,
}).toString(),
redirect: "manual",
});
@@ -445,7 +633,8 @@ describe("oauth resourceRFC 8707)簽發端把關", () => {
code_challenge: challenge,
code_challenge_method: "S256",
resource,
owner_secret: "s3cr3t-owner",
email: GOOD_EMAIL,
password: GOOD_PASSWORD,
}).toString(),
redirect: "manual",
});
@@ -570,7 +759,8 @@ describe("oauth store drift guardOAUTH_KV 的 put 一律帶 TTL", () => {
redirect_uri: redirect,
code_challenge: challenge,
code_challenge_method: "S256",
owner_secret: "s3cr3t-owner",
email: GOOD_EMAIL,
password: GOOD_PASSWORD,
}).toString(),
redirect: "manual",
});
@@ -0,0 +1,208 @@
/**
* kbdb_* 資料層工具:**用登入進來的那個人的身分查詢**(2026-08-12)。
*
* leo:「人類進 Portal 輸入帳密表示你是主人,可以查到你權限所有東西;AI 透過輸入帳密的
* MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」
* 「掛上 MCP 並輸入帳密,那個動作本身就是授權」⇒ 下游不得再要求第二次認證。
*
* 本檔守三件事:
* ① 以帳密連線時,查詢**帶登入者的 portal session** 打 cypher `/portal/data/*`
* ——不再拿 KBDB 的服務內部金鑰直打 KBDB(那條路繞過所有庫過濾)。
* ② 呼叫端自帶的 owner_id **一律不生效**(範圍由帳號權限決定,不由呼叫端指定)。
* ③ 舊 token(沒有身分)**fail-closed**:誠實要求重新連線,不偷偷退回服務金鑰那條老路。
* ④ 服務級憑據(static token / partner key)維持既有 KBDB 直連(零回歸)。
*/
import { describe, it, expect } from "vitest";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { Env } from "../../../src/types.js";
import { registerAllKbdbDataTools } from "../../../src/tools/kbdb_data.js";
import type { KnowledgeIdentity } from "../../../src/lib/portal-client.js";
type ToolHandler = (args: Record<string, unknown>) => Promise<{
content: { type: string; text: string }[];
isError?: boolean;
}>;
function makeServer() {
const tools = new Map<string, { description: string; handler: ToolHandler }>();
const server = {
tool(name: string, description: string, _schema: unknown, handler: ToolHandler) {
tools.set(name, { description, handler });
},
};
return { server: server as unknown as McpServer, tools };
}
/** 兩個 binding 都掛上,才驗得出「該走哪一條」——走錯的那條會被記錄下來。 */
function makeEnv(respond: (which: "cypher" | "kbdb", url: URL, init?: RequestInit) => Response) {
const cypherCalls: { url: URL; init?: RequestInit }[] = [];
const kbdbCalls: { url: URL; init?: RequestInit }[] = [];
const env = {
CYPHER_EXECUTOR: {
fetch: async (input: string, init?: RequestInit) => {
const url = new URL(input);
cypherCalls.push({ url, init });
return respond("cypher", url, init);
},
},
KBDB: {
fetch: async (input: string, init?: RequestInit) => {
const url = new URL(input);
kbdbCalls.push({ url, init });
return respond("kbdb", url, init);
},
},
KBDB_INTERNAL_TOKEN: "service-key-should-not-be-used-on-portal-path",
} as unknown as Env;
return { env, cypherCalls, kbdbCalls };
}
function parseResult(r: { content: { text: string }[] }) {
return JSON.parse(r.content[0].text) as Record<string, unknown>;
}
const PORTAL: KnowledgeIdentity = {
kind: "portal",
portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["kb"] },
};
const SERVICE: KnowledgeIdentity = { kind: "service" };
const STALE: KnowledgeIdentity = { kind: "stale" };
function tools(identity: KnowledgeIdentity, respond: Parameters<typeof makeEnv>[0]) {
const { server, tools } = makeServer();
const e = makeEnv(respond);
registerAllKbdbDataTools(server, e.env, identity);
return { tools, ...e };
}
const OK = () => new Response(JSON.stringify({ success: true, entries: [], records: [], count: 0 }));
describe("kbdb_* 以登入者身分查詢(portal 資料面)", () => {
const cases: Array<{ tool: string; args: Record<string, unknown>; path: string; method?: string }> = [
{ tool: "kbdb_search", args: { q: "火星座標" }, path: "/portal/data/search" },
{ tool: "kbdb_query", args: { template: "triplet" }, path: "/portal/data/records/by-template/triplet" },
{ tool: "kbdb_get_record", args: { record_id: "rec_1" }, path: "/portal/data/records/rec_1" },
{ tool: "kbdb_list_templates", args: {}, path: "/portal/data/templates" },
{ tool: "kbdb_create_template", args: { name: "contact", slots: ["name"] }, path: "/portal/data/templates", method: "POST" },
{ tool: "kbdb_create_record", args: { template: "contact", values: { name: "Leo" } }, path: "/portal/data/records", method: "POST" },
];
for (const c of cases) {
it(`${c.tool} → 打 ${c.path},帶登入者 session,完全不碰 KBDB 服務金鑰`, async () => {
const { tools: t, cypherCalls, kbdbCalls } = tools(PORTAL, OK);
const res = await t.get(c.tool)!.handler(c.args);
expect(res.isError).toBeUndefined();
// 走的是 cypher 的 portal 資料面,不是 KBDB 直連
expect(kbdbCalls, `${c.tool} 不該直打 KBDB`).toHaveLength(0);
expect(cypherCalls).toHaveLength(1);
expect(cypherCalls[0].url.pathname).toBe(c.path);
expect(cypherCalls[0].init?.method ?? "GET").toBe(c.method ?? "GET");
// 帶的是「那個人的 session」,不是任何服務金鑰
const auth = new Headers(cypherCalls[0].init!.headers as HeadersInit).get("Authorization");
expect(auth).toBe("Bearer sess-abc");
expect(auth).not.toContain("service-key");
});
}
it("呼叫端自帶 owner_id 一律不生效(不讓呼叫端自己挑租戶/歸屬)", async () => {
const { tools: t, cypherCalls } = tools(PORTAL, OK);
await t.get("kbdb_search")!.handler({ q: "x", owner_id: "someone-else" });
await t.get("kbdb_query")!.handler({ template: "triplet", owner_id: "someone-else" });
for (const call of cypherCalls) {
expect(call.url.searchParams.get("owner_id")).toBeNull();
}
});
it("寫入時 owner_id 不從呼叫端 body 走(server 定死成登入者的歸屬)", async () => {
const { tools: t, cypherCalls } = tools(PORTAL, OK);
await t.get("kbdb_create_record")!.handler({
template: "contact",
values: { name: "Leo" },
owner_id: "someone-else",
});
const body = JSON.parse(String(cypherCalls[0].init!.body)) as Record<string, unknown>;
expect(body).not.toHaveProperty("owner_id");
});
it("越庫寫入被擋(403)→ 誠實講是權限問題", async () => {
const { tools: t } = tools(PORTAL, () =>
new Response(JSON.stringify({ error: '無「secret」庫的權限,不能寫入該庫' }), { status: 403 }),
);
const res = await t.get("kbdb_create_record")!.handler({
template: "note",
values: { library: "secret", body: "x" },
});
expect(res.isError).toBe(true);
expect(parseResult(res).error_code).toBe("forbidden");
});
it("查不是自己的 record(404)→ 與「不存在」同一句話(不洩存在性)", async () => {
const { tools: t } = tools(PORTAL, () =>
new Response(JSON.stringify({ error: "找不到這筆資料" }), { status: 404 }),
);
const res = await t.get("kbdb_get_record")!.handler({ record_id: "rec_someone_else" });
expect(res.isError).toBe(true);
expect(parseResult(res).error_code).toBe("not_found");
expect(String(parseResult(res).human_message)).toContain("不在你的權限範圍內");
});
it("session 過期(401)→ session_expired,不謊稱資料是空的", async () => {
const { tools: t } = tools(PORTAL, () =>
new Response(JSON.stringify({ error: "session 無效或已過期" }), { status: 401 }),
);
const res = await t.get("kbdb_search")!.handler({ q: "x" });
expect(res.isError).toBe(true);
expect(parseResult(res).error_code).toBe("session_expired");
});
});
describe("fail-closed:舊 token 沒有身分就查不到東西(不退回服務金鑰)", () => {
for (const name of [
"kbdb_search",
"kbdb_query",
"kbdb_get_record",
"kbdb_list_templates",
"kbdb_create_template",
"kbdb_create_record",
]) {
it(`${name} → identity_missing,且一個查詢都不發`, async () => {
const { tools: t, cypherCalls, kbdbCalls } = tools(STALE, OK);
const res = await t.get(name)!.handler({
q: "x",
template: "t",
record_id: "r",
name: "n",
slots: ["a"],
values: { a: "b" },
});
expect(res.isError).toBe(true);
expect(parseResult(res).error_code).toBe("identity_missing");
expect(cypherCalls).toHaveLength(0);
expect(kbdbCalls).toHaveLength(0);
});
}
});
describe("回歸:服務級憑據維持既有 KBDB 直連", () => {
it("kbdb_search 仍直打 KBDB /entries/search,且照舊吃 owner_id", async () => {
const { tools: t, cypherCalls, kbdbCalls } = tools(SERVICE, OK);
const res = await t.get("kbdb_search")!.handler({ q: "x", owner_id: "leo" });
expect(res.isError).toBeUndefined();
expect(cypherCalls).toHaveLength(0);
expect(kbdbCalls).toHaveLength(1);
expect(kbdbCalls[0].url.pathname).toBe("/entries/search");
expect(kbdbCalls[0].url.searchParams.get("owner_id")).toBe("leo");
});
it("kbdb_query / kbdb_get_record 路徑不變", async () => {
const { tools: t, kbdbCalls } = tools(SERVICE, OK);
await t.get("kbdb_query")!.handler({ template: "triplet" });
await t.get("kbdb_get_record")!.handler({ record_id: "rec_1" });
expect(kbdbCalls.map((c) => c.url.pathname)).toEqual([
"/records/by-template/triplet",
"/records/rec_1",
]);
});
});
+92 -7
View File
@@ -5,6 +5,17 @@ import {
registerGraphNeighbors,
GRAPH_NEIGHBORS_WORKFLOW,
} from "../../../src/tools/kbdb_graph.js";
import type { KnowledgeIdentity } from "../../../src/lib/portal-client.js";
/** 服務級憑據(static token / partner key)——既有路徑,行為零變更。 */
const SERVICE: KnowledgeIdentity = { kind: "service" };
/** 有人輸入 Portal 帳密授權的連線——走 cypher 的 portal 資料面。 */
const PORTAL: KnowledgeIdentity = {
kind: "portal",
portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["*"] },
};
/** 本次改版前簽發的舊 token(沒有身分)。 */
const STALE: KnowledgeIdentity = { kind: "stale" };
// ── 假 McpServer:只攔 tool 註冊,抓出 handler 直接呼叫 ─────────────────────────
type ToolHandler = (args: Record<string, unknown>) => Promise<{
@@ -45,7 +56,7 @@ describe("kbdb_graph_neighbors: registration", () => {
it("registers under kbdb_* prefix (D17 KBDB MCP boundary)", () => {
const { server, tools } = makeServer();
const { env } = makeEnv(() => new Response("{}"));
registerGraphNeighbors(server, env, "leo");
registerGraphNeighbors(server, env, "leo", SERVICE);
expect(tools.has("kbdb_graph_neighbors")).toBe(true);
expect(tools.get("kbdb_graph_neighbors")!.description).toContain("graph");
});
@@ -61,7 +72,7 @@ describe("kbdb_graph_neighbors: request shape", () => {
{ status: 200 },
),
);
registerGraphNeighbors(server, env, "leo");
registerGraphNeighbors(server, env, "leo", SERVICE);
const res = await tools.get("kbdb_graph_neighbors")!.handler({
subject: "Arcrun",
depth: 2,
@@ -88,7 +99,7 @@ describe("kbdb_graph_neighbors: request shape", () => {
const { env, calls } = makeEnv(
() => new Response(JSON.stringify({ success: true, neighbors: [], count: 0 })),
);
registerGraphNeighbors(server, env, "leo");
registerGraphNeighbors(server, env, "leo", SERVICE);
await tools.get("kbdb_graph_neighbors")!.handler({
subject: "A",
kbdb_base: "https://kbdb.example.com",
@@ -108,7 +119,7 @@ describe("kbdb_graph_neighbors: honest errors (鐵律:workflow 沒裝不 crash
const { env } = makeEnv(
() => new Response(JSON.stringify({ error: '找不到 workflow "graph_neighbors"' }), { status: 404 }),
);
registerGraphNeighbors(server, env, "leo");
registerGraphNeighbors(server, env, "leo", SERVICE);
const res = await tools.get("kbdb_graph_neighbors")!.handler({
subject: "A",
kbdb_base: "https://kbdb.example.com",
@@ -124,7 +135,7 @@ describe("kbdb_graph_neighbors: honest errors (鐵律:workflow 沒裝不 crash
const { env } = makeEnv(
() => new Response(JSON.stringify({ success: false, error: "boom", trace: [] }), { status: 500 }),
);
registerGraphNeighbors(server, env, "leo");
registerGraphNeighbors(server, env, "leo", SERVICE);
const res = await tools.get("kbdb_graph_neighbors")!.handler({
subject: "A",
kbdb_base: "https://kbdb.example.com",
@@ -143,7 +154,7 @@ describe("kbdb_graph_neighbors: honest errors (鐵律:workflow 沒裝不 crash
status: 200,
}),
);
registerGraphNeighbors(server, env, "leo");
registerGraphNeighbors(server, env, "leo", SERVICE);
const res = await tools.get("kbdb_graph_neighbors")!.handler({
subject: "A",
kbdb_base: "https://kbdb.example.com",
@@ -156,7 +167,7 @@ describe("kbdb_graph_neighbors: honest errors (鐵律:workflow 沒裝不 crash
it("empty orgNamespace → no_namespace error, no fetch made", async () => {
const { server, tools } = makeServer();
const { env, calls } = makeEnv(() => new Response("{}"));
registerGraphNeighbors(server, env, "");
registerGraphNeighbors(server, env, "", SERVICE);
const res = await tools.get("kbdb_graph_neighbors")!.handler({
subject: "A",
kbdb_base: "https://kbdb.example.com",
@@ -166,3 +177,77 @@ describe("kbdb_graph_neighbors: honest errors (鐵律:workflow 沒裝不 crash
expect(calls).toHaveLength(0);
});
});
// ── 2026-08-12:以帳密連線時走登入者的身分(leo:主人查得到的,授權的 AI 就查得到)──
describe("kbdb_graph_neighbors: 登入身分(portal 資料面)", () => {
it("打 cypher 的 /portal/data/graph/neighbors,且帶的是登入者的 session(不是服務金鑰)", async () => {
const { server, tools } = makeServer();
const { env, calls } = makeEnv(
() =>
new Response(
JSON.stringify({
neighbors: [{ node: "B", predicate: "uses", from: "A", depth: 1 }],
edges: [],
count: 1,
}),
{ status: 200 },
),
);
registerGraphNeighbors(server, env, "leo", PORTAL);
const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A", depth: 2 });
expect(calls).toHaveLength(1);
expect(calls[0].url.pathname).toBe("/portal/data/graph/neighbors/A");
expect(calls[0].url.searchParams.get("depth")).toBe("2");
const headers = new Headers(calls[0].init!.headers as HeadersInit);
expect(headers.get("Authorization")).toBe("Bearer sess-abc");
expect(res.isError).toBeUndefined();
expect((parseResult(res).data as { count: number }).count).toBe(1);
});
it("**不需要 kbdb_base**:已經登入過了,不再要第二次「證明你是誰/你的庫在哪」", async () => {
const { server, tools } = makeServer();
const { env, calls } = makeEnv(
() => new Response(JSON.stringify({ neighbors: [], edges: [], count: 0 })),
);
registerGraphNeighbors(server, env, "leo", PORTAL);
const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A" });
expect(res.isError).toBeUndefined();
expect(calls).toHaveLength(1);
// 呼叫端就算硬塞 kbdb_base 也不會被拿去用(server 自己知道要查哪個庫)
expect(calls[0].url.searchParams.get("kbdb_base")).toBeNull();
});
it("session 過期(401)→ 誠實說是登入過期,不說「查不到資料」", async () => {
const { server, tools } = makeServer();
const { env } = makeEnv(
() => new Response(JSON.stringify({ error: "session 無效或已過期" }), { status: 401 }),
);
registerGraphNeighbors(server, env, "leo", PORTAL);
const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A" });
expect(res.isError).toBe(true);
expect(parseResult(res).error_code).toBe("session_expired");
});
it("無 graph 權限(403)→ 誠實回沒權限,不假裝「沒有關聯」", async () => {
const { server, tools } = makeServer();
const { env } = makeEnv(
() => new Response(JSON.stringify({ error: "無知識圖譜檢視權限" }), { status: 403 }),
);
registerGraphNeighbors(server, env, "leo", PORTAL);
const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A" });
expect(res.isError).toBe(true);
expect(parseResult(res).error_code).toBe("forbidden");
});
it("舊 token(沒有身分)→ 不偷偷退回服務金鑰那條老路,要求重新連線", async () => {
const { server, tools } = makeServer();
const { env, calls } = makeEnv(() => new Response("{}"));
registerGraphNeighbors(server, env, "leo", STALE);
const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A" });
expect(res.isError).toBe(true);
expect(parseResult(res).error_code).toBe("identity_missing");
expect(calls).toHaveLength(0); // 一個查詢都沒發出去(fail-closed
});
});
+135 -17
View File
@@ -7,6 +7,32 @@ import {
renderLibraryMapLines,
__resetLibraryMapInstructionsCacheForTests,
} from "../../../src/lib/library-map.js";
import type { KnowledgeIdentity } from "../../../src/lib/portal-client.js";
/** 服務級憑據(static token / partner key)——既有 KBDB 直連路徑,行為零變更。 */
const SERVICE: KnowledgeIdentity = { kind: "service" };
/** 有人輸入 Portal 帳密授權的連線——走 cypher 的 portal 資料面(只看得到自己有權限的庫)。 */
const PORTAL: KnowledgeIdentity = {
kind: "portal",
portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["kb"] },
};
/** 本次改版前簽發的舊 token(沒有身分)。 */
const STALE: KnowledgeIdentity = { kind: "stale" };
/** 假 CYPHER_EXECUTOR bindingportal 資料面用)。 */
function makePortalEnv(respond: (url: URL, init?: RequestInit) => Response) {
const calls: { url: URL; init?: RequestInit }[] = [];
const env = {
CYPHER_EXECUTOR: {
fetch: async (input: string, init?: RequestInit) => {
const url = new URL(input);
calls.push({ url, init });
return respond(url, init);
},
},
} as unknown as Env;
return { env, calls };
}
// ── 假 McpServer:只攔 tool 註冊,抓出 handler 直接呼叫(比照 kbdb-graph.test.ts)──────
type ToolHandler = (args: Record<string, unknown>) => Promise<{
@@ -56,7 +82,7 @@ describe("kbdb_get_map: registration", () => {
it("registers under kbdb_* prefix (D17) with the 'call this first' hint in description", () => {
const { server, tools } = makeServer();
const { env } = makeEnv(() => new Response("{}"));
registerGetMap(server, env);
registerGetMap(server, env, SERVICE);
expect(tools.has("kbdb_get_map")).toBe(true);
// 任務規格:description 必含「不確定該查什麼時,先呼叫此工具」
expect(tools.get("kbdb_get_map")!.description).toContain("不確定該查什麼時,先呼叫此工具");
@@ -69,7 +95,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
const { env, calls } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
);
registerGetMap(server, env);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({});
expect(calls).toHaveLength(1);
@@ -90,7 +116,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
const { env, calls } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
);
registerGetMap(server, env);
registerGetMap(server, env, SERVICE);
await tools.get("kbdb_get_map")!.handler({ owner_id: "leo" });
expect(calls[0].url.searchParams.get("owner_id")).toBe("leo");
});
@@ -105,7 +131,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
const { env } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [row], count: 1 })),
);
registerGetMap(server, env);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({});
const data = parseResult(res).data as {
libraries: { top_entities: string[]; triplet_count: number }[];
@@ -119,7 +145,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
const { env } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
);
registerGetMap(server, env);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({});
const body = parseResult(res);
expect(body.ok).toBe(true);
@@ -135,7 +161,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
const { env } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
);
registerGetMap(server, env);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({});
const body = parseResult(res);
const hintsText = JSON.stringify(body.hints);
@@ -149,7 +175,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
it("HTTP error → map_fetch_failed with recompute hint, not a crash", async () => {
const { server, tools } = makeServer();
const { env } = makeEnv(() => new Response("boom", { status: 500 }));
registerGetMap(server, env);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({});
expect(res.isError).toBe(true);
const body = parseResult(res);
@@ -178,7 +204,7 @@ describe("kbdb_get_map: 單庫詳圖(library 參數)", () => {
const { env, calls } = makeEnv(
() => new Response(JSON.stringify({ success: true, map: DETAIL })),
);
registerGetMap(server, env);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" });
expect(calls[0].url.pathname).toBe("/map/kb");
const map = (parseResult(res).data as { map: typeof DETAIL }).map;
@@ -197,7 +223,7 @@ describe("kbdb_get_map: 單庫詳圖(library 參數)", () => {
triplet_count: "111",
};
const { env } = makeEnv(() => new Response(JSON.stringify({ success: true, map: raw })));
registerGetMap(server, env);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" });
expect(res.isError).toBeUndefined();
const map = (parseResult(res).data as { map: Record<string, unknown> }).map;
@@ -212,7 +238,7 @@ describe("kbdb_get_map: 單庫詳圖(library 參數)", () => {
const { env } = makeEnv(
() => new Response(JSON.stringify({ success: false, error: "not found" }), { status: 404 }),
);
registerGetMap(server, env);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({ library: "ghost" });
expect(res.isError).toBe(true);
const body = parseResult(res);
@@ -233,7 +259,7 @@ describe("kbdb_get_map: 單庫詳圖(library 參數)", () => {
},
},
} as unknown as Env;
registerGetMap(server, env);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" });
expect(res.isError).toBe(true);
expect(parseResult(res).error_code).toBe("internal_error");
@@ -258,7 +284,7 @@ describe("buildLibraryMapInstructions", () => {
}),
),
);
const text = await buildLibraryMapInstructions(env);
const text = await buildLibraryMapInstructions(env, SERVICE);
expect(text).not.toBeNull();
// design §4 格式:{library}{narrative}|核心:{top3}{triplet_count} triplets
expect(text!).toContain("kbleo 的知識庫主庫|核心:00-INDEX、kb/00-INDEX、Gitea111 triplets");
@@ -269,7 +295,7 @@ describe("buildLibraryMapInstructions", () => {
it("HTTP error → null(靜默略過,不 throw 不擋連線)", async () => {
const { env } = makeEnv(() => new Response("boom", { status: 500 }));
await expect(buildLibraryMapInstructions(env)).resolves.toBeNull();
await expect(buildLibraryMapInstructions(env, SERVICE)).resolves.toBeNull();
});
it("binding throws → null(靜默略過)", async () => {
@@ -280,22 +306,22 @@ describe("buildLibraryMapInstructions", () => {
},
},
} as unknown as Env;
await expect(buildLibraryMapInstructions(env)).resolves.toBeNull();
await expect(buildLibraryMapInstructions(env, SERVICE)).resolves.toBeNull();
});
it("empty libraries → null(沒地圖就不注入,不塞空段落)", async () => {
const { env } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
);
await expect(buildLibraryMapInstructions(env)).resolves.toBeNull();
await expect(buildLibraryMapInstructions(env, SERVICE)).resolves.toBeNull();
});
it("caches within TTLsame isolate 第二次不再打 /map", async () => {
const { env, calls } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
);
const first = await buildLibraryMapInstructions(env);
const second = await buildLibraryMapInstructions(env);
const first = await buildLibraryMapInstructions(env, SERVICE);
const second = await buildLibraryMapInstructions(env, SERVICE);
expect(second).toBe(first);
expect(calls).toHaveLength(1);
});
@@ -318,3 +344,95 @@ describe("renderLibraryMapLines", () => {
expect(renderLibraryMapLines([])).toBeNull();
});
});
// ── 2026-08-12:地圖也要跟著登入者的權限走 ────────────────────────────────────
// 地圖本身就是情報(有哪些庫、各有多少關聯、核心 entity 是誰)——不能整館推給
// 一個只有部分權限的帳號。
describe("藏書地圖:登入身分(portal 資料面)", () => {
beforeEach(() => __resetLibraryMapInstructionsCacheForTests());
it("kbdb_get_map 打 /portal/data/map,帶登入者 session,不碰 KBDB 服務金鑰", async () => {
const { server, tools } = makeServer();
const { env, calls } = makePortalEnv(
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
);
registerGetMap(server, env, PORTAL);
const res = await tools.get("kbdb_get_map")!.handler({});
expect(res.isError).toBeUndefined();
expect(calls).toHaveLength(1);
expect(calls[0].url.pathname).toBe("/portal/data/map");
expect(new Headers(calls[0].init!.headers as HeadersInit).get("Authorization")).toBe("Bearer sess-abc");
});
it("呼叫端硬塞 owner_id 也不生效(查詢範圍由帳號權限決定,不由呼叫端指定)", async () => {
const { server, tools } = makeServer();
const { env, calls } = makePortalEnv(
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
);
registerGetMap(server, env, PORTAL);
await tools.get("kbdb_get_map")!.handler({ owner_id: "someone-else" });
expect(calls[0].url.searchParams.get("owner_id")).toBeNull();
});
it("查沒權限的庫 → 與「不存在」同一句話(不洩存在性)", async () => {
const { server, tools } = makeServer();
const { env } = makePortalEnv(() => new Response(JSON.stringify({ error: "找不到這筆資料" }), { status: 404 }));
registerGetMap(server, env, PORTAL);
const res = await tools.get("kbdb_get_map")!.handler({ library: "secret-lib" });
expect(res.isError).toBe(true);
const body = parseResult(res);
expect(body.error_code).toBe("map_not_found");
expect(String(body.human_message)).toContain("不在你被授權");
});
it("session 過期(401)→ session_expired,不說「地圖是空的」", async () => {
const { server, tools } = makeServer();
const { env } = makePortalEnv(
() => new Response(JSON.stringify({ error: "session 無效或已過期" }), { status: 401 }),
);
registerGetMap(server, env, PORTAL);
const res = await tools.get("kbdb_get_map")!.handler({});
expect(res.isError).toBe(true);
expect(parseResult(res).error_code).toBe("session_expired");
});
it("舊 token(沒身分)→ identity_missing,且一個查詢都不發(fail-closed", async () => {
const { server, tools } = makeServer();
const { env, calls } = makePortalEnv(() => new Response("{}"));
registerGetMap(server, env, STALE);
const res = await tools.get("kbdb_get_map")!.handler({});
expect(res.isError).toBe(true);
expect(parseResult(res).error_code).toBe("identity_missing");
expect(calls).toHaveLength(0);
});
it("instructions 的地圖也走 portal 資料面(連線開場推的庫名不得超出權限)", async () => {
const { env, calls } = makePortalEnv(
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
);
const text = await buildLibraryMapInstructions(env, PORTAL);
expect(text).toContain("kb");
expect(calls[0].url.pathname).toBe("/portal/data/map");
});
it("**快取不跨身分共用**:不同 session 各自打一次,不會拿到別人的視野", async () => {
const { env, calls } = makePortalEnv(
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
);
const other: KnowledgeIdentity = {
kind: "portal",
portal: { session: "sess-other", display_name: "小明", role: "user", libraries: ["notes"] },
};
await buildLibraryMapInstructions(env, PORTAL);
await buildLibraryMapInstructions(env, other);
expect(calls).toHaveLength(2); // 兩次真的各打一次
await buildLibraryMapInstructions(env, PORTAL);
expect(calls).toHaveLength(2); // 同一 session 第二次才吃快取
});
it("舊 token → 不給地圖(instructions 不外洩任何庫名)", async () => {
const { env, calls } = makePortalEnv(() => new Response("{}"));
expect(await buildLibraryMapInstructions(env, STALE)).toBeNull();
expect(calls).toHaveLength(0);
});
});