/** * Portal 資料面 client — 「授權的 AI」用登入者的身分查東西的唯一管道。 * * leo 2026-08-12:「人類進 Portal 輸入帳密表示你是主人,可以查到你權限所有東西; * AI 透過輸入帳密的 MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」 * 「掛上 MCP 並輸入帳密,那個動作本身就是授權」⇒ **下游不得再要求第二次認證**。 * * 所以這裡帶的是 **portal session token**(同意頁輸入帳密時 cypher 發的那張, * 與人類在 portal 網頁上拿到的完全同一種),不是任何服務內部金鑰。 * 端點是 cypher 的 `/portal/data/*`——庫過濾、租戶注入、停用即時生效全在那邊 server 側做完, * 本檔不做任何判斷(薄殼鐵律 rule 07:能力長在 API,介面只轉換)。 * * 走既有 CYPHER_EXECUTOR service binding,不新增 binding、不新增金鑰。 */ import type { Env } from "../types.js"; import type { PortalIdentity } from "../oauth/store.js"; import { errorResponse } from "./cypher-client.js"; export interface PortalCallOpts { method?: string; body?: unknown; query?: Record; } /** 用登入者的 session 打 cypher 的 portal 資料面。 */ export async function portalFetch( env: Env, session: string, path: string, opts: PortalCallOpts = {}, ): Promise { if (!env.CYPHER_EXECUTOR) { throw new Error("CYPHER_EXECUTOR service binding not configured"); } const url = new URL(`https://cypher${path}`); for (const [k, v] of Object.entries(opts.query ?? {})) { if (v !== undefined && v !== "") url.searchParams.set(k, String(v)); } return env.CYPHER_EXECUTOR.fetch(url.toString(), { method: opts.method ?? "GET", headers: { "Content-Type": "application/json", Authorization: `Bearer ${session}`, }, body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, }); } /** * 知識面工具的身分解析結果。 * * 三態刻意分開,因為「查不到」和「沒有」不可以長得一樣(leo 的老原則): * - portal :有登入者 → 走 portal 資料面(權限=這個人的權限) * - service :服務級憑據(static token / partner key)→ 維持既有 KBDB 直連(零回歸) * - stale :OAuth token 但沒帶身分(本次改版前簽發的舊 token)→ **誠實要求重新連線**, * 不偷偷退回服務金鑰那條老路(那正是要修掉的「不管誰登入都看到同一格」) */ export type KnowledgeIdentity = | { kind: "portal"; portal: PortalIdentity } | { kind: "service" } | { kind: "stale" }; export function resolveKnowledgeIdentity( authPath: "oauth" | "service", portal: PortalIdentity | undefined, ): KnowledgeIdentity { if (authPath !== "oauth") return { kind: "service" }; return portal?.session ? { kind: "portal", portal } : { kind: "stale" }; } /** 舊 token(沒帶身分)時的統一回覆:講清楚怎麼修,不假裝查不到資料。 */ export function staleIdentityError() { return errorResponse( "identity_missing", "這條 MCP 連線是舊版簽發的 token,裡面沒有登入者身分,因此查不到任何知識內容。" + "重新連線一次(在 claude.ai 的 connector 設定裡重新授權、輸入你的 Portal 帳密)即可——" + "不需要另外找任何 credential 或金鑰。", [ "到 claude.ai → Settings → Connectors,把這個 connector 重新連線一次(會跳出輸入 Portal 帳密的頁面)", "重連後 kbdb_* 全部工具都會用你這個帳號的權限查詢", ], ); } /** * portal 資料面的錯誤 → 給 AI 看的訊息。 * 401/403 特別處理:那代表**登入階段過期或帳號被停用**,不是「資料不存在」—— * 兩者混在一起會讓 AI 對使用者說「你的知識庫是空的」,那是畫面在說謊。 */ export async function portalError(res: Response, what: string) { const detail = await res.text().catch(() => ""); if (res.status === 401) { return errorResponse( "session_expired", `${what}失敗:登入階段已過期(portal session 到期或已登出)。`, [ "到 claude.ai → Settings → Connectors 重新連線這個 connector(重新輸入 Portal 帳密)", "重連後權限與你在 portal 網頁上看到的一致", ], detail, ); } if (res.status === 403) { return errorResponse( "forbidden", `${what}失敗:這個帳號沒有這項權限(帳號可能已停用,或沒有被授權該知識庫)。`, ["請知識庫管理員在 portal 的帳號管理裡確認你的狀態與可用知識庫"], detail, ); } return errorResponse(`portal_${res.status}`, `${what}失敗(HTTP ${res.status})`, ["稍後重試"], detail); }