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:
@@ -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.5:KBDB 資料層 proxy(讓 CLI 透過 cypher 達 KBDB,純轉發)
|
||||
|
||||
// Worker 導出(fetch + scheduled)
|
||||
// scheduled handler 對應 wrangler.toml [triggers].crons,每分鐘 tick;
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 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(沿用 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 — 建 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= — 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' } });
|
||||
});
|
||||
Reference in New Issue
Block a user