5668d48d37
- 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>
235 lines
13 KiB
TypeScript
235 lines
13 KiB
TypeScript
/**
|
||
* 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<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 — 建 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<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' } });
|
||
});
|