Files
Arcrun/cypher-executor/src/routes/kbdb-proxy.ts
T
uncle6me-web 5668d48d37 portal-auth P3 前置:graph 粗閘 helpers(D-4)+session 能力欄位+kbdb-proxy 透傳 library(P1 順延)
- portal.ts:export 共用 helpers(requirePortalUser/parseLibraries/kbdbFetch/run…)
  +graphSourceLibraries/hasGraphAccess/workflowsVisible;/portal/session 補
  graph_allowed、workflows_visible(顯示提示,真閘在 /portal/data/* 路由層)
- portal_library seed 加 graph_source slot;ensurePortalTemplates 冪等補舊 template
  缺的 slots(updateRecord 對 template 外 slot 會 reject,不補就寫不進標記)
- admin libraries PATCH 收 graph_source(boolean);toPublicLibrary 回 graph_source
- kbdb-proxy /kbdb/search 與 /kbdb/entries 白名單加 library(tasks.md P1 順延一行)
- PORTAL_SHOW_WORKFLOWS var(D-8,預設 admin)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 12:59:41 +08:00

235 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* KBDB 資料層 proxykbdb-base Phase 9.5HANDOFF §2 + §3b 後續)
*
* 為什麼存在:CLI 是 client,只認證到 cypher-executorX-Arcrun-API-Key),達不到獨立的
* KBDB workerMCP 走內部 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-Keynamespace/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-kbdbworkers.dev,無 auth、不需 token)——
* 不沿用 webhook-handlers.ts 的舊 fallback kbdb.finally.clickinkstone 遺留、已死、要 token)。
* KBDB_BASE_URL 可覆蓋(self-hosted fork 指自己的 KBDB)。
*/
export function kbdbBase(env: Bindings): { base: string; headers: Record<string, string> } {
const base = (env.KBDB_BASE_URL ?? 'https://arcrun-kbdb.uncle6-me.workers.dev').replace(/\/$/, '');
const headers: Record<string, string> = { '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 — 建 templatename + 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 — 填一筆 recordtemplate + 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_typebase 通用 filterworkflow-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 的 proxyHANDOFF §2 缺口①,mira _kbdb_client.py 遷移目標)。
// 租戶隔離同 records(選項①):寫入強制注入 owner_id、list 強制以本租戶 owner_id 過濾;
// by-id 沿用既有 records by-id 慣例(require-key,不額外做 owner 比對——與本檔其他 by-id 端點一致)。
// POST /kbdb/entries — 建一個 entryentry_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 — listfilters: entry_type / parent_id / page_name / source / library / q(search) / limit / offset)。
// owner_id 強制覆寫成本租戶(防跨租戶讀;caller 不能查別人的 owner_id)。
// Arcrun#3 發現①根因:本白名單原本沒有 q/searchcaller 帶 search= 會被這裡靜默丟棄,
// 打到 base 永遠是「無過濾 list」——不是 458K 筆搜不到,是這個 filter 從沒被轉發過。
// 修法:q 與 search 都收,統一轉發成 base 認得的 qbase 端見 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' } });
});
// ── graphkbdb-graph-plugin proxyMira Console 卡片詳頁「關聯視圖」)───────────────
//
// 純轉發(rule 07):圖能力真身在 kbdb-graph-plugin workerGET /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<string, string> = {};
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' } });
});