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>
283 lines
11 KiB
TypeScript
283 lines
11 KiB
TypeScript
import { Hono } from "hono";
|
||
import { cors } from "hono/cors";
|
||
import { Env } from "./types.js";
|
||
import { partnerAuthMiddleware, type AuthPath } from "./middleware/partner-auth.js";
|
||
import { handleMcpRequest } from "./mcp-handler.js";
|
||
import { resolveKnowledgeIdentity } from "./lib/portal-client.js";
|
||
import type { PortalIdentity } from "./oauth/store.js";
|
||
import { inspectorHtml } from "./pages/inspector.js";
|
||
import { kbdbFetch } from "./lib/kbdb-client.js";
|
||
import { registerOAuthRoutes } from "./oauth/routes.js";
|
||
|
||
const _app = new Hono<{
|
||
Bindings: Env;
|
||
Variables: {
|
||
org_namespace: string;
|
||
partner_token: string;
|
||
// 登入者身分(以帳密走 OAuth 連進來時才有)+ 這條連線是哪種憑據。
|
||
// 知識面工具(kbdb_*)據此決定走 portal 資料面還是既有 KBDB 直連(見 lib/portal-client.ts)。
|
||
portal?: PortalIdentity;
|
||
auth_path: AuthPath;
|
||
};
|
||
}>();
|
||
|
||
// ── OAuth 2.1 server 路由(掛在 worker 根路徑,非 /mcp)──────────────────────────
|
||
// well-known / authorize / token / register 必須在 origin 根,claude.ai 遠端 connector 才發現得到。
|
||
// 安全模型見 mcp/OAUTH.md。註冊在 basePath 之前,落在同一份共享 router。
|
||
registerOAuthRoutes(_app);
|
||
|
||
// ── GET /health — 讓「這台跑的是哪一版 MCP」用一條 curl 看得到 ─────────────────────
|
||
// 為什麼要有:cypher-executor 早就有 /health(bundle_version + auth_store 探針),
|
||
// arcrun-mcp 沒有 ⇒ 要判斷某台實例的 MCP 是哪一代認證,只能打 /authorize 剖 HTML 數欄位
|
||
// (ops-facts 2026-08-10 的土法)。那個判準脆弱又難教。
|
||
//
|
||
// ⚠️ 誠實界定(別把它當世代判準用過頭):本端點是**這個 commit 之後才有的**,所以
|
||
// 「/health 回 404」只代表「比本版舊」,**不代表就是 owner_secret 世代**——
|
||
// 現階段判世代仍要看 /authorize 的欄位(一個 owner_secret =舊;email+password =新)。
|
||
// 等這版推到各實例之後,`auth` 欄位才會變成一眼可讀的世代判準。
|
||
// 不需認證、不吐任何機密;MCP_BUILD 是部署標記,由各實例 toml [vars] 帶入。
|
||
_app.get("/health", (c) => c.json({
|
||
ok: true,
|
||
service: "arcrun-mcp",
|
||
auth: "portal-login",
|
||
build: c.env.MCP_BUILD ?? "unknown",
|
||
oauth_kv: c.env.OAUTH_KV ? "present" : "missing",
|
||
}));
|
||
|
||
const app = _app.basePath('/mcp');
|
||
|
||
app.use("*", cors({
|
||
origin: "*",
|
||
allowMethods: ["GET", "POST", "OPTIONS"],
|
||
allowHeaders: ["Content-Type", "Authorization"],
|
||
exposeHeaders: ["Content-Type"],
|
||
maxAge: 600,
|
||
}));
|
||
|
||
app.get("/", (c) => c.text("u6u MCP Server is running."));
|
||
|
||
app.get("/inspector", (c) => {
|
||
return c.html(inspectorHtml);
|
||
});
|
||
|
||
// ── GUI 認證端點 ───────────────────────────────────────────────────────────────
|
||
|
||
// GET /auth/verify — GUI 登入驗證,重用 partnerAuthMiddleware
|
||
app.get("/auth/verify", partnerAuthMiddleware, (c) => {
|
||
const orgNamespace = c.get("org_namespace");
|
||
return c.json({ valid: true, org_namespace: orgNamespace });
|
||
});
|
||
|
||
// ── GUI REST 端點(與 MCP tools 平行) ────────────────────────────────────────
|
||
|
||
// GET /workflows — 列出 Workflow 清單(GUI 用)
|
||
app.get("/workflows", partnerAuthMiddleware, async (c) => {
|
||
const orgNamespace = c.get("org_namespace");
|
||
try {
|
||
const resp = await kbdbFetch(
|
||
c.env,
|
||
`/records/search?template=workflow_metadata&user_id=${encodeURIComponent(orgNamespace)}`
|
||
);
|
||
if (!resp.ok) return c.json({ workflows: [] });
|
||
const data = await resp.json<{ records: Array<{ id: string; slots?: Record<string, unknown> }> }>();
|
||
const workflows = (data.records ?? []).map(r => ({
|
||
id: r.id,
|
||
name: (r.slots?.display_name as string | undefined) ?? (r.slots?.name as string | undefined) ?? r.id,
|
||
last_run: r.slots?.last_run as string | undefined,
|
||
status: r.slots?.status as string | undefined,
|
||
slots: r.slots,
|
||
}));
|
||
return c.json({ workflows });
|
||
} catch {
|
||
return c.json({ workflows: [] });
|
||
}
|
||
});
|
||
|
||
// GET /workflows/:id — 取得單一 Workflow(GUI poll 用)
|
||
app.get("/workflows/:id", partnerAuthMiddleware, async (c) => {
|
||
const id = c.req.param("id") ?? '';
|
||
try {
|
||
if (!id) return c.json({ error: "Missing id" }, 400);
|
||
const resp = await kbdbFetch(c.env, `/records/${encodeURIComponent(id)}`);
|
||
if (!resp.ok) return c.json({ error: "Not found" }, 404);
|
||
const data = await resp.json<{ id: string; slots?: Record<string, unknown> }>();
|
||
return c.json({
|
||
id: data.id,
|
||
name: (data.slots?.display_name as string | undefined) ?? data.id,
|
||
slots: data.slots,
|
||
});
|
||
} catch {
|
||
return c.json({ error: "Internal error" }, 500);
|
||
}
|
||
});
|
||
|
||
// POST /action-log — GUI 寫入用戶動作記錄
|
||
app.post("/action-log", partnerAuthMiddleware, async (c) => {
|
||
const orgNamespace = c.get("org_namespace");
|
||
try {
|
||
const body = await c.req.json<{
|
||
action_type: string;
|
||
payload?: Record<string, unknown>;
|
||
occurred_at?: string;
|
||
}>();
|
||
const occurred_at = body.occurred_at ?? new Date().toISOString();
|
||
|
||
await kbdbFetch(c.env, "/records", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
template_id: "tpl-action-log",
|
||
user_id: orgNamespace,
|
||
slots: {
|
||
org_namespace: orgNamespace,
|
||
action_type: body.action_type,
|
||
payload: JSON.stringify(body.payload ?? {}),
|
||
occurred_at,
|
||
},
|
||
}),
|
||
});
|
||
return c.json({ ok: true });
|
||
} catch {
|
||
return c.json({ ok: false }, 500);
|
||
}
|
||
});
|
||
|
||
// ── Prototype Pages REST 端點 ─────────────────────────────────────────────────
|
||
|
||
// GET /prototype-pages — 列出 Prototype Pages
|
||
app.get("/prototype-pages", partnerAuthMiddleware, async (c) => {
|
||
const orgNamespace = c.get("org_namespace");
|
||
try {
|
||
const resp = await kbdbFetch(
|
||
c.env,
|
||
`/records/search?template=tpl-page-block&user_id=${encodeURIComponent(orgNamespace)}`
|
||
);
|
||
if (!resp.ok) return c.json({ pages: [] });
|
||
const data = await resp.json<{ records: Array<{ id: string; slots?: Record<string, unknown> }> }>();
|
||
const pages = (data.records ?? []).map(r => ({
|
||
id: r.id,
|
||
page_name: (r.slots?.page_name as string | undefined) ?? 'Untitled',
|
||
components_json: (r.slots?.components_json as string | undefined) ?? '[]',
|
||
last_edited_by: (r.slots?.last_edited_by as string | undefined) ?? 'gui',
|
||
last_edited_at: (r.slots?.last_edited_at as string | undefined) ?? '',
|
||
status: (r.slots?.status as string | undefined) ?? 'draft',
|
||
}));
|
||
return c.json({ pages });
|
||
} catch {
|
||
return c.json({ pages: [] });
|
||
}
|
||
});
|
||
|
||
// POST /prototype-pages — 建立新 Prototype Page
|
||
app.post("/prototype-pages", partnerAuthMiddleware, async (c) => {
|
||
const orgNamespace = c.get("org_namespace");
|
||
try {
|
||
const body = await c.req.json<{ page_name?: string }>();
|
||
const page_name = body.page_name ?? 'Untitled';
|
||
const now = new Date().toISOString();
|
||
|
||
const resp = await kbdbFetch(c.env, "/records", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
template_id: "tpl-page-block",
|
||
user_id: orgNamespace,
|
||
slots: {
|
||
page_name,
|
||
org_namespace: orgNamespace,
|
||
components_json: '[]',
|
||
last_edited_by: 'gui',
|
||
last_edited_at: now,
|
||
status: 'draft',
|
||
},
|
||
}),
|
||
});
|
||
if (!resp.ok) return c.json({ error: "Failed to create" }, 500);
|
||
const data = await resp.json<{ id: string; slots?: Record<string, unknown> }>();
|
||
return c.json({
|
||
id: data.id,
|
||
page_name,
|
||
components_json: '[]',
|
||
last_edited_by: 'gui',
|
||
last_edited_at: now,
|
||
status: 'draft',
|
||
}, 201);
|
||
} catch {
|
||
return c.json({ error: "Internal error" }, 500);
|
||
}
|
||
});
|
||
|
||
// GET /prototype-pages/:id — 取得單一 Prototype Page
|
||
app.get("/prototype-pages/:id", partnerAuthMiddleware, async (c) => {
|
||
const id = c.req.param("id") ?? '';
|
||
try {
|
||
if (!id) return c.json({ error: "Missing id" }, 400);
|
||
const resp = await kbdbFetch(c.env, `/records/${encodeURIComponent(id)}`);
|
||
if (!resp.ok) return c.json({ error: "Not found" }, 404);
|
||
const data = await resp.json<{ id: string; slots?: Record<string, unknown> }>();
|
||
return c.json({
|
||
id: data.id,
|
||
page_name: (data.slots?.page_name as string | undefined) ?? 'Untitled',
|
||
components_json: (data.slots?.components_json as string | undefined) ?? '[]',
|
||
last_edited_by: (data.slots?.last_edited_by as string | undefined) ?? 'gui',
|
||
last_edited_at: (data.slots?.last_edited_at as string | undefined) ?? '',
|
||
status: (data.slots?.status as string | undefined) ?? 'draft',
|
||
});
|
||
} catch {
|
||
return c.json({ error: "Internal error" }, 500);
|
||
}
|
||
});
|
||
|
||
// PUT /prototype-pages/:id — 儲存 Prototype Page
|
||
app.put("/prototype-pages/:id", partnerAuthMiddleware, async (c) => {
|
||
const id = c.req.param("id") ?? '';
|
||
try {
|
||
if (!id) return c.json({ error: "Missing id" }, 400);
|
||
const body = await c.req.json<{
|
||
components_json?: string;
|
||
page_name?: string;
|
||
}>();
|
||
const now = new Date().toISOString();
|
||
const slots: Record<string, unknown> = {
|
||
last_edited_by: 'gui',
|
||
last_edited_at: now,
|
||
};
|
||
if (body.components_json !== undefined) slots.components_json = body.components_json;
|
||
if (body.page_name !== undefined) slots.page_name = body.page_name;
|
||
|
||
const resp = await kbdbFetch(c.env, `/records/${encodeURIComponent(id)}`, {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ slots }),
|
||
});
|
||
if (!resp.ok) return c.json({ error: "Failed to save" }, 500);
|
||
return c.json({ ok: true });
|
||
} catch {
|
||
return c.json({ error: "Internal error" }, 500);
|
||
}
|
||
});
|
||
|
||
// ── MCP 端點 ──────────────────────────────────────────────────────────────────
|
||
|
||
app.options("/mcp", (c) => {
|
||
return new Response(null, {
|
||
status: 204,
|
||
headers: {
|
||
"Access-Control-Allow-Origin": "*",
|
||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||
},
|
||
});
|
||
});
|
||
|
||
app.post("/", partnerAuthMiddleware, async (c) => {
|
||
const orgNamespace = c.get("org_namespace");
|
||
const partnerToken = c.get("partner_token");
|
||
const identity = resolveKnowledgeIdentity(c.get("auth_path"), c.get("portal"));
|
||
return handleMcpRequest(c.req.raw, c.env, orgNamespace, partnerToken, identity);
|
||
});
|
||
|
||
// 輸出根 app(_app):與 basePath('/mcp') 的 app 共享同一份 router,故 OAuth 根路由與
|
||
// /mcp 路由都能被分派。(若輸出 app 則根路徑的 well-known 分派行為依賴 basePath 細節,改輸出 _app 明確。)
|
||
export default _app;
|