/** * KBDB 資料層 proxy(kbdb-base Phase 9.5,HANDOFF §2 + §3b 後續) * * 為什麼存在:CLI 是 client,只認證到 cypher-executor(X-Arcrun-API-Key),達不到獨立的 * KBDB worker(MCP 走內部 service binding 可達,CLI 不行)。故在 cypher 開一條 proxy, * 讓 CLI 薄殼(acr kbdb *)透過「它本來就連的 cypher」打 KBDB 基本盤 API。 * * 薄殼鐵律(rule 07):本檔是 **proxy**,純轉發到 KBDB 基本盤 HTTP API, * 無業務邏輯、不寫 SQL、不建表、不直連 D1。能力真身在 KBDB 基本盤(kbdb/src/routes/*)。 * * KBDB 鐵律(leo 2026-06-14):只暴露 template/record/query/search,**不開建表/SQL**。 * * 租戶隔離(leo 2026-06-14 拍板,選項①): * - X-Arcrun-API-Key(namespace/api_key)→ 自動當 owner_id 注入 records/entries 的寫入與查詢。 * 不同 namespace 的資料互相看不到。與 cypher 其他端點同身份模型。 * - **templates 全域共享**(虛擬表定義是 schema 不是資料;類 Supabase 的表結構大家共用)→ 不注入 owner_id。 * * cypher→KBDB 連法沿用既有慣例(webhook-handlers.ts / recipes.ts): * KBDB_BASE_URL HTTP fetch + 選用 KBDB_INTERNAL_TOKEN Bearer。**不新增 service binding**(rule 02 §3.1)。 */ import { Hono } from 'hono'; import type { Bindings } from '../types'; export const kbdbProxyRouter = new Hono<{ Bindings: Bindings }>(); /** * KBDB 基本盤 base URL + internal headers。 * fallback 指**現役** arcrun-kbdb(workers.dev,無 auth、不需 token)—— * 不沿用 webhook-handlers.ts 的舊 fallback kbdb.finally.click(inkstone 遺留、已死、要 token)。 * KBDB_BASE_URL 可覆蓋(self-hosted fork 指自己的 KBDB)。 */ export function kbdbBase(env: Bindings): { base: string; headers: Record } { const base = (env.KBDB_BASE_URL ?? 'https://arcrun-kbdb.uncle6-me.workers.dev').replace(/\/$/, ''); const headers: Record = { 'Content-Type': 'application/json' }; if (env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${env.KBDB_INTERNAL_TOKEN}`; return { base, headers }; } /** 取租戶身份(owner_id)。缺 header → 401(與 cypher 其他資料端點一致)。 */ function tenant(c: { req: { header: (k: string) => string | undefined } }): string | null { return c.req.header('X-Arcrun-API-Key') ?? null; } const NEED_KEY = { error: '缺少 X-Arcrun-API-Key header' } as const; // ── templates(全域共享,不注入 owner_id)────────────────────────────────────── // POST /kbdb/templates — 建 template(name + slots)。鐵律:這是「虛擬表定義」非建真表。 kbdbProxyRouter.post('/kbdb/templates', async (c) => { const owner = tenant(c); if (!owner) return c.json(NEED_KEY, 401); const body = await c.req.json().catch(() => null); if (!body || !body.name || !Array.isArray(body.slots)) { return c.json({ error: 'name 與 slots[] 必填' }, 400); } const { base, headers } = kbdbBase(c.env); const res = await fetch(`${base}/templates`, { method: 'POST', headers, // created_by 帶上租戶當溯源,但 template 本身全域可見可用 body: JSON.stringify({ name: body.name, slots: body.slots, description: body.description, created_by: owner }), }); return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); }); // GET /kbdb/templates — 列出所有 template(全域)。 kbdbProxyRouter.get('/kbdb/templates', async (c) => { if (!tenant(c)) return c.json(NEED_KEY, 401); const { base, headers } = kbdbBase(c.env); const res = await fetch(`${base}/templates`, { headers }); return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); }); // GET /kbdb/templates/:idOrName — 取單一 template。 kbdbProxyRouter.get('/kbdb/templates/:idOrName', async (c) => { if (!tenant(c)) return c.json(NEED_KEY, 401); const { base, headers } = kbdbBase(c.env); const res = await fetch(`${base}/templates/${encodeURIComponent(c.req.param('idOrName'))}`, { headers }); return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); }); // ── records(以租戶 namespace 為 owner_id 隔離)──────────────────────────────── // POST /kbdb/records — 填一筆 record(template + values)。owner_id 自動注入。 kbdbProxyRouter.post('/kbdb/records', async (c) => { const owner = tenant(c); if (!owner) return c.json(NEED_KEY, 401); const body = await c.req.json().catch(() => null); if (!body || !body.template || !body.values) { return c.json({ error: 'template 與 values 必填' }, 400); } const { base, headers } = kbdbBase(c.env); const res = await fetch(`${base}/records`, { method: 'POST', headers, // 強制以租戶身份隔離:忽略 caller 自帶 owner_id,一律用 header 身份(防跨租戶寫入) body: JSON.stringify({ template: body.template, values: body.values, owner_id: owner }), }); return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); }); // GET /kbdb/records/by-template/:template — 列某 template 下「本租戶」的 records。 kbdbProxyRouter.get('/kbdb/records/by-template/:template', async (c) => { const owner = tenant(c); if (!owner) return c.json(NEED_KEY, 401); const { base, headers } = kbdbBase(c.env); const res = await fetch( `${base}/records/by-template/${encodeURIComponent(c.req.param('template'))}?owner_id=${encodeURIComponent(owner)}`, { headers }, ); return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); }); // GET /kbdb/records/:recordId — 取單筆 record。 kbdbProxyRouter.get('/kbdb/records/:recordId', async (c) => { if (!tenant(c)) return c.json(NEED_KEY, 401); const { base, headers } = kbdbBase(c.env); const res = await fetch(`${base}/records/${encodeURIComponent(c.req.param('recordId'))}`, { headers }); return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); }); // ── search(限本租戶範圍內)──────────────────────────────────────────────────── // GET /kbdb/search?q=&entry_type=&source=&library=&mode= — entries 搜尋,限本租戶 owner_id。 // 透傳 entry_type(base 通用 filter,workflow-discovery Q4)/ source / library(多值逗號分隔, // portal-auth P1 順延項——owner/admin 面自選庫過濾;portal 一般用戶不經這,走 /portal/data/* // 的 server 注入)/ mode 給 KBDB /entries/search。 kbdbProxyRouter.get('/kbdb/search', async (c) => { const owner = tenant(c); if (!owner) return c.json(NEED_KEY, 401); const q = c.req.query('q'); if (!q) return c.json({ error: 'q 必填' }, 400); const { base, headers } = kbdbBase(c.env); const params = new URLSearchParams({ q, owner_id: owner }); for (const k of ['entry_type', 'source', 'library', 'mode']) { const v = c.req.query(k); if (v) params.set(k, v); } const res = await fetch(`${base}/entries/search?${params.toString()}`, { headers }); return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); }); // ── entries(原子資料 / 樹節點,以租戶 namespace 為 owner_id 隔離)───────────────── // // kbdb-base 9.6:基本盤 /entries CRUD 的 proxy(HANDOFF §2 缺口①,mira _kbdb_client.py 遷移目標)。 // 租戶隔離同 records(選項①):寫入強制注入 owner_id、list 強制以本租戶 owner_id 過濾; // by-id 沿用既有 records by-id 慣例(require-key,不額外做 owner 比對——與本檔其他 by-id 端點一致)。 // POST /kbdb/entries — 建一個 entry(entry_type 必填,如 block/value/project/workflow)。owner_id 自動注入。 kbdbProxyRouter.post('/kbdb/entries', async (c) => { const owner = tenant(c); if (!owner) return c.json(NEED_KEY, 401); const body = await c.req.json().catch(() => null); if (!body || !body.entry_type) return c.json({ error: 'entry_type 必填' }, 400); const { base, headers } = kbdbBase(c.env); const res = await fetch(`${base}/entries`, { method: 'POST', headers, // 強制以租戶身份隔離:忽略 caller 自帶 owner_id,一律用 header 身份(防跨租戶寫入) body: JSON.stringify({ ...body, owner_id: owner }), }); return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); }); // GET /kbdb/entries — list(filters: entry_type / parent_id / page_name / source / library / q(search) / limit / offset)。 // owner_id 強制覆寫成本租戶(防跨租戶讀;caller 不能查別人的 owner_id)。 // Arcrun#3 發現①根因:本白名單原本沒有 q/search,caller 帶 search= 會被這裡靜默丟棄, // 打到 base 永遠是「無過濾 list」——不是 458K 筆搜不到,是這個 filter 從沒被轉發過。 // 修法:q 與 search 都收,統一轉發成 base 認得的 q(base 端見 entries.ts 同步修)。 kbdbProxyRouter.get('/kbdb/entries', async (c) => { const owner = tenant(c); if (!owner) return c.json(NEED_KEY, 401); const { base, headers } = kbdbBase(c.env); const params = new URLSearchParams(); params.set('owner_id', owner); // 強制本租戶,不接受 caller 覆寫 for (const k of ['entry_type', 'parent_id', 'page_name', 'source', 'library', 'limit', 'offset']) { const v = c.req.query(k); if (v) params.set(k, v); } const q = c.req.query('q') || c.req.query('search'); if (q) params.set('q', q); const res = await fetch(`${base}/entries?${params.toString()}`, { headers }); return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); }); // GET /kbdb/entries/:id — 取單筆 entry。 kbdbProxyRouter.get('/kbdb/entries/:id', async (c) => { if (!tenant(c)) return c.json(NEED_KEY, 401); const { base, headers } = kbdbBase(c.env); const res = await fetch(`${base}/entries/${encodeURIComponent(c.req.param('id'))}`, { headers }); return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); }); // ── graph(kbdb-graph-plugin proxy,Mira Console 卡片詳頁「關聯視圖」)─────────────── // // 純轉發(rule 07):圖能力真身在 kbdb-graph-plugin worker(GET /graph/neighbors/:name, // 回 { node, edges[], neighbors[], edgeCount, neighborCount })。瀏覽器不能持 KBDB_INTERNAL_TOKEN, // 故經 cypher 代轉(token 只在 server 側)。plugin base 現算慣例同 registry/KBDB_BASE_URL。 export function graphBase(env: Bindings): string { if (env.KBDB_GRAPH_URL) return env.KBDB_GRAPH_URL.replace(/\/$/, ''); return `https://kbdb-graph-plugin.${env.WORKER_SUBDOMAIN}.workers.dev`; } // GET /kbdb/graph/neighbors/:name — 查某節點(entity/卡片名)的鄰居 + 邊。 // 查無 triplet 資料時 plugin 回空陣列——前端據此顯示「尚無關聯資料」(誠實,不編造關聯)。 kbdbProxyRouter.get('/kbdb/graph/neighbors/:name', async (c) => { if (!tenant(c)) return c.json(NEED_KEY, 401); const base = graphBase(c.env); const headers: Record = {}; if (c.env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`; try { const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(c.req.param('name'))}`, { headers }); return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); } catch (e) { // plugin worker 沒部署 / 不可達 → 誠實回報(前端顯示「關聯服務未部署」而非假裝無關聯) return c.json({ error: `kbdb-graph-plugin 不可達(${base}):${e instanceof Error ? e.message : String(e)}` }, 502); } }); // PATCH /kbdb/entries/:id — 更新單筆 entry。owner_id 不可被改(剝除 caller 自帶的 owner_id)。 kbdbProxyRouter.patch('/kbdb/entries/:id', async (c) => { if (!tenant(c)) return c.json(NEED_KEY, 401); const body = await c.req.json().catch(() => ({})); // 不讓 patch 改 owner_id(防把別人的資料認領過來或踢給別人) const { owner_id: _drop, ...patch } = body ?? {}; const { base, headers } = kbdbBase(c.env); const res = await fetch(`${base}/entries/${encodeURIComponent(c.req.param('id'))}`, { method: 'PATCH', headers, body: JSON.stringify(patch), }); return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); });