feat(kbdb,mcp): KBDB 資料層薄殼 + self-hosted MCP 認證 + cypher KBDB proxy

三件一條鏈(HANDOFF §2/§3b,kbdb-base Phase 9):

A. KBDB MCP 薄殼(9.1):mcp/src/tools/kbdb_data.ts 6 工具
   template/record/query/search,調基本盤 API。鐵律:不給建表/SQL,只 template+slot。

B. MCP self-hosted 認證 401(mcp-account-source §5.5):
   - partner-auth.ts:MULTI_TENANT=false 時 Bearer 明碼直接當 org_namespace,
     繞 KBDB partner 驗證(對齊 cypher 的 opaque-key 模型)。官方 SaaS 行為不變、共用同碼。
   - mcp-setup.ts:把 namespace/api_key 寫進 .mcp.json headers.Authorization。
   - 新增 self-hosted vs SaaS 分支單測(9 tests 綠)。

C. cypher KBDB proxy(9.5)+ CLI 薄殼(9.2):
   - routes/kbdb-proxy.ts 純轉發 /kbdb/* → KBDB 基本盤(KBDB_BASE_URL HTTP fetch,
     不新增 service binding)。讓 CLI(只認證到 cypher)能達獨立 KBDB worker。
   - 租戶隔離:X-Arcrun-API-Key 自動當 owner_id 注入 records/entries(強制覆寫防跨租戶);
     templates 全域共享(虛擬表定義是 schema 非資料)。
   - cli/src/commands/kbdb.ts:acr kbdb template/record/query/search,與 MCP kbdb_* 同能力。
   - kbdb base:entries 加 page_name 過濾(9.3)。

cypher + cli + mcp tsc exit 0。未驗收:端到端需 deploy + KBDB_BASE_URL 可達後實測。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-06-14 22:12:32 +08:00
parent b9bf3ec3d5
commit 886a8e31d0
13 changed files with 582 additions and 2 deletions
+2
View File
@@ -20,6 +20,7 @@ import { authRouter } from './routes/auth';
import { resumeRouter } from './routes/resume';
import { executionsRouter } from './routes/executions';
import { initSeedRouter } from './routes/init-seed';
import { kbdbProxyRouter } from './routes/kbdb-proxy';
const app = new Hono<{ Bindings: Bindings }>();
@@ -48,6 +49,7 @@ app.route('/', authRouter);
app.route('/', resumeRouter);
app.route('/', executionsRouter); // LI SDD M2.1: /executions/* + /workflows/:name/executions
app.route('/', initSeedRouter); // 薄殼原則:seed recipe 是 API 行為(rule 07,壓測 §4.1
app.route('/', kbdbProxyRouter); // kbdb-base 9.5KBDB 資料層 proxy(讓 CLI 透過 cypher 達 KBDB,純轉發)
// Worker 導出(fetch + scheduled
// scheduled handler 對應 wrangler.toml [triggers].crons,每分鐘 tick
+131
View File
@@ -0,0 +1,131 @@
/**
* 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(沿用 webhook-handlers.ts 慣例)。 */
function kbdbBase(env: Bindings): { base: string; headers: Record<string, string> } {
const base = (env.KBDB_BASE_URL ?? 'https://kbdb.finally.click').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= — entries LIKE 關鍵字搜尋,限本租戶 owner_id。
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 res = await fetch(
`${base}/entries/search?q=${encodeURIComponent(q)}&owner_id=${encodeURIComponent(owner)}`,
{ headers },
);
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
});