a5e4caf5cb
leo 2026-08-12 打開總圖看到:「0 個實體 · 0 條關聯/知識庫還沒有任何關聯—— 上傳文件後 AI 會自動織網」。**而他庫裡有 1854 條三元組。** 那句話會叫他去做一件不需要做的事。 三個獨立的洞疊起來才變成那句謊: ① console-dashboard.ts 打 kbdb-graph-plugin 沒帶認證(同段落打 kbdb 的兩支都有帶) ② 那顆 worker 不在更新的部署清單裡 ⇒ token 一換它就落單 ③ **畫面把 null 畫成 0**——後端已經誠實回 null 了,是前端把它變成謊話 為什麼一直沒被發現:graph plugin 原本身上沒有 token ⇒ 門開著 ⇒ 沒帶也進得去。 2026-08-12 輪替後它有了 token,門關上,401 才浮出來。 📍 repo:matrix/arcrun(cypher-executor/src/routes/、console-ui/public/) 📍 票:Leo/Arcrun#100
308 lines
17 KiB
TypeScript
308 lines
17 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' } });
|
||
});
|
||
|
||
// PATCH /kbdb/records/:recordId — 翻某筆 record 的 slot 值({ values:{slot:content} })。
|
||
// 補上基本盤既有能力(kbdb/src/routes/records.ts 的 PATCH /records/:recordId,mira-dissolve T2.1)
|
||
// 缺的對外通道——2026-08-11 leo 三元組 library 補標核實:base 早有這個端點,但這條 proxy
|
||
// 之前只轉發 GET/POST,插件/工作流打不到,補標三元組只能繞去改表(違 D38)。單純轉發,無業務邏輯。
|
||
// by-id 沿用既有慣例(require-key,不額外做 owner 比對——與本檔 GET .../:recordId、
|
||
// PATCH /kbdb/entries/:id 同款)。
|
||
kbdbProxyRouter.patch('/kbdb/records/:recordId', async (c) => {
|
||
if (!tenant(c)) return c.json(NEED_KEY, 401);
|
||
const body = await c.req.json().catch(() => null);
|
||
if (!body || typeof body.values !== 'object' || body.values === null) {
|
||
return c.json({ error: 'values 必填({slot名: 內容})' }, 400);
|
||
}
|
||
const { base, headers } = kbdbBase(c.env);
|
||
const res = await fetch(`${base}/records/${encodeURIComponent(c.req.param('recordId'))}`, {
|
||
method: 'PATCH',
|
||
headers,
|
||
body: JSON.stringify({ values: body.values }),
|
||
});
|
||
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`;
|
||
}
|
||
|
||
/**
|
||
* kbdb-graph-plugin 的 internal headers。**打 plugin 一律用這支,不要各自手拼**(Arcrun#100)。
|
||
*
|
||
* plugin 端(kbdb-graph-plugin/src/index.ts)對 `/triplets` `/graph` `/search` `/entities`
|
||
* 四個前綴掛了 Bearer 閘:設了 KBDB_INTERNAL_TOKEN 就必須帶,否則一律 401。
|
||
* 原本三處手拼(本檔 neighbors、portal-data neighbors、console-dashboard 兩支 stats),
|
||
* 前兩處帶了、後兩處漏了 → `/triplets/stats` 永遠 401 → 前端「三元組 0」。
|
||
* 收斂成一支函式=新的呼叫點不可能再漏(漂移的根,不是那兩行本身)。
|
||
*/
|
||
export function graphHeaders(env: Bindings): Record<string, string> {
|
||
const headers: Record<string, string> = {};
|
||
if (env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${env.KBDB_INTERNAL_TOKEN}`;
|
||
return headers;
|
||
}
|
||
|
||
// 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 = graphHeaders(c.env);
|
||
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);
|
||
}
|
||
});
|
||
|
||
// ── map(藏書地圖,library-map SDD M5/R4:console 首頁全館地圖)──────────────────
|
||
//
|
||
// 純轉發(rule 07):聚合真身在 KBDB 基本盤(kbdb/src/routes/map.ts,M2 已 merge)。
|
||
// owner_id **不強制注入、只選擇性透傳**——與 MCP kbdb_get_map(M4)同義,理由兩層:
|
||
// 1. map block 是庫級聚合摘要(narrative+top entities+規模),非租戶私資料;
|
||
// 2. 現行 backfill recompute 未帶 owner(map block owner_id=NULL)——若比照 search
|
||
// 強制注入租戶,`e.owner_id = ?` 會把 NULL 列全濾掉 → 首頁永遠假空狀態(不誠實)。
|
||
// 仍要求 X-Arcrun-API-Key(與本檔其他端點同閘;console 登入後才有 tenant 字串)。
|
||
|
||
// GET /kbdb/map — 全館地圖(每庫一行:library+narrative+top 3 entities+triplet_count+updated_at)。
|
||
kbdbProxyRouter.get('/kbdb/map', async (c) => {
|
||
if (!tenant(c)) return c.json(NEED_KEY, 401);
|
||
const { base, headers } = kbdbBase(c.env);
|
||
const owner = c.req.query('owner_id');
|
||
const qs = owner ? `?owner_id=${encodeURIComponent(owner)}` : '';
|
||
try {
|
||
const res = await fetch(`${base}/map${qs}`, { headers });
|
||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||
} catch (e) {
|
||
// KBDB 不可達 → 誠實回報(前端顯示「地圖服務不可達」,不擋首頁其他功能)
|
||
return c.json({ success: false, error: `KBDB 不可達(${base}):${e instanceof Error ? e.message : String(e)}` }, 502);
|
||
}
|
||
});
|
||
|
||
// GET /kbdb/map/:library — 該庫詳圖(完整 top_entities 帶 degree/relation_profile/bridges/commit_hash)。
|
||
kbdbProxyRouter.get('/kbdb/map/:library', async (c) => {
|
||
if (!tenant(c)) return c.json(NEED_KEY, 401);
|
||
const { base, headers } = kbdbBase(c.env);
|
||
const owner = c.req.query('owner_id');
|
||
const qs = owner ? `?owner_id=${encodeURIComponent(owner)}` : '';
|
||
try {
|
||
const res = await fetch(`${base}/map/${encodeURIComponent(c.req.param('library'))}${qs}`, { headers });
|
||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||
} catch (e) {
|
||
return c.json({ success: false, error: `KBDB 不可達(${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' } });
|
||
});
|