Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d3973ecbc |
@@ -24,6 +24,8 @@ import { consoleAuthRouter } from './routes/console-auth';
|
||||
import { consoleDashboardRouter } from './routes/console-dashboard';
|
||||
import { portalRouter } from './routes/portal';
|
||||
import { portalDataRouter } from './routes/portal-data';
|
||||
import { storageRouter } from './routes/storage';
|
||||
import { withDurableStores } from './lib/durable-store';
|
||||
|
||||
const app = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -97,11 +99,19 @@ app.route('/', consoleAuthRouter); // Arcrun#3 發現②:console 專用簡單
|
||||
app.route('/', consoleDashboardRouter); // T-cockpit ②:駕駛艙 dashboard(聚合 KBDB dash_* entries,無需登入唯讀)
|
||||
app.route('/', portalRouter); // portal-auth P2(#24/#25):RAG Portal 多人授權——用戶模型+認證 API
|
||||
app.route('/', portalDataRouter); // portal-auth P3:/portal/data/* server-side enforce(owner_id+library 注入,安全核心)
|
||||
app.route('/', storageRouter); // KV 退休(#16/#17):資產遷移/盤點端點
|
||||
|
||||
// Worker 導出(fetch + scheduled)
|
||||
// scheduled handler 對應 wrangler.toml [triggers].crons,每分鐘 tick;
|
||||
// 邏輯在 src/scheduled.ts。對應 SDD: arcrun.md 三-A P1 #3。
|
||||
//
|
||||
// 🔴 KV 退休(Leo/Arcrun#16 + #17):WEBHOOKS / RECIPES 在這裡被換成 KBDB 撐腰的版本
|
||||
//(lib/durable-store.ts)。**這是唯一的接線點**——換在入口,四十幾處呼叫端一行不動,
|
||||
// 也就沒有「某一處忘了改」這種漏洞(那正是資產會不見的入口)。
|
||||
// 使用者的工作流與 recipe 從此住在 KBDB(D1,一份資產一列 entry),KV 只是快取:
|
||||
// KV 被換掉/重建之後,資料仍在,且會在下一次讀取時自己長回快取。
|
||||
export default {
|
||||
fetch: app.fetch,
|
||||
scheduled: handleScheduled,
|
||||
fetch: (req: Request, env: Bindings, ctx: ExecutionContext) => app.fetch(req, withDurableStores(env), ctx),
|
||||
scheduled: (event: ScheduledController, env: Bindings, ctx: ExecutionContext) =>
|
||||
handleScheduled(event, withDurableStores(env), ctx),
|
||||
} satisfies ExportedHandler<Bindings>;
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* asset-keys — 哪些 KV key 是「使用者的資產」,以及它在 KBDB 裡對應哪一列
|
||||
*
|
||||
* KV 退休(Leo/Arcrun#16 + #17)。leo 2026-08-12:
|
||||
* 「我要的是寫進 KBDB,不是 KV,他的 Recipes、Cypher 是一段話,文字,數據,一個 entry」
|
||||
* 「如果零件和工作流的 recipe 不見了,是很可怕的事情」
|
||||
*
|
||||
* ── 這支檔在整件事裡的位置 ────────────────────────────────────────────────
|
||||
* 真正的修法只有一句:**資產的家在 KBDB,KV 降級成可丟棄的快取**。
|
||||
* 但 KV 的呼叫端有四十幾處(webhooks-named / portal / executions / component-loader /
|
||||
* auth-dispatcher / wasi-shim …),逐處改寫既冗長又容易漏一處——漏掉的那處就是下一次
|
||||
* 「東西不見了」的入口。所以改法是換掉 binding 本身(見 durable-store.ts),
|
||||
* 而這支檔就是那層唯一需要人看懂的東西:**一張表,說清楚哪些 key 是資產、對應哪一列。**
|
||||
*
|
||||
* ── 判準:資產 vs 衍生 ────────────────────────────────────────────────────
|
||||
* 資產 = 弄丟了就再也回不來的東西(使用者/AI 寫出來的工作流、recipe)。→ 進 KBDB。
|
||||
* 衍生 = 從資產算得出來的東西(反查索引 idx:*、cron 索引、快取、session、
|
||||
* 帶 TTL 的暫存)。→ 留在 KV,弄丟了自己重建(durable-store 負責重建)。
|
||||
*
|
||||
* 判斷不確定時一律歸「衍生」——把衍生誤存進 KBDB 只是多幾列垃圾,
|
||||
* 把資產誤判成衍生才是把人家的東西弄丟。
|
||||
*
|
||||
* ── 為什麼不做成「KV key 原樣鏡射進 KBDB」 ────────────────────────────────
|
||||
* 那樣 KBDB 會變成第二顆 KV,leo 要的「一個 entry」就不成立:搜不到、看不懂、
|
||||
* 對 portal/console 也沒有意義。所以這裡把每個 key 解析成**有語意的一列**
|
||||
* (entry_type + owner_id + page_name + 一句描述),KBDB 端才真的是「他的資產」。
|
||||
*/
|
||||
|
||||
/** KBDB 裡 arcrun 資產的 entry_type(每個都有一列 template 定義,見 kbdb/migrations/0005)。 */
|
||||
export type AssetEntryType = 'workflow_def' | 'api_recipe' | 'auth_recipe' | 'prompt_recipe';
|
||||
|
||||
export interface AssetRef {
|
||||
entry_type: AssetEntryType;
|
||||
/** KBDB entries.id——由 key 決定,同一份資產永遠同一列(冪等,重跑遷移不長重複)。 */
|
||||
entry_id: string;
|
||||
/** 租戶。recipe 是整台實例共用的庫,故為 null(與現行 RECIPES KV 無租戶前綴一致)。 */
|
||||
owner_id: string | null;
|
||||
/** 該型別的自然鍵(workflow 名 / recipe uuid / service 名),對應 entries.page_name。 */
|
||||
page_name: string;
|
||||
/** 原本的 KV key——反向重建快取時要用(KBDB → KV 回填)。 */
|
||||
kv_key: string;
|
||||
}
|
||||
|
||||
/** entries.id 的前綴,跟別人的資料分得開,也讓 `arcrun:` 一眼看得出是誰的列。 */
|
||||
const ID_PREFIX = 'arcrun';
|
||||
|
||||
/**
|
||||
* 把一個 KV key 解析成 KBDB 的一列;不是資產就回 null(呼叫端原樣走 KV)。
|
||||
*
|
||||
* ⚠️ 這是**唯一**決定「什麼進 KBDB」的地方。要新增一類資產就加在這裡,
|
||||
* 不要在別處另開一條偷偷寫 KBDB 的路——兩套並存必然漂移(2026-08-08 credential 的教訓)。
|
||||
*/
|
||||
export function classifyAssetKey(key: string): AssetRef | null {
|
||||
// ── 明確排除的衍生資料(放最前面,免得被下面的樣式誤收)────────────────
|
||||
// idx:* recipe/component 反查索引(canonical→uuid、hash→canonical)
|
||||
// cron-idx:* cron 排程索引(8.P0 的單一 key)
|
||||
// 兩者都能從資產重算,見 durable-store.ts 的 rehydrate*。
|
||||
if (key.startsWith('idx:') || key.startsWith('cron-idx:')) return null;
|
||||
|
||||
// auth_recipe:{service} — 「怎麼認證」的定義。注意只有定義,沒有任何密文
|
||||
//(憑證明文在 CF Workers Secrets,見 .claude/rules/01-tech-stack.md「Credential 儲存規範」)。
|
||||
if (key.startsWith('auth_recipe:')) {
|
||||
const service = key.slice('auth_recipe:'.length);
|
||||
if (!service) return null;
|
||||
return {
|
||||
entry_type: 'auth_recipe',
|
||||
entry_id: `${ID_PREFIX}:auth_recipe:${service}`,
|
||||
owner_id: null,
|
||||
page_name: service,
|
||||
kv_key: key,
|
||||
};
|
||||
}
|
||||
|
||||
// prompt_recipe:{name}
|
||||
if (key.startsWith('prompt_recipe:')) {
|
||||
const name = key.slice('prompt_recipe:'.length);
|
||||
if (!name) return null;
|
||||
return {
|
||||
entry_type: 'prompt_recipe',
|
||||
entry_id: `${ID_PREFIX}:prompt_recipe:${name}`,
|
||||
owner_id: null,
|
||||
page_name: name,
|
||||
kv_key: key,
|
||||
};
|
||||
}
|
||||
|
||||
// recipe:{uuid}|recipe:{canonical_id}(migration 前的舊 key,仍是資產,一樣要保住)
|
||||
if (key.startsWith('recipe:')) {
|
||||
const id = key.slice('recipe:'.length);
|
||||
if (!id) return null;
|
||||
return {
|
||||
entry_type: 'api_recipe',
|
||||
entry_id: `${ID_PREFIX}:recipe:${id}`,
|
||||
owner_id: null,
|
||||
page_name: id,
|
||||
kv_key: key,
|
||||
};
|
||||
}
|
||||
|
||||
// {api_key}:wf:{name} — 具名工作流(acr push / portal 安裝器寫的那把)。
|
||||
// 用**第一個** ':wf:' 切:api_key 不含冒號,而 workflow 名允許的字元集
|
||||
//(webhooks-named.ts 驗 /^[\w-]+$/)本來就不含冒號,所以切點唯一。
|
||||
const wfAt = key.indexOf(':wf:');
|
||||
if (wfAt > 0) {
|
||||
const owner = key.slice(0, wfAt);
|
||||
const name = key.slice(wfAt + ':wf:'.length);
|
||||
if (!owner || !name) return null;
|
||||
return {
|
||||
entry_type: 'workflow_def',
|
||||
entry_id: `${ID_PREFIX}:wf:${owner}:${name}`,
|
||||
owner_id: owner,
|
||||
page_name: name,
|
||||
kv_key: key,
|
||||
};
|
||||
}
|
||||
|
||||
// 其餘一律衍生/暫存:匿名 webhook token、daemon-active、session、stats… 留在 KV。
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 從 KBDB 的一列反推回原本的 KV key(快取回填、list 都要用)。 */
|
||||
export function assetKvKey(entryType: AssetEntryType, ownerId: string | null, pageName: string): string {
|
||||
switch (entryType) {
|
||||
case 'workflow_def':
|
||||
return `${ownerId ?? ''}:wf:${pageName}`;
|
||||
case 'api_recipe':
|
||||
return `recipe:${pageName}`;
|
||||
case 'auth_recipe':
|
||||
return `auth_recipe:${pageName}`;
|
||||
case 'prompt_recipe':
|
||||
return `prompt_recipe:${pageName}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一個 KV list 的 prefix 該去 KBDB 撈哪一類資產。
|
||||
*
|
||||
* 為什麼 list 一定要走 KBDB(不能像 get 那樣先問快取):被換掉的那顆 KV 是**空的**,
|
||||
* 空 KV list 出來是「零筆」而不是「查不到」——這正是 2026-08-12 那天畫面上
|
||||
* 「九支工作流全部消失」的形狀。get 可以 KV 先行(miss 再回源),list 不行。
|
||||
*
|
||||
* 回 null = 這個 prefix 不是資產類(例如 cron-idx:),照舊走 KV。
|
||||
*/
|
||||
export function classifyListPrefix(prefix: string | undefined): { entry_type: AssetEntryType; owner_id?: string } | null {
|
||||
if (!prefix) return null; // 無 prefix 的全域 list(webhooks-list)維持原行為
|
||||
if (prefix.startsWith('idx:') || prefix.startsWith('cron-idx:')) return null;
|
||||
if (prefix === 'auth_recipe:') return { entry_type: 'auth_recipe' };
|
||||
if (prefix === 'prompt_recipe:') return { entry_type: 'prompt_recipe' };
|
||||
if (prefix === 'recipe:') return { entry_type: 'api_recipe' };
|
||||
// `{api_key}:wf:` — 列出某租戶的所有工作流(webhooks-named / portal-data 都用這個)
|
||||
if (prefix.endsWith(':wf:')) {
|
||||
const owner = prefix.slice(0, -':wf:'.length);
|
||||
if (owner) return { entry_type: 'workflow_def', owner_id: owner };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
/**
|
||||
* durable-store — 讓 KV 變成快取,資產的家搬到 KBDB
|
||||
*
|
||||
* KV 退休(Leo/Arcrun#16 + #17)。leo 2026-08-12:
|
||||
* 「因為對 KV 的使用有禁令,但卻會把資產放在這裡,這不是違法嗎?」
|
||||
* 「現在是我幫他寫工作流,未來是他的 AI 自己寫工作流,
|
||||
* 如果零件和工作流的 recipe 不見了,是很可怕的事情」
|
||||
* 同一天實害:一次例行更新讓使用者的九支工作流在畫面上全部消失
|
||||
*(根因見 cli/src/lib/resource-resolver.ts 檔頭 Arcrun#97)。
|
||||
*
|
||||
* ── 一句話 ────────────────────────────────────────────────────────────────
|
||||
* **資產寫進 KBDB(D1,一份資產一列 entry);KV 退成可丟棄的快取。**
|
||||
* 換掉/重建 KV 之後,資料仍在 KBDB,而且會在下一次讀取時自己長回快取裡。
|
||||
*
|
||||
* ── 為什麼是換掉 binding,不是改四十幾處呼叫端 ────────────────────────────
|
||||
* WEBHOOKS / RECIPES 的呼叫端散在 webhooks-named、portal、executions、component-loader、
|
||||
* auth-dispatcher、wasi-shim…… 逐處改寫既冗長又一定會漏,而**漏掉的那一處就是下一次
|
||||
* 「東西不見了」的入口**。所以在 worker 入口把 binding 換成本檔的包裝,呼叫端一行不動——
|
||||
* 「哪些 key 是資產」則集中在 asset-keys.ts 那一張表裡,是這件事唯一需要人看懂的東西。
|
||||
*
|
||||
* ── 三條行為規則 ──────────────────────────────────────────────────────────
|
||||
* 1. **讀**:先問 KV(快)→ 沒有就回源 KBDB → 順手把快取補回去(空 KV 自己痊癒)。
|
||||
* 2. **寫**:先寫 KBDB(真相),成功了才寫 KV 快取。
|
||||
* KBDB 寫失敗 → **拋錯**,不假裝部署成功——「看起來成功、其實沒存到」正是這張票的病。
|
||||
* 3. **列舉**:一律走 KBDB,**不准問 KV**。被換掉的那顆 KV 是空的,
|
||||
* 空 KV 列出來是「零筆」而不是「查不到」——那正是「九支工作流全部消失」的形狀。
|
||||
*
|
||||
* ── 衍生資料(idx:* / cron-idx:*)怎麼辦 ──────────────────────────────────
|
||||
* 它們算得出來,所以不進 KBDB(KBDB 不該長出垃圾列),改成**讀不到就重算**。
|
||||
* 見 rehydrateRecipeIndices / rehydrateCronIndex。
|
||||
*/
|
||||
|
||||
import { classifyAssetKey, classifyListPrefix, assetKvKey, type AssetRef, type AssetEntryType } from './asset-keys';
|
||||
import { CRON_INDEX_KEY, cronEntryKey, type CronIndex } from './cron-index';
|
||||
|
||||
export interface KbdbEnv {
|
||||
KBDB_BASE_URL?: string;
|
||||
KBDB_INTERNAL_TOKEN?: string;
|
||||
}
|
||||
|
||||
/** KBDB 位址與 token 的取法沿用既有慣例(lib/workflow-search.ts、routes/webhooks-named.ts 同款)。 */
|
||||
function kbdbBase(env: KbdbEnv): string {
|
||||
return (env.KBDB_BASE_URL ?? 'https://arcrun-kbdb.uncle6-me.workers.dev').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function kbdbHeaders(env: KbdbEnv): Record<string, string> {
|
||||
const h: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (env.KBDB_INTERNAL_TOKEN) h['Authorization'] = `Bearer ${env.KBDB_INTERNAL_TOKEN}`;
|
||||
return h;
|
||||
}
|
||||
|
||||
/** KBDB 一列 entry 的回應形狀(只取本檔用得到的欄位)。 */
|
||||
interface KbdbEntry {
|
||||
id: string;
|
||||
content?: string | null;
|
||||
entry_type?: string | null;
|
||||
owner_id?: string | null;
|
||||
page_name?: string | null;
|
||||
metadata_json?: string | null;
|
||||
updated_at?: number;
|
||||
}
|
||||
|
||||
/** 資產寫進 metadata_json 的信封。definition = 原值 parse 過的物件;非 JSON 的原字串走 definition_raw。 */
|
||||
interface AssetEnvelope {
|
||||
arcrun_asset: true;
|
||||
kv_key: string;
|
||||
definition?: unknown;
|
||||
definition_raw?: string;
|
||||
/** api_recipe 專用:本部署目前安裝的是不是這一版(重建 idx:installed:* 用,見 rehydrateRecipeIndices)。 */
|
||||
installed?: boolean;
|
||||
}
|
||||
|
||||
export class KbdbUnavailableError extends Error {
|
||||
constructor(op: string, detail: string) {
|
||||
super(
|
||||
`資產無法寫入 KBDB(${op}):${detail}。` +
|
||||
'本次操作已中止且未寫入任何一邊——這是刻意的:寧可讓你現在看到失敗,' +
|
||||
'也不要寫進只會被下次更新換掉的 KV、事後才發現東西不見了(Leo/Arcrun#16、#17)。',
|
||||
);
|
||||
this.name = 'KbdbUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
/** 從資產定義裡挑一句「給人看也給搜尋看」的描述,當 entries.content。 */
|
||||
function assetContent(type: AssetEntryType, def: unknown, pageName: string): string {
|
||||
const d = (def ?? {}) as Record<string, unknown>;
|
||||
const pick = (...keys: string[]): string => {
|
||||
for (const k of keys) {
|
||||
const v = d[k];
|
||||
if (typeof v === 'string' && v.trim()) return v.trim();
|
||||
}
|
||||
return '';
|
||||
};
|
||||
switch (type) {
|
||||
case 'workflow_def':
|
||||
return pick('description') || pageName;
|
||||
case 'api_recipe':
|
||||
return pick('description', 'display_name', 'canonical_id') || pageName;
|
||||
case 'auth_recipe':
|
||||
return pick('description', 'display_name', 'service') || pageName;
|
||||
case 'prompt_recipe':
|
||||
return pick('description', 'name') || pageName;
|
||||
}
|
||||
}
|
||||
|
||||
/** 把 KBDB 一列還原成原本的 KV 值(字串)。不是資產信封(或壞掉)→ null,誠實當作沒有。 */
|
||||
function entryToKvValue(entry: KbdbEntry | null | undefined): string | null {
|
||||
if (!entry?.metadata_json) return null;
|
||||
try {
|
||||
const env = JSON.parse(entry.metadata_json) as AssetEnvelope;
|
||||
if (!env || env.arcrun_asset !== true) return null;
|
||||
if (typeof env.definition_raw === 'string') return env.definition_raw;
|
||||
if (env.definition === undefined) return null;
|
||||
return JSON.stringify(env.definition);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* KV binding 的替身:介面與 KVNamespace 同形,行為見檔頭三條規則。
|
||||
* 一個 request 建一個實例(rehydrate 的去重旗標是 per-instance 的)。
|
||||
*/
|
||||
export class DurableKv {
|
||||
private rehydratedRecipeIdx = false;
|
||||
private rehydratedCronIdx = false;
|
||||
|
||||
constructor(
|
||||
private readonly kv: KVNamespace,
|
||||
private readonly env: KbdbEnv,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 拿回底層那顆真正的 KV。**只有遷移/盤點會用到**(routes/storage.ts):
|
||||
* 那兩支的工作正是「比較 KV 那邊有什麼、KBDB 這邊有什麼」,
|
||||
* 若透過包裝去問,list 會被導去 KBDB,就永遠比不出差異、也搬不動舊資料。
|
||||
* 一般業務程式碼不該碰這支——碰了就等於繞過本卷的全部保護。
|
||||
*/
|
||||
get rawKv(): KVNamespace {
|
||||
return this.kv;
|
||||
}
|
||||
|
||||
// ── KBDB 存取原語 ──────────────────────────────────────────────────────
|
||||
|
||||
private async kbdbGetEntry(entryId: string): Promise<KbdbEntry | null> {
|
||||
const res = await fetch(`${kbdbBase(this.env)}/entries/${encodeURIComponent(entryId)}`, {
|
||||
headers: kbdbHeaders(this.env),
|
||||
});
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) return null; // 讀不到就當沒有;快取仍可能有值,不炸讀取路徑
|
||||
const json = (await res.json().catch(() => null)) as { entry?: KbdbEntry } | null;
|
||||
return json?.entry ?? null;
|
||||
}
|
||||
|
||||
private async kbdbListEntries(entryType: AssetEntryType, ownerId?: string): Promise<KbdbEntry[]> {
|
||||
const params = new URLSearchParams({ entry_type: entryType, limit: '1000' });
|
||||
if (ownerId) params.set('owner_id', ownerId);
|
||||
const res = await fetch(`${kbdbBase(this.env)}/entries?${params.toString()}`, {
|
||||
headers: kbdbHeaders(this.env),
|
||||
});
|
||||
if (!res.ok) throw new KbdbUnavailableError('list', `HTTP ${res.status}`);
|
||||
const json = (await res.json().catch(() => null)) as { entries?: KbdbEntry[] } | null;
|
||||
return json?.entries ?? [];
|
||||
}
|
||||
|
||||
private async kbdbPutEntry(ref: AssetRef, envelope: AssetEnvelope): Promise<void> {
|
||||
const content = assetContent(ref.entry_type, envelope.definition, ref.page_name);
|
||||
const res = await fetch(`${kbdbBase(this.env)}/entries/${encodeURIComponent(ref.entry_id)}`, {
|
||||
method: 'PUT',
|
||||
headers: kbdbHeaders(this.env),
|
||||
body: JSON.stringify({
|
||||
entry_type: ref.entry_type,
|
||||
owner_id: ref.owner_id,
|
||||
page_name: ref.page_name,
|
||||
content,
|
||||
// 刻意**不**標 embed:true:工作流的語意搜尋走既有的 entry_type='workflow' 那一列
|
||||
//(workflow-discovery 方案 C 的雙寫),這裡標了會變成同一支工作流嵌兩份向量。
|
||||
metadata_json: JSON.stringify(envelope),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new KbdbUnavailableError('put', `HTTP ${res.status} @ ${ref.entry_id}`);
|
||||
}
|
||||
|
||||
private async kbdbDeleteEntry(entryId: string): Promise<void> {
|
||||
const res = await fetch(`${kbdbBase(this.env)}/entries/${encodeURIComponent(entryId)}`, {
|
||||
method: 'DELETE',
|
||||
headers: kbdbHeaders(this.env),
|
||||
});
|
||||
// 404 = 本來就沒有,對刪除而言是成功(冪等)。
|
||||
if (!res.ok && res.status !== 404) throw new KbdbUnavailableError('delete', `HTTP ${res.status} @ ${entryId}`);
|
||||
}
|
||||
|
||||
// ── 衍生索引重建(讀不到就重算,不進 KBDB)────────────────────────────
|
||||
|
||||
/**
|
||||
* 從 KBDB 的 api_recipe 列重建 recipe 反查索引:
|
||||
* idx:{hash_id} → canonical_id
|
||||
* idx:canonical:{canonical} → [uuid, ...]
|
||||
* idx:installed:{canonical} → uuid
|
||||
*
|
||||
* installed 的還原順序:先看資產自己標的 installed 旗標(正常路徑,寫入時就記下了,
|
||||
* 見 put() 對 `idx:installed:` 的處理);同一個 canonical 沒有任何一版標記時
|
||||
*(=遷移之前就存在的舊資料),退而取 updated_at 最新的那一版——因為
|
||||
* installRecipeRecord 的語意本來就是「最後寫入的那版即為安裝版」。
|
||||
* 這是還原不是猜測,但仍是**退路**,故在此寫明白。
|
||||
*/
|
||||
private async rehydrateRecipeIndices(): Promise<void> {
|
||||
if (this.rehydratedRecipeIdx) return;
|
||||
this.rehydratedRecipeIdx = true;
|
||||
|
||||
const entries = await this.kbdbListEntries('api_recipe');
|
||||
const byCanonical = new Map<string, Array<{ uuid: string; installed: boolean; updated_at: number }>>();
|
||||
const writes: Array<Promise<unknown>> = [];
|
||||
|
||||
for (const e of entries) {
|
||||
const raw = entryToKvValue(e);
|
||||
if (!raw) continue;
|
||||
let def: { uuid?: string; canonical_id?: string; hash_id?: string };
|
||||
try { def = JSON.parse(raw) as typeof def; } catch { continue; }
|
||||
if (!def.canonical_id) continue;
|
||||
|
||||
if (def.hash_id) writes.push(this.kv.put(`idx:${def.hash_id}`, def.canonical_id));
|
||||
if (!def.uuid) continue;
|
||||
|
||||
let installed = false;
|
||||
try {
|
||||
installed = (JSON.parse(e.metadata_json ?? '{}') as AssetEnvelope).installed === true;
|
||||
} catch { /* 壞信封 → 當作沒標記,走 updated_at 退路 */ }
|
||||
|
||||
const list = byCanonical.get(def.canonical_id) ?? [];
|
||||
list.push({ uuid: def.uuid, installed, updated_at: e.updated_at ?? 0 });
|
||||
byCanonical.set(def.canonical_id, list);
|
||||
}
|
||||
|
||||
for (const [canonical, versions] of byCanonical) {
|
||||
writes.push(this.kv.put(`idx:canonical:${canonical}`, JSON.stringify(versions.map((v) => v.uuid))));
|
||||
const chosen =
|
||||
versions.find((v) => v.installed) ??
|
||||
versions.reduce((a, b) => (b.updated_at > a.updated_at ? b : a));
|
||||
writes.push(this.kv.put(`idx:installed:${canonical}`, chosen.uuid));
|
||||
}
|
||||
await Promise.all(writes);
|
||||
}
|
||||
|
||||
/** 從 KBDB 的 workflow_def 列重建 cron 索引(單一 key,見 lib/cron-index.ts)。 */
|
||||
private async rehydrateCronIndex(): Promise<void> {
|
||||
if (this.rehydratedCronIdx) return;
|
||||
this.rehydratedCronIdx = true;
|
||||
|
||||
const entries = await this.kbdbListEntries('workflow_def');
|
||||
const index: CronIndex = {};
|
||||
for (const e of entries) {
|
||||
const raw = entryToKvValue(e);
|
||||
if (!raw) continue;
|
||||
let def: { cron_expr?: string };
|
||||
try { def = JSON.parse(raw) as typeof def; } catch { continue; }
|
||||
if (!def.cron_expr || !e.owner_id || !e.page_name) continue;
|
||||
index[cronEntryKey(e.owner_id, e.page_name)] = def.cron_expr;
|
||||
}
|
||||
// 即使是空的也要寫回去:寫了之後 get 就命中,下一分鐘的 tick 不會再重算一次
|
||||
//(不寫的話 scheduled() 每分鐘都會回源 KBDB 一趟,白花錢)。
|
||||
await this.kv.put(CRON_INDEX_KEY, JSON.stringify(index));
|
||||
}
|
||||
|
||||
// ── KVNamespace 介面 ───────────────────────────────────────────────────
|
||||
|
||||
async get(key: string, type?: 'text' | 'json' | 'arrayBuffer' | 'stream' | { type: string }): Promise<any> {
|
||||
// 二進位/串流形態本 worker 沒有呼叫端在用(資產都是 JSON 文字)。原樣轉發,
|
||||
// 不假裝支援——真有人開始用而拿不到 KBDB 回源,會在這裡被看見,不是靜默降級。
|
||||
const t0 = typeof type === 'string' ? type : type?.type;
|
||||
if (t0 === 'arrayBuffer' || t0 === 'stream') return this.kv.get(key, t0 as 'arrayBuffer');
|
||||
|
||||
const asText = (raw: string | null): unknown => {
|
||||
if (raw === null) return null;
|
||||
const t = typeof type === 'string' ? type : type?.type;
|
||||
if (t === 'json') {
|
||||
try { return JSON.parse(raw); } catch { return null; }
|
||||
}
|
||||
return raw;
|
||||
};
|
||||
|
||||
const ref = classifyAssetKey(key);
|
||||
if (!ref) {
|
||||
// 衍生/暫存:原樣走 KV。讀不到而且是「算得出來」的索引 → 重算一次再讀。
|
||||
const raw = await this.kv.get(key, 'text');
|
||||
if (raw !== null) return asText(raw);
|
||||
if (key.startsWith('idx:')) {
|
||||
await this.rehydrateRecipeIndices().catch(() => {});
|
||||
return asText(await this.kv.get(key, 'text'));
|
||||
}
|
||||
if (key === CRON_INDEX_KEY) {
|
||||
await this.rehydrateCronIndex().catch(() => {});
|
||||
return asText(await this.kv.get(key, 'text'));
|
||||
}
|
||||
return asText(raw);
|
||||
}
|
||||
|
||||
// 資產:快取優先,miss 回源 KBDB 並補快取(被換掉的空 KV 就是這樣自己痊癒的)。
|
||||
const cached = await this.kv.get(key, 'text');
|
||||
if (cached !== null) return asText(cached);
|
||||
|
||||
const fromKbdb = entryToKvValue(await this.kbdbGetEntry(ref.entry_id));
|
||||
if (fromKbdb === null) return asText(null);
|
||||
await this.kv.put(key, fromKbdb).catch(() => {}); // 補快取失敗不影響這次讀取
|
||||
return asText(fromKbdb);
|
||||
}
|
||||
|
||||
async put(key: string, value: string | ArrayBuffer | ReadableStream, options?: KVNamespacePutOptions): Promise<void> {
|
||||
// 帶 TTL=定義上就是暫存(daemon 回報、session…),不是資產,不進 KBDB。
|
||||
if (options?.expirationTtl || options?.expiration || typeof value !== 'string') {
|
||||
return this.kv.put(key, value as string, options);
|
||||
}
|
||||
|
||||
// idx:installed:{canonical} 不是資產,但它記的是**使用者的選擇**(這個 canonical 目前
|
||||
// 裝的是哪一版),純算不回來。所以把它記進對應那一版 recipe 資產的信封裡,
|
||||
// KBDB 端不會多出一列「指標 entry」,重建時又還原得精確(見 rehydrateRecipeIndices)。
|
||||
if (key.startsWith('idx:installed:')) {
|
||||
await this.kv.put(key, value, options);
|
||||
await this.markInstalledVersion(key.slice('idx:installed:'.length), value).catch(() => {
|
||||
// 標記失敗不擋主流程:recipe 本體已經在 KBDB,最壞情況是重建時退回 updated_at 那條路。
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const ref = classifyAssetKey(key);
|
||||
if (!ref) return this.kv.put(key, value, options);
|
||||
|
||||
let definition: unknown;
|
||||
let definitionRaw: string | undefined;
|
||||
try { definition = JSON.parse(value); } catch { definitionRaw = value; }
|
||||
|
||||
// 覆寫定義不該把「這版是目前安裝的那版」洗掉 → 只有 api_recipe 需要先讀回舊信封。
|
||||
// 其他三型沒有這個欄位,省下這一次往返(每次 acr push 都會走到這裡)。
|
||||
const previous = ref.entry_type === 'api_recipe' ? await this.readEnvelope(ref) : null;
|
||||
|
||||
// 先真相、後快取。KBDB 失敗就拋——不寫 KV、不回報成功(禁假綠,mindset §7)。
|
||||
await this.kbdbPutEntry(ref, {
|
||||
arcrun_asset: true,
|
||||
kv_key: key,
|
||||
...(definitionRaw !== undefined ? { definition_raw: definitionRaw } : { definition }),
|
||||
...(previous?.installed ? { installed: true } : {}),
|
||||
});
|
||||
await this.kv.put(key, value, options);
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
const ref = classifyAssetKey(key);
|
||||
if (ref) await this.kbdbDeleteEntry(ref.entry_id);
|
||||
await this.kv.delete(key);
|
||||
}
|
||||
|
||||
async list(options?: KVNamespaceListOptions): Promise<KVNamespaceListResult<unknown, string>> {
|
||||
const target = classifyListPrefix(options?.prefix ?? undefined);
|
||||
if (!target) return this.kv.list(options) as Promise<KVNamespaceListResult<unknown, string>>;
|
||||
|
||||
// 資產列舉一律回源(見檔頭規則 3)。順手把每一筆補進快取——列表回應本來就帶了完整內容,
|
||||
// 呼叫端接著一筆筆 get 時就會全部命中,一次回源換掉 N 次往返。
|
||||
const entries = await this.kbdbListEntries(target.entry_type, target.owner_id);
|
||||
const keys: Array<{ name: string }> = [];
|
||||
const warm: Array<Promise<unknown>> = [];
|
||||
for (const e of entries) {
|
||||
if (!e.page_name) continue;
|
||||
// workflow_def 的 KV key 由租戶+名字組成,缺租戶就組不出正確的 key——
|
||||
// 與其回一個 `:wf:x` 這種對不到任何東西的名字,不如跳過(列不出來看得見,
|
||||
// 組錯名字則會安靜地讀到 null,那更難查)。
|
||||
if (target.entry_type === 'workflow_def' && !e.owner_id) continue;
|
||||
const name = assetKvKey(target.entry_type, e.owner_id ?? null, e.page_name);
|
||||
keys.push({ name });
|
||||
const raw = entryToKvValue(e);
|
||||
if (raw !== null) warm.push(this.kv.put(name, raw).catch(() => {}));
|
||||
}
|
||||
await Promise.all(warm);
|
||||
return { keys, list_complete: true, cacheStatus: null } as unknown as KVNamespaceListResult<unknown, string>;
|
||||
}
|
||||
|
||||
/** KVNamespace 介面補齊(本 worker 沒有呼叫端在用,原樣轉發,不做資產處理)。 */
|
||||
getWithMetadata(key: string, type?: any): Promise<any> {
|
||||
return (this.kv as unknown as { getWithMetadata: (k: string, t?: any) => Promise<any> }).getWithMetadata(key, type);
|
||||
}
|
||||
|
||||
// ── 內部小工具 ─────────────────────────────────────────────────────────
|
||||
|
||||
private async readEnvelope(ref: AssetRef): Promise<AssetEnvelope | null> {
|
||||
const entry = await this.kbdbGetEntry(ref.entry_id);
|
||||
if (!entry?.metadata_json) return null;
|
||||
try {
|
||||
const env = JSON.parse(entry.metadata_json) as AssetEnvelope;
|
||||
return env?.arcrun_asset === true ? env : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 把「這個 canonical 目前裝的是哪一版」記進該版 recipe 的信封(同 canonical 的其他版清掉旗標)。 */
|
||||
private async markInstalledVersion(canonicalId: string, uuid: string): Promise<void> {
|
||||
const entries = await this.kbdbListEntries('api_recipe');
|
||||
const jobs: Array<Promise<unknown>> = [];
|
||||
for (const e of entries) {
|
||||
const raw = entryToKvValue(e);
|
||||
if (!raw) continue;
|
||||
let def: { uuid?: string; canonical_id?: string };
|
||||
try { def = JSON.parse(raw) as typeof def; } catch { continue; }
|
||||
if (def.canonical_id !== canonicalId || !def.uuid) continue;
|
||||
|
||||
const shouldBeInstalled = def.uuid === uuid;
|
||||
let envelope: AssetEnvelope;
|
||||
try { envelope = JSON.parse(e.metadata_json ?? '{}') as AssetEnvelope; } catch { continue; }
|
||||
if ((envelope.installed === true) === shouldBeInstalled) continue; // 已經是對的,不白寫
|
||||
|
||||
envelope.installed = shouldBeInstalled;
|
||||
jobs.push(
|
||||
fetch(`${kbdbBase(this.env)}/entries/${encodeURIComponent(e.id)}`, {
|
||||
method: 'PATCH',
|
||||
headers: kbdbHeaders(this.env),
|
||||
body: JSON.stringify({ metadata_json: JSON.stringify(envelope) }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
await Promise.all(jobs);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 worker 入口把 WEBHOOKS / RECIPES 換成 KBDB 撐腰的版本。
|
||||
*
|
||||
* 只換這兩個:它們裝的是**使用者寫出來的東西**(工作流、recipe)。
|
||||
* 其餘 KV(EXEC_CONTEXT 執行中暫存、SESSIONS_KV、ANALYTICS_KV、CREDENTIALS_KV)
|
||||
* 要嘛是暫存、要嘛另有搬遷路徑(credential 走 CF Workers Secrets + D1 目錄,
|
||||
* 見 .claude/rules/01-tech-stack.md),不在本卷範圍——**不順手一起動**。
|
||||
*
|
||||
* KBDB_BASE_URL 沒設也照樣運作:kbdbBase() 有預設值;真的連不上時
|
||||
* 讀取路徑退回純 KV(維持現況、不比以前糟),寫入路徑誠實拋錯(不假裝存好了)。
|
||||
*/
|
||||
export function withDurableStores<T extends { WEBHOOKS?: KVNamespace; RECIPES?: KVNamespace } & KbdbEnv>(env: T): T {
|
||||
const wrapped = { ...env } as T;
|
||||
if (env.WEBHOOKS) wrapped.WEBHOOKS = new DurableKv(env.WEBHOOKS, env) as unknown as KVNamespace;
|
||||
if (env.RECIPES) wrapped.RECIPES = new DurableKv(env.RECIPES, env) as unknown as KVNamespace;
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得底層真 KV(沒被包裝就是它自己)。遷移/盤點專用,理由見 DurableKv.rawKv。
|
||||
* 寫成獨立函式是為了讓「誰在繞過包裝」grep 得出來——目前只有 routes/storage.ts。
|
||||
*/
|
||||
export function unwrapKv(binding: KVNamespace): KVNamespace {
|
||||
const maybe = binding as unknown as { rawKv?: KVNamespace };
|
||||
return maybe.rawKv ?? binding;
|
||||
}
|
||||
@@ -132,7 +132,13 @@ function canReadLibrary(userLibraries: string[], library: string): boolean {
|
||||
// execution_log/execution_log_usage(KV 額度事故修復,2026-08-07):workflow 執行紀錄與其內部
|
||||
// 用量計數器,entry_type 與既有 value/workflow 同層級的內部型別——一併排除,避免用戶搜尋知識時
|
||||
// 混進執行 log(同層防線:本模組也從不設 metadata_json.embed=true,永不進語意搜尋索引)。
|
||||
const INTERNAL_ENTRY_TYPES = new Set(['value', 'workflow', 'execution_log', 'execution_log_usage']);
|
||||
// KV 退休(#16/#17)新增四型:資產定義本體住進 KBDB 之後,它們是「系統的東西」而不是
|
||||
// 使用者的知識卡——知識瀏覽/搜尋要跟 execution_log 一樣排除,否則 portal 會冒出
|
||||
// 一堆 workflow_def / api_recipe 汙染結果。
|
||||
const INTERNAL_ENTRY_TYPES = new Set([
|
||||
'value', 'workflow', 'execution_log', 'execution_log_usage',
|
||||
'workflow_def', 'api_recipe', 'auth_recipe', 'prompt_recipe',
|
||||
]);
|
||||
|
||||
export function filterDeprecatedEntries<T extends { metadata_json?: string | null; content?: string | null; entry_type?: string | null }>(
|
||||
entries: T[],
|
||||
@@ -653,215 +659,6 @@ portalDataRouter.get('/portal/data/workflows', (c) =>
|
||||
}),
|
||||
);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 授權的 AI(arcrun-mcp)走的資料面 — 與人類 portal 同一道閘、同一份權限
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
//
|
||||
// leo 2026-08-12:「人類進 Portal 輸入帳密表示你是主人,可以查到你權限所有東西;
|
||||
// AI 透過輸入帳密的 MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」
|
||||
// 「掛上 MCP 並輸入帳密,那個動作本身就是授權」⇒ **下游不得再要求第二次認證**。
|
||||
//
|
||||
// 之前的病:MCP 驗完帳密只留下一個布林值,身分當場丟掉(oauth/routes.ts 舊 `loginOk = res.ok`),
|
||||
// 於是查詢時只好去找一把**服務內部金鑰**(KBDB_INTERNAL_TOKEN)直打 KBDB——
|
||||
// 那條路繞過了本檔上半部所有的庫過濾,等於「誰登入都看到同一格、而且是全部」。
|
||||
//
|
||||
// 修法=MCP 改帶**登入者的 portal session token** 打本段端點。所以本段的每一支:
|
||||
// ① 一律 requirePortalUser(session → 回讀 user record → 停用即時生效),
|
||||
// ② owner_id / library 由 server 注入,**呼叫端傳什麼都不看**(與上半部同一條紅線:
|
||||
// 呼叫端自己帶租戶字串=繞過庫過濾),
|
||||
// ③ 越權與不存在同回 404(不洩存在性)。
|
||||
//
|
||||
// 薄殼(rule 07):這裡沒有新能力——template/record/map 的真身都在 KBDB 基本盤,
|
||||
// 本段只做「權限注入+轉發」,與上半部 search/entries 一模一樣的做法。
|
||||
|
||||
/**
|
||||
* record 的庫歸屬。與 entry 不同:**沒有 `library` slot 的 record 不套庫過濾**。
|
||||
*
|
||||
* 為什麼不比照 entry 用 'general' fallback:entry 是知識內容(庫是它的第一屬性,沒標就歸
|
||||
* general 是對的);record 是結構化資料列(contact / workflow_metadata / triplet…),
|
||||
* 「庫」只對 triplet 這種有標 library slot 的才有意義。若照抄 general fallback,
|
||||
* 一個庫權限是 ["kb"] 的帳號會連自己建的 contact 都讀不回——那是誤殺,不是隔離。
|
||||
* 租戶邊界仍然守著(owner_id 由 server 注入/逐筆比對),這裡只多守「有標庫的別越庫」。
|
||||
*/
|
||||
function recordLibrary(values: Record<string, unknown> | undefined): string | null {
|
||||
const lib = values?.library;
|
||||
return typeof lib === 'string' && lib.trim() ? lib.trim() : null;
|
||||
}
|
||||
|
||||
/** record 可讀?租戶要對;有標 library 的還要在用戶庫集合內。 */
|
||||
function canReadRecord(
|
||||
rec: { values?: Record<string, unknown>; owner_id?: string | null },
|
||||
tenant: string,
|
||||
libraries: string[],
|
||||
): boolean {
|
||||
if ((rec.owner_id ?? '') !== tenant) return false;
|
||||
const lib = recordLibrary(rec.values);
|
||||
return lib === null || canReadLibrary(libraries, lib);
|
||||
}
|
||||
|
||||
// GET /portal/data/map — 藏書地圖全館視圖,**只回這個帳號有權限的庫**。
|
||||
// KBDB 的 /map 對權限無知(它回全館),過濾在這裡做——MCP 不得比 portal 同一個帳號看得更多。
|
||||
portalDataRouter.get('/portal/data/map', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const libraries = parseLibraries(auth.user.values.libraries);
|
||||
if (libraries.length === 0) {
|
||||
return c.json({ success: true, libraries: [], count: 0, note: '此帳號尚未被授權任何知識庫,請聯絡管理員。' });
|
||||
}
|
||||
const res = await kbdbFetch(c.env, `/map?owner_id=${encodeURIComponent(portalTenant(c.env))}`);
|
||||
if (!res.ok) {
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
const body = (await res.json().catch(() => null)) as { libraries?: { library?: string }[] } | null;
|
||||
if (!body || !Array.isArray(body.libraries)) {
|
||||
return c.json({ error: '藏書地圖讀取失敗:KBDB 回應不是預期的 libraries 清單' }, 502);
|
||||
}
|
||||
const allowed = body.libraries.filter(
|
||||
(l) => typeof l?.library === 'string' && canReadLibrary(libraries, l.library),
|
||||
);
|
||||
return c.json({ success: true, libraries: allowed, count: allowed.length });
|
||||
}),
|
||||
);
|
||||
|
||||
// GET /portal/data/map/:library — 單庫詳圖。無權該庫 → 與不存在同回 404(不洩存在性)。
|
||||
portalDataRouter.get('/portal/data/map/:library', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const libraries = parseLibraries(auth.user.values.libraries);
|
||||
const library = c.req.param('library');
|
||||
if (!canReadLibrary(libraries, library)) return notFound(c);
|
||||
const res = await kbdbFetch(
|
||||
c.env,
|
||||
`/map/${encodeURIComponent(library)}?owner_id=${encodeURIComponent(portalTenant(c.env))}`,
|
||||
);
|
||||
if (res.status === 404) return notFound(c);
|
||||
if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status})` }, 502);
|
||||
return new Response(res.body, { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}),
|
||||
);
|
||||
|
||||
// GET /portal/data/templates — template 清單。
|
||||
// template=虛擬表定義(schema),**全域共享不分租戶**(kbdb-proxy 同一裁定,leo 2026-06-14):
|
||||
// 它描述「資料長什麼形狀」,不含任何人的內容。內容的隔離在 records/entries 那層。
|
||||
portalDataRouter.get('/portal/data/templates', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const res = await kbdbFetch(c.env, '/templates');
|
||||
if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status})` }, 502);
|
||||
return new Response(res.body, { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}),
|
||||
);
|
||||
|
||||
// POST /portal/data/templates — 建 template(name + slots)。
|
||||
// 鐵律:這是「虛擬表定義」,不是建真的資料表;KBDB 不提供建表/SQL。
|
||||
// created_by 記租戶(溯源),template 本身全域可見可用。
|
||||
portalDataRouter.post('/portal/data/templates', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const body = (await c.req.json().catch(() => null)) as
|
||||
| { name?: unknown; slots?: unknown; description?: unknown }
|
||||
| null;
|
||||
if (!body || typeof body.name !== 'string' || !body.name.trim() || !Array.isArray(body.slots)) {
|
||||
return c.json({ error: 'name 與 slots[] 必填' }, 400);
|
||||
}
|
||||
const res = await kbdbFetch(c.env, '/templates', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: body.name,
|
||||
slots: body.slots,
|
||||
description: typeof body.description === 'string' ? body.description : undefined,
|
||||
created_by: portalTenant(c.env),
|
||||
}),
|
||||
});
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
}),
|
||||
);
|
||||
|
||||
// GET /portal/data/records/by-template/:template — 某 template 底下的 record。
|
||||
// server 注入 owner_id(呼叫端傳的一律忽略);有標 library 的再逐筆過濾。
|
||||
portalDataRouter.get('/portal/data/records/by-template/:template', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const libraries = parseLibraries(auth.user.values.libraries);
|
||||
if (libraries.length === 0) return c.json({ success: true, records: [], count: 0 });
|
||||
const tenant = portalTenant(c.env);
|
||||
const res = await kbdbFetch(
|
||||
c.env,
|
||||
`/records/by-template/${encodeURIComponent(c.req.param('template'))}?owner_id=${encodeURIComponent(tenant)}`,
|
||||
);
|
||||
if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status})` }, 502);
|
||||
const body = (await res.json().catch(() => null)) as
|
||||
| { records?: { values?: Record<string, unknown>; owner_id?: string | null }[] }
|
||||
| null;
|
||||
if (!body || !Array.isArray(body.records)) {
|
||||
return c.json({ error: 'record 讀取失敗:KBDB 回應不是預期的 records 清單' }, 502);
|
||||
}
|
||||
// KBDB 已按 owner_id 過濾;這裡再守一次庫(縱深防禦,且舊部署若回多了不會外洩)。
|
||||
const records = body.records.filter((r) => canReadRecord(r, tenant, libraries));
|
||||
return c.json({ success: true, records, count: records.length });
|
||||
}),
|
||||
);
|
||||
|
||||
// GET /portal/data/records/:recordId — 單筆 record。
|
||||
// 逐筆驗歸屬(owner_id 必須是本實例租戶)+ 驗庫;兩者不符與不存在同回 404。
|
||||
portalDataRouter.get('/portal/data/records/:recordId', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const libraries = parseLibraries(auth.user.values.libraries);
|
||||
if (libraries.length === 0) return notFound(c);
|
||||
const res = await kbdbFetch(c.env, `/records/${encodeURIComponent(c.req.param('recordId'))}`);
|
||||
if (res.status === 404) return notFound(c);
|
||||
if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status})` }, 502);
|
||||
const body = (await res.json().catch(() => null)) as
|
||||
| { record?: { values?: Record<string, unknown>; owner_id?: string | null } }
|
||||
| null;
|
||||
const record = body?.record;
|
||||
if (!record) return notFound(c);
|
||||
if (!canReadRecord(record, portalTenant(c.env), libraries)) return notFound(c);
|
||||
return c.json({ success: true, record });
|
||||
}),
|
||||
);
|
||||
|
||||
// POST /portal/data/records — 依 template 填一筆 record。
|
||||
// owner_id **一律由 server 定死成本實例租戶**(呼叫端傳的忽略)——寫入端若讓呼叫端挑歸屬,
|
||||
// 等於開一扇「把資料寫進別人格子」的門。要寫進某個庫(values.library)必須有該庫權限。
|
||||
portalDataRouter.post('/portal/data/records', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const libraries = parseLibraries(auth.user.values.libraries);
|
||||
if (libraries.length === 0) {
|
||||
return c.json({ error: '此帳號尚未被授權任何知識庫,無法寫入' }, 403);
|
||||
}
|
||||
const body = (await c.req.json().catch(() => null)) as
|
||||
| { template?: unknown; values?: unknown }
|
||||
| null;
|
||||
if (!body || typeof body.template !== 'string' || !body.template.trim() || !body.values || typeof body.values !== 'object') {
|
||||
return c.json({ error: 'template 與 values 必填' }, 400);
|
||||
}
|
||||
const values = body.values as Record<string, unknown>;
|
||||
const targetLib = recordLibrary(values);
|
||||
if (targetLib !== null && !canReadLibrary(libraries, targetLib)) {
|
||||
// 寫入越庫是**明確拒絕**(403),不套讀取那條 404 不洩存在性的規則:
|
||||
// 庫名是呼叫端自己指定的,這裡沒有「洩漏某庫存在」的問題,講清楚才可修正。
|
||||
return c.json({ error: `無「${targetLib}」庫的權限,不能寫入該庫` }, 403);
|
||||
}
|
||||
const res = await kbdbFetch(c.env, '/records', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ template: body.template, values, owner_id: portalTenant(c.env) }),
|
||||
});
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
}),
|
||||
);
|
||||
|
||||
// GET /portal/data/diagnostics — 檢修孔(2026-08-07 leo 直接指令):
|
||||
//
|
||||
// 「可以很簡單,就是一顆按鈕在設定裡,他按鈕下載一個檔案,把檔案發給我,你看那個檔。」
|
||||
|
||||
@@ -677,11 +677,6 @@ portalRouter.post('/portal/login', (c) =>
|
||||
display_name: rec.values.display_name ?? '',
|
||||
role: rec.values.role ?? 'user',
|
||||
libraries: parseLibraries(rec.values.libraries),
|
||||
// session 還能活多久(秒)。**非機密**(是這台實例的 TTL 設定,不是任何人的憑據),
|
||||
// 但呼叫端需要它才能把自己發的憑證對齊這個上限——arcrun-mcp 用它把 OAuth
|
||||
// access_token 的 TTL 夾到 min(自己的 TTL, 這個值):否則 MCP token 活 30 天、
|
||||
// 底下的 portal session 7 天就死,使用者會在第 8 天遇到「連著卻查不到」的鬼打牆。
|
||||
session_expires_in: sessionTtl(c.env),
|
||||
// 絕不回租戶字串(design §3.3:portal_user 拿到租戶字串就能繞過庫 filter 直打 /kbdb/*)
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* /storage — 資產盤點與搬遷(KV → KBDB)
|
||||
*
|
||||
* KV 退休(Leo/Arcrun#16 + #17)。交辦的驗收條件之一逐字是:
|
||||
* 「既有的東西要能搬過去,而且**搬的過程不能弄丟任何一筆**(搬之前先數,搬之後再數)」
|
||||
* 所以這兩支端點的重點不是「搬」,是**數得出來**:
|
||||
* GET /storage/audit 兩邊各有幾筆、差在哪幾筆(唯讀,先看再決定要不要搬)
|
||||
* POST /storage/migrate-to-kbdb 搬,回傳搬之前的數、逐筆結果、搬之後的數
|
||||
*
|
||||
* 三個刻意的設計:
|
||||
* 1. **只增不刪**:搬完不動 KV 的原始資料。搬錯了、想反悔,原地還在;
|
||||
* KV 本來就要退成快取,留著它一份沒有壞處(真的要清是另一個決定,不在這支裡順手做)。
|
||||
* 2. **冪等**:KBDB 端用固定的 entry id(asset-keys.ts),同一筆搬幾次都只有一列。
|
||||
* 可以放心重跑到 missing 歸零為止。
|
||||
* 3. **誠實**:每一筆的成敗逐筆列出來,失敗有原因;不吞錯、不四捨五入回一句「成功」
|
||||
* (禁假綠,mindset §7)。搬完 after 對不上 before 就是沒搬乾淨,數字自己會講。
|
||||
*
|
||||
* flag 安全:兩支都是人/AI 主動呼叫一次的操作,**不掛 cron、不輪詢**
|
||||
*(KV list 免費額度 1000/日,這裡會 list,所以更不能自動化重複打)。
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { unwrapKv } from '../lib/durable-store';
|
||||
import { classifyAssetKey, type AssetEntryType } from '../lib/asset-keys';
|
||||
|
||||
export const storageRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
/** 本卷管的四類資產,以及它們住在哪顆 KV。 */
|
||||
const ASSET_SOURCES: Array<{ binding: 'WEBHOOKS' | 'RECIPES' }> = [
|
||||
{ binding: 'WEBHOOKS' },
|
||||
{ binding: 'RECIPES' },
|
||||
];
|
||||
|
||||
interface ScannedKey {
|
||||
key: string;
|
||||
binding: 'WEBHOOKS' | 'RECIPES';
|
||||
entry_type: AssetEntryType;
|
||||
entry_id: string;
|
||||
}
|
||||
|
||||
/** 掃一顆 KV 的全部 key(跟著 cursor 走完,不只第一頁),挑出屬於資產的。 */
|
||||
async function scanAssetKeys(kv: KVNamespace, binding: 'WEBHOOKS' | 'RECIPES'): Promise<ScannedKey[]> {
|
||||
const found: ScannedKey[] = [];
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const page = await kv.list(cursor ? { cursor } : {});
|
||||
for (const k of page.keys) {
|
||||
const ref = classifyAssetKey(k.name);
|
||||
if (ref) found.push({ key: k.name, binding, entry_type: ref.entry_type, entry_id: ref.entry_id });
|
||||
}
|
||||
cursor = page.list_complete ? undefined : page.cursor;
|
||||
} while (cursor);
|
||||
return found;
|
||||
}
|
||||
|
||||
function kbdb(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 };
|
||||
}
|
||||
|
||||
/** KBDB 端某型別現有的 entry id 集合(用來算「哪幾筆還沒搬過去」)。 */
|
||||
async function kbdbExistingIds(env: Bindings, entryType: AssetEntryType): Promise<Set<string>> {
|
||||
const { base, headers } = kbdb(env);
|
||||
const res = await fetch(`${base}/entries?entry_type=${encodeURIComponent(entryType)}&limit=1000`, { headers });
|
||||
if (!res.ok) throw new Error(`KBDB 讀取失敗(${entryType}):HTTP ${res.status}`);
|
||||
const json = (await res.json().catch(() => null)) as { entries?: Array<{ id: string }> } | null;
|
||||
return new Set((json?.entries ?? []).map((e) => e.id));
|
||||
}
|
||||
|
||||
const ASSET_TYPES: AssetEntryType[] = ['workflow_def', 'api_recipe', 'auth_recipe', 'prompt_recipe'];
|
||||
|
||||
interface Tally {
|
||||
kv: Record<string, number>;
|
||||
kbdb: Record<string, number>;
|
||||
missing_in_kbdb: string[];
|
||||
}
|
||||
|
||||
/** 兩邊各數一次,並列出「KV 有、KBDB 沒有」的那幾筆。 */
|
||||
async function tally(env: Bindings): Promise<Tally> {
|
||||
const scanned: ScannedKey[] = [];
|
||||
for (const src of ASSET_SOURCES) {
|
||||
const binding = env[src.binding];
|
||||
if (!binding) continue;
|
||||
scanned.push(...(await scanAssetKeys(unwrapKv(binding), src.binding)));
|
||||
}
|
||||
|
||||
const kvCounts: Record<string, number> = {};
|
||||
for (const t of ASSET_TYPES) kvCounts[t] = 0;
|
||||
for (const s of scanned) kvCounts[s.entry_type] += 1;
|
||||
|
||||
const kbdbCounts: Record<string, number> = {};
|
||||
const existing = new Map<AssetEntryType, Set<string>>();
|
||||
for (const t of ASSET_TYPES) {
|
||||
const ids = await kbdbExistingIds(env, t);
|
||||
existing.set(t, ids);
|
||||
kbdbCounts[t] = ids.size;
|
||||
}
|
||||
|
||||
const missing = scanned
|
||||
.filter((s) => !existing.get(s.entry_type)!.has(s.entry_id))
|
||||
.map((s) => s.key);
|
||||
|
||||
return { kv: kvCounts, kbdb: kbdbCounts, missing_in_kbdb: missing };
|
||||
}
|
||||
|
||||
// GET /storage/audit — 唯讀盤點。搬之前先看、搬之後再看,兩次數字自己會說話。
|
||||
storageRouter.get('/storage/audit', async (c) => {
|
||||
try {
|
||||
const t = await tally(c.env);
|
||||
return c.json({
|
||||
success: true,
|
||||
kv_asset_counts: t.kv,
|
||||
kbdb_asset_counts: t.kbdb,
|
||||
missing_in_kbdb: t.missing_in_kbdb,
|
||||
missing_count: t.missing_in_kbdb.length,
|
||||
verdict:
|
||||
t.missing_in_kbdb.length === 0
|
||||
? 'KV 裡的資產在 KBDB 都有一份——換掉 KV 不會弄丟東西。'
|
||||
: `還有 ${t.missing_in_kbdb.length} 筆只存在於 KV。跑 POST /storage/migrate-to-kbdb 把它們搬過去。`,
|
||||
});
|
||||
} catch (e) {
|
||||
// 誠實:盤點本身失敗就說失敗,不回一個看起來很乾淨的 0(那會被讀成「沒東西要搬」)。
|
||||
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 502);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /storage/migrate-to-kbdb — 把 KV 裡的資產補進 KBDB。只增不刪、冪等、可重跑。
|
||||
// body(都可省略):{ dry_run?: boolean }
|
||||
storageRouter.post('/storage/migrate-to-kbdb', async (c) => {
|
||||
const body = (await c.req.json().catch(() => ({}))) as { dry_run?: boolean };
|
||||
const dryRun = body.dry_run === true;
|
||||
|
||||
let before: Tally;
|
||||
try {
|
||||
before = await tally(c.env);
|
||||
} catch (e) {
|
||||
return c.json({ success: false, error: `搬遷前盤點失敗,未動任何資料:${e instanceof Error ? e.message : String(e)}` }, 502);
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
return c.json({
|
||||
success: true,
|
||||
dry_run: true,
|
||||
before: { kv: before.kv, kbdb: before.kbdb },
|
||||
would_migrate: before.missing_in_kbdb,
|
||||
would_migrate_count: before.missing_in_kbdb.length,
|
||||
});
|
||||
}
|
||||
|
||||
const migrated: string[] = [];
|
||||
const errors: Array<{ key: string; error: string }> = [];
|
||||
|
||||
for (const src of ASSET_SOURCES) {
|
||||
const wrapped = c.env[src.binding];
|
||||
if (!wrapped) continue;
|
||||
const raw = unwrapKv(wrapped);
|
||||
for (const s of await scanAssetKeys(raw, src.binding)) {
|
||||
try {
|
||||
// 從**真** KV 讀原值,再用包裝過的 binding 寫回去——寫入路徑就是平常那條
|
||||
//(先 KBDB 後快取),所以搬遷用的是跟日常寫入完全同一段程式碼,不另開一條會漂移的路。
|
||||
const value = await raw.get(s.key, 'text');
|
||||
if (value === null) continue; // 掃到當下剛好被刪:不是錯,跳過
|
||||
await wrapped.put(s.key, value);
|
||||
migrated.push(s.key);
|
||||
} catch (e) {
|
||||
errors.push({ key: s.key, error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let after: Tally | null = null;
|
||||
let afterError: string | null = null;
|
||||
try {
|
||||
after = await tally(c.env);
|
||||
} catch (e) {
|
||||
afterError = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
const clean = errors.length === 0 && after !== null && after.missing_in_kbdb.length === 0;
|
||||
return c.json(
|
||||
{
|
||||
success: clean,
|
||||
before: { kv: before.kv, kbdb: before.kbdb, missing_in_kbdb: before.missing_in_kbdb.length },
|
||||
migrated,
|
||||
migrated_count: migrated.length,
|
||||
errors,
|
||||
after: after
|
||||
? { kv: after.kv, kbdb: after.kbdb, missing_in_kbdb: after.missing_in_kbdb }
|
||||
: { error: afterError },
|
||||
verdict: clean
|
||||
? '搬完了:KV 裡的每一筆資產在 KBDB 都有對應的一列(missing 歸零)。KV 原始資料原封不動保留。'
|
||||
: '**沒有搬乾淨**——看 errors 與 after.missing_in_kbdb。修掉原因後可以直接重跑(冪等,不會產生重複)。',
|
||||
},
|
||||
clean ? 200 : 500,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* asset-keys 單元測試 — 「哪些 KV key 是使用者的資產」這張表本身
|
||||
*
|
||||
* KV 退休(Leo/Arcrun#16 + #17)。這支測的是純函式,沒有 KV / KBDB / 網路,
|
||||
* 因為它要守的東西也很單純:**分類錯了,資產就會被留在會被換掉的那一層。**
|
||||
*
|
||||
* 兩個方向都要測,而且分量一樣重:
|
||||
* - 資產不可以被誤判成衍生(漏收=下次更新就不見了,這是 2026-08-12 的病)
|
||||
* - 衍生不可以被誤判成資產(多收=KBDB 長出算得出來的垃圾列)
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { classifyAssetKey, classifyListPrefix, assetKvKey } from '../src/lib/asset-keys';
|
||||
import { CRON_INDEX_KEY } from '../src/lib/cron-index';
|
||||
|
||||
describe('classifyAssetKey — 資產', () => {
|
||||
it('具名工作流 {api_key}:wf:{name} → workflow_def,租戶與名字都拆得出來', () => {
|
||||
const ref = classifyAssetKey('leo:wf:rag_chat');
|
||||
expect(ref).not.toBeNull();
|
||||
expect(ref!.entry_type).toBe('workflow_def');
|
||||
expect(ref!.owner_id).toBe('leo');
|
||||
expect(ref!.page_name).toBe('rag_chat');
|
||||
expect(ref!.kv_key).toBe('leo:wf:rag_chat');
|
||||
});
|
||||
|
||||
it('api recipe(uuid key 與 migration 前的 canonical key 都算資產)', () => {
|
||||
expect(classifyAssetKey('recipe:8f3b-uuid')!.entry_type).toBe('api_recipe');
|
||||
expect(classifyAssetKey('recipe:telegram_send')!.entry_type).toBe('api_recipe');
|
||||
});
|
||||
|
||||
it('auth recipe / prompt recipe', () => {
|
||||
expect(classifyAssetKey('auth_recipe:notion')!.entry_type).toBe('auth_recipe');
|
||||
expect(classifyAssetKey('auth_recipe:notion')!.page_name).toBe('notion');
|
||||
expect(classifyAssetKey('prompt_recipe:wiki_synthesis')!.entry_type).toBe('prompt_recipe');
|
||||
});
|
||||
|
||||
it('同一個 key 永遠對到同一個 entry_id(冪等的根據——重跑遷移不會長出重複列)', () => {
|
||||
expect(classifyAssetKey('leo:wf:a')!.entry_id).toBe(classifyAssetKey('leo:wf:a')!.entry_id);
|
||||
expect(classifyAssetKey('leo:wf:a')!.entry_id).not.toBe(classifyAssetKey('evan:wf:a')!.entry_id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyAssetKey — 衍生資料不可誤收', () => {
|
||||
it('recipe 反查索引 idx:* 全部不是資產(算得回來,見 rehydrateRecipeIndices)', () => {
|
||||
expect(classifyAssetKey('idx:rec_f7e2a1b3')).toBeNull();
|
||||
expect(classifyAssetKey('idx:canonical:telegram_send')).toBeNull();
|
||||
expect(classifyAssetKey('idx:installed:telegram_send')).toBeNull();
|
||||
});
|
||||
|
||||
it('cron 索引不是資產', () => {
|
||||
expect(classifyAssetKey(CRON_INDEX_KEY)).toBeNull();
|
||||
expect(classifyAssetKey('cron-idx:leo:daily')).toBeNull();
|
||||
});
|
||||
|
||||
it('匿名 webhook token / 其他暫存 key 不是資產(本卷不碰,非漏收)', () => {
|
||||
expect(classifyAssetKey('a1b2c3d4e5f6')).toBeNull();
|
||||
expect(classifyAssetKey('daemon-active:leo')).toBeNull();
|
||||
});
|
||||
|
||||
it('殘缺的 key 不當資產(寧可退回原本的 KV 行為,也不要建出半截的列)', () => {
|
||||
expect(classifyAssetKey('recipe:')).toBeNull();
|
||||
expect(classifyAssetKey('auth_recipe:')).toBeNull();
|
||||
expect(classifyAssetKey(':wf:orphan')).toBeNull();
|
||||
expect(classifyAssetKey('leo:wf:')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('assetKvKey — 從 KBDB 反推回 KV key(回填快取與 list 都靠它)', () => {
|
||||
it('四型都能原路折返', () => {
|
||||
for (const key of ['leo:wf:rag_chat', 'recipe:telegram_send', 'auth_recipe:notion', 'prompt_recipe:x']) {
|
||||
const ref = classifyAssetKey(key)!;
|
||||
expect(assetKvKey(ref.entry_type, ref.owner_id, ref.page_name)).toBe(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyListPrefix — 列舉走哪一邊', () => {
|
||||
it('租戶工作流列舉 → 走 KBDB(帶 owner)', () => {
|
||||
expect(classifyListPrefix('leo:wf:')).toEqual({ entry_type: 'workflow_def', owner_id: 'leo' });
|
||||
});
|
||||
|
||||
it('recipe / auth_recipe 列舉 → 走 KBDB', () => {
|
||||
expect(classifyListPrefix('recipe:')).toEqual({ entry_type: 'api_recipe' });
|
||||
expect(classifyListPrefix('auth_recipe:')).toEqual({ entry_type: 'auth_recipe' });
|
||||
});
|
||||
|
||||
it('索引與無 prefix 的全域列舉 → 維持原本的 KV 行為', () => {
|
||||
expect(classifyListPrefix('idx:')).toBeNull();
|
||||
expect(classifyListPrefix('cron-idx:')).toBeNull();
|
||||
expect(classifyListPrefix(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -232,214 +232,6 @@ describe('GET /portal/data/entries/:id(逐筆驗庫)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 3b. 授權的 AI(arcrun-mcp)走的資料面 ═══════════════
|
||||
//
|
||||
// leo 2026-08-12:「AI 透過輸入帳密的 MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」
|
||||
// ⇒ 這幾支端點與人類走的 search/entries 是同一道閘:同一個 session、同一份庫權限、
|
||||
// 同樣「呼叫端自帶 owner_id 一律不生效」、同樣「越權與不存在同一句 404」。
|
||||
|
||||
describe('藏書地圖 /portal/data/map(MCP 走的那條)', () => {
|
||||
it('只回這個帳號有權限的庫;全館其他庫不出現在回應裡', async () => {
|
||||
await seedSession('tok-m1', 'rec_1');
|
||||
mockGetRecord('rec_1', userValues({ libraries: '["finance"]' }));
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/map?'), method: 'GET' })
|
||||
.reply(200, {
|
||||
success: true,
|
||||
libraries: [
|
||||
{ library: 'finance', narrative: '財務', top_entities: [], triplet_count: 3 },
|
||||
{ library: 'hr', narrative: '人資', top_entities: [], triplet_count: 9 },
|
||||
],
|
||||
count: 2,
|
||||
});
|
||||
const res = await get('/portal/data/map', { Authorization: 'Bearer tok-m1' });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { libraries: { library: string }[]; count: number };
|
||||
expect(data.libraries.map((l) => l.library)).toEqual(['finance']);
|
||||
expect(data.count).toBe(1);
|
||||
});
|
||||
|
||||
it('["*"] 全庫 → 全部庫都回', async () => {
|
||||
await seedSession('tok-m2', 'rec_2');
|
||||
mockGetRecord('rec_2', userValues({ libraries: '["*"]' }));
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/map?'), method: 'GET' })
|
||||
.reply(200, {
|
||||
success: true,
|
||||
libraries: [
|
||||
{ library: 'finance', narrative: '', top_entities: [], triplet_count: 3 },
|
||||
{ library: 'hr', narrative: '', top_entities: [], triplet_count: 9 },
|
||||
],
|
||||
count: 2,
|
||||
});
|
||||
const res = await get('/portal/data/map', { Authorization: 'Bearer tok-m2' });
|
||||
const data = (await res.json()) as { libraries: { library: string }[] };
|
||||
expect(data.libraries.map((l) => l.library)).toEqual(['finance', 'hr']);
|
||||
});
|
||||
|
||||
it('庫集合為空 → 誠實空結果+說明,不打 KBDB', async () => {
|
||||
await seedSession('tok-m3', 'rec_3');
|
||||
mockGetRecord('rec_3', userValues({ libraries: '[]' }));
|
||||
const res = await get('/portal/data/map', { Authorization: 'Bearer tok-m3' });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { count: number; note?: string };
|
||||
expect(data.count).toBe(0);
|
||||
expect(data.note).toContain('尚未被授權');
|
||||
});
|
||||
|
||||
it('單庫詳圖:無權該庫 → 404 同一句(不打 KBDB,不洩該庫存不存在)', async () => {
|
||||
await seedSession('tok-m4', 'rec_4');
|
||||
mockGetRecord('rec_4', userValues({ libraries: '["finance"]' }));
|
||||
const res = await get('/portal/data/map/hr', { Authorization: 'Bearer tok-m4' });
|
||||
expect(res.status).toBe(404);
|
||||
expect(((await res.json()) as { error: string }).error).toBe('找不到這筆資料');
|
||||
});
|
||||
|
||||
it('單庫詳圖:有權該庫 → 200 轉發', async () => {
|
||||
await seedSession('tok-m5', 'rec_5');
|
||||
mockGetRecord('rec_5', userValues({ libraries: '["finance"]' }));
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/map/finance'), method: 'GET' })
|
||||
.reply(200, { success: true, map: { library: 'finance', triplet_count: 3 } });
|
||||
const res = await get('/portal/data/map/finance', { Authorization: 'Bearer tok-m5' });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('未登入 → 401', async () => {
|
||||
expect((await get('/portal/data/map')).status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('結構化資料 /portal/data/records、/portal/data/templates(MCP 走的那條)', () => {
|
||||
it('by-template:server 注入 owner_id;caller 自帶的被靜默覆蓋(繞不過)', async () => {
|
||||
await seedSession('tok-r1', 'rec_1');
|
||||
mockGetRecord('rec_1', userValues({ libraries: '["*"]' }));
|
||||
let captured = '';
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({
|
||||
path: (p: string) => {
|
||||
if (!p.startsWith('/records/by-template/contact')) return false;
|
||||
captured = p;
|
||||
return true;
|
||||
},
|
||||
method: 'GET',
|
||||
})
|
||||
.reply(200, { success: true, records: [], count: 0 });
|
||||
const res = await get('/portal/data/records/by-template/contact?owner_id=someone-else', {
|
||||
Authorization: 'Bearer tok-r1',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(new URL(`http://x${captured}`).searchParams.get('owner_id')).toBe(TENANT);
|
||||
});
|
||||
|
||||
it('by-template:有標 library 的 record 越庫的被濾掉;沒標 library 的照回', async () => {
|
||||
await seedSession('tok-r2', 'rec_2');
|
||||
mockGetRecord('rec_2', userValues({ libraries: '["finance"]' }));
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/records/by-template/triplet'), method: 'GET' })
|
||||
.reply(200, {
|
||||
success: true,
|
||||
records: [
|
||||
{ record_id: 'r1', owner_id: TENANT, values: { library: 'finance', subject: 'A' } },
|
||||
{ record_id: 'r2', owner_id: TENANT, values: { library: 'hr', subject: 'B' } },
|
||||
{ record_id: 'r3', owner_id: TENANT, values: { subject: 'C' } }, // 沒標庫=結構化資料列
|
||||
],
|
||||
count: 3,
|
||||
});
|
||||
const res = await get('/portal/data/records/by-template/triplet', { Authorization: 'Bearer tok-r2' });
|
||||
const data = (await res.json()) as { records: { record_id: string }[] };
|
||||
expect(data.records.map((r) => r.record_id)).toEqual(['r1', 'r3']);
|
||||
});
|
||||
|
||||
it('單筆:別的租戶的 record → 404 同一句(就算全庫權限也擋)', async () => {
|
||||
await seedSession('tok-r3', 'rec_3');
|
||||
mockGetRecord('rec_3', userValues({ libraries: '["*"]' }));
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/records/r_other', method: 'GET' })
|
||||
.reply(200, { success: true, record: { record_id: 'r_other', owner_id: 'other-tenant', values: {} } });
|
||||
const res = await get('/portal/data/records/r_other', { Authorization: 'Bearer tok-r3' });
|
||||
expect(res.status).toBe(404);
|
||||
expect(((await res.json()) as { error: string }).error).toBe('找不到這筆資料');
|
||||
});
|
||||
|
||||
it('單筆:越庫的 record → 404 同一句;有權的 → 200', async () => {
|
||||
await seedSession('tok-r4', 'rec_4');
|
||||
mockGetRecord('rec_4', userValues({ libraries: '["finance"]' }));
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/records/r_hr', method: 'GET' })
|
||||
.reply(200, { success: true, record: { record_id: 'r_hr', owner_id: TENANT, values: { library: 'hr' } } });
|
||||
expect((await get('/portal/data/records/r_hr', { Authorization: 'Bearer tok-r4' })).status).toBe(404);
|
||||
|
||||
await seedSession('tok-r5', 'rec_5');
|
||||
mockGetRecord('rec_5', userValues({ libraries: '["finance"]' }));
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/records/r_fin', method: 'GET' })
|
||||
.reply(200, { success: true, record: { record_id: 'r_fin', owner_id: TENANT, values: { library: 'finance' } } });
|
||||
expect((await get('/portal/data/records/r_fin', { Authorization: 'Bearer tok-r5' })).status).toBe(200);
|
||||
});
|
||||
|
||||
it('寫入:owner_id 由 server 定死,呼叫端塞的不算', async () => {
|
||||
await seedSession('tok-r6', 'rec_6');
|
||||
mockGetRecord('rec_6', userValues({ libraries: '["*"]' }));
|
||||
let body: Record<string, unknown> = {};
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({
|
||||
path: '/records',
|
||||
method: 'POST',
|
||||
body: (b: string) => {
|
||||
body = JSON.parse(b) as Record<string, unknown>;
|
||||
return true;
|
||||
},
|
||||
})
|
||||
.reply(200, { success: true, record: { record_id: 'r_new' } });
|
||||
const res = await SELF.fetch('http://localhost/portal/data/records', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer tok-r6', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ template: 'contact', values: { name: 'Leo' }, owner_id: 'someone-else' }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.owner_id).toBe(TENANT);
|
||||
});
|
||||
|
||||
it('寫入越庫 → 403(明確拒絕,庫名是呼叫端自己指定的,沒有存在性可洩)', async () => {
|
||||
await seedSession('tok-r7', 'rec_7');
|
||||
mockGetRecord('rec_7', userValues({ libraries: '["finance"]' }));
|
||||
const res = await SELF.fetch('http://localhost/portal/data/records', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer tok-r7', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ template: 'note', values: { library: 'hr', body: 'x' } }),
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('templates 全域共享(schema 非內容):登入即可列', async () => {
|
||||
await seedSession('tok-t1', 'rec_t1');
|
||||
mockGetRecord('rec_t1', userValues({ libraries: '["finance"]' }));
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/templates', method: 'GET' })
|
||||
.reply(200, { success: true, templates: [{ id: 'tpl1', name: 'contact' }], count: 1 });
|
||||
const res = await get('/portal/data/templates', { Authorization: 'Bearer tok-t1' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(((await res.json()) as { count: number }).count).toBe(1);
|
||||
});
|
||||
|
||||
it('未登入 → 401(records / templates 都是)', async () => {
|
||||
expect((await get('/portal/data/templates')).status).toBe(401);
|
||||
expect((await get('/portal/data/records/by-template/contact')).status).toBe(401);
|
||||
expect((await get('/portal/data/records/r1')).status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 4. graph D-4 粗閘 ═══════════════
|
||||
|
||||
describe('GET /portal/data/graph/neighbors/:name(D-4 粗閘)', () => {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
-- arcrun 資產型別 template seed — KV 退休(Leo/Arcrun#16 + #17)
|
||||
--
|
||||
-- 為什麼有這一檔(leo 2026-08-12 原話):
|
||||
-- 「我要的是寫進 KBDB,不是 KV,他的 Recipes、Cypher 是一段話,文字,數據,一個 entry」
|
||||
-- 「如果零件和工作流的 recipe 不見了,是很可怕的事情」
|
||||
-- 同一天(2026-08-12)真的發作過:一次例行更新讓使用者的九支工作流在畫面上全部消失
|
||||
-- (根因見 cli/src/lib/resource-resolver.ts 檔頭 Arcrun#97——舊 deploy 會照名字新建一顆空 KV
|
||||
-- 再綁上去)。#97 修的是「不要再把 worker 綁到空的資源上」;本卷修的是更根本的一句:
|
||||
-- **使用者的資產本來就不該只存在於一個會被換掉的暫存層裡。**
|
||||
--
|
||||
-- KBDB 鐵律(leo 2026-06-14,D38):三張表打天下,永遠不加新 table,新資料類型一律用 template。
|
||||
-- 本檔**零 schema 異動**——只 INSERT OR IGNORE 四列 template 定義,手法與同目錄
|
||||
-- 0003_library_map.sql / 0004_execution_log_template.sql 完全相同。
|
||||
--
|
||||
-- 儲存精神比照 0004(execution_log)與 recipe-stat:template 只負責「schema 文件化 +
|
||||
-- GET /templates 可發現」,實際一筆資產是 entries 表的**一列**——
|
||||
-- entry_type = 'workflow_def' | 'api_recipe' | 'auth_recipe' | 'prompt_recipe'
|
||||
-- owner_id = 租戶(workflow 才有;recipe 是整台實例共用的庫,故為 NULL)
|
||||
-- page_name = 該型別的自然鍵(workflow 名 / recipe uuid / service 名)
|
||||
-- content = 給人看也給語意搜尋看的一句描述
|
||||
-- metadata_json = 定義本體(graph / endpoint / inject … 原樣 JSON)
|
||||
-- ——不走 entry_values 全展開的多列 record:一支 workflow 的 graph 是一整包巢狀 JSON,
|
||||
-- 拆成 slot 多列既不會變得比較好查,反而讓「一筆資產=一列」這件事不再成立
|
||||
-- (recipe_stat 與 execution_log 早已示範「template 存在 + entries 直接存」這個模式合法)。
|
||||
--
|
||||
-- 讀寫一律走 HTTP API(/entries、/entries/:id),呼叫端是 cypher-executor 的
|
||||
-- src/lib/durable-store.ts。牆外沒有任何一行 SQL。
|
||||
|
||||
INSERT OR IGNORE INTO templates (id, name, description, slots_json, created_by)
|
||||
VALUES
|
||||
('tpl-workflow-def', 'workflow_def',
|
||||
'工作流定義本體(KV 退休 #17)。一支工作流=entries 一列;graph/config/cron_expr 打包進 metadata_json,WEBHOOKS KV 降為可丟棄的快取',
|
||||
'["name","description","graph","config","cron_expr","created_at"]', 'system'),
|
||||
|
||||
('tpl-api-recipe', 'api_recipe',
|
||||
'API recipe 定義本體(KV 退休 #16)。一份 recipe=entries 一列;endpoint/headers/body/auth 等打包進 metadata_json,RECIPES KV 降為快取。idx:* 反查索引屬衍生資料,不進 KBDB,由 durable-store 從本型別重建',
|
||||
'["uuid","canonical_id","hash_id","author","endpoint","method","auth_service","installed"]', 'system'),
|
||||
|
||||
('tpl-auth-recipe', 'auth_recipe',
|
||||
'Auth recipe 定義本體(KV 退休 #16)。一個服務一列;primitive/base_url/required_secrets/inject 打包進 metadata_json。只存「怎麼認證」,不存任何密文(憑證明文在 CF Workers Secrets,見 .claude/rules/01-tech-stack.md)',
|
||||
'["service","primitive","base_url","version","required_secrets","inject"]', 'system'),
|
||||
|
||||
('tpl-prompt-recipe', 'prompt_recipe',
|
||||
'Prompt recipe 定義本體(KV 退休 #16)。一份 prompt recipe=entries 一列,定義打包進 metadata_json',
|
||||
'["name","definition"]', 'system');
|
||||
@@ -134,6 +134,51 @@ export async function deleteEntry(db: D1Database, id: string): Promise<void> {
|
||||
await db.prepare('DELETE FROM entries WHERE id = ?').bind(id).run();
|
||||
}
|
||||
|
||||
/**
|
||||
* 以「呼叫端指定的 id」寫一列——已存在就整列覆蓋,不存在就新建(KV 退休 #16/#17)。
|
||||
*
|
||||
* 為什麼 base 需要這一支:`createEntry` 的 id 預設是隨機的,同一份資產每存一次就多一列;
|
||||
* 呼叫端若想要「同一份資產永遠是同一列」,只能自己先 GET 再決定 POST 還是 PATCH——
|
||||
* 兩趟往返、而且中間有競態。把它收成一個原語,語意才單一(冪等:同樣的輸入跑幾次結果都一樣)。
|
||||
*
|
||||
* 這是 base 的通用能力,不是替某個 entry_type 開的特例——任何有天然鍵的資料型別
|
||||
* (arcrun 的 workflow_def / api_recipe / auth_recipe,或未來別的)都用得上。
|
||||
* **零 schema 異動**:仍然只寫 entries 這一張既有的表。
|
||||
*
|
||||
* 覆蓋語意刻意是「整列取代」而非 PATCH 式合併:呼叫端手上是一份完整的資產定義,
|
||||
* 合併語意會讓「刪掉某個欄位」變成做不到的事(舊值會留下來)。
|
||||
* created_at 保留原值(資產的誕生時間不因為改一次內容就被重寫),updated_at 更新。
|
||||
*/
|
||||
export async function upsertEntry(db: D1Database, id: string, input: Omit<CreateEntryInput, 'id'>): Promise<Entry> {
|
||||
const existing = await getEntry(db, id);
|
||||
if (!existing) return createEntry(db, { ...input, id });
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE entries
|
||||
SET content = ?, entry_type = ?, owner_id = ?, parent_id = ?, page_name = ?,
|
||||
refs_json = ?, tags_json = ?, task_status = ?, confidence = ?, metadata_json = ?,
|
||||
updated_at = unixepoch()
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.bind(
|
||||
input.content ?? null,
|
||||
input.entry_type,
|
||||
input.owner_id ?? null,
|
||||
input.parent_id ?? null,
|
||||
input.page_name ?? null,
|
||||
input.refs_json ?? '[]',
|
||||
input.tags_json ?? '[]',
|
||||
input.task_status ?? null,
|
||||
input.confidence ?? null,
|
||||
input.metadata_json ?? null,
|
||||
id,
|
||||
)
|
||||
.run();
|
||||
const row = await getEntry(db, id);
|
||||
if (!row) throw new Error('upsertEntry: update succeeded but row not found');
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把某 owner 下某庫的所有 entries 標 deprecated(t135 by-name 移除語意)。
|
||||
* 沿用既有 deprecated 機制:metadata_json.status='deprecated' → 搜尋端過濾、庫列表排除。
|
||||
|
||||
@@ -65,13 +65,6 @@ export interface RecordResult {
|
||||
record_id: string;
|
||||
template_id: string;
|
||||
values: Record<string, string>;
|
||||
/**
|
||||
* record 的歸屬(=其底層 slot entries 的 owner_id,createRecord 寫入時同一值)。
|
||||
* 2026-08-12 補:`GET /records/:id` 原本不回這欄,所以**呼叫端無從判斷這筆是不是自己的**
|
||||
* ——按 id 直讀等於沒有租戶邊界。要讓 cypher 的 portal 資料面(授權的人/AI 走的那條)
|
||||
* 能對單筆做「不是我的就回 404」,歸屬必須跟著資料一起回來。無歸屬的舊資料 → null。
|
||||
*/
|
||||
owner_id: string | null;
|
||||
}
|
||||
|
||||
export async function createRecord(db: D1Database, input: CreateRecordInput): Promise<RecordResult> {
|
||||
@@ -92,7 +85,7 @@ export async function createRecord(db: D1Database, input: CreateRecordInput): Pr
|
||||
.bind(uid('ev'), recordId, tpl.id, slot, entry.id)
|
||||
.run();
|
||||
}
|
||||
return { record_id: recordId, template_id: tpl.id, values: input.values, owner_id: input.owner_id ?? null };
|
||||
return { record_id: recordId, template_id: tpl.id, values: input.values };
|
||||
}
|
||||
|
||||
// Update an existing record's slot values (mira-dissolve T2.1, issue #6).
|
||||
@@ -154,19 +147,17 @@ export async function updateRecord(
|
||||
export async function getRecord(db: D1Database, recordId: string): Promise<RecordResult | null> {
|
||||
const res = await db
|
||||
.prepare(
|
||||
`SELECT ev.slot_name as slot, e.content as content, ev.template_id as template_id, e.owner_id as owner_id
|
||||
`SELECT ev.slot_name as slot, e.content as content, ev.template_id as template_id
|
||||
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
||||
WHERE ev.record_id = ?`,
|
||||
)
|
||||
.bind(recordId)
|
||||
.all<{ slot: string; content: string; template_id: string; owner_id: string | null }>();
|
||||
.all<{ slot: string; content: string; template_id: string }>();
|
||||
const rows = res.results ?? [];
|
||||
if (rows.length === 0) return null;
|
||||
const values: Record<string, string> = {};
|
||||
for (const r of rows) values[r.slot] = r.content;
|
||||
// 歸屬取第一個非 null 的 slot entry owner(同一 record 的 slot entries 同歸屬)
|
||||
const owner_id = rows.find((r) => r.owner_id != null)?.owner_id ?? null;
|
||||
return { record_id: recordId, template_id: rows[0].template_id, values, owner_id };
|
||||
return { record_id: recordId, template_id: rows[0].template_id, values };
|
||||
}
|
||||
|
||||
export async function searchByTemplate(db: D1Database, template: string, owner_id?: string, limit = 100): Promise<RecordResult[]> {
|
||||
@@ -201,20 +192,19 @@ export async function searchByTemplate(db: D1Database, template: string, owner_i
|
||||
const placeholders = chunk.map(() => '?').join(',');
|
||||
const evRes = await db
|
||||
.prepare(
|
||||
`SELECT ev.record_id as record_id, ev.slot_name as slot, e.content as content, ev.template_id as template_id, e.owner_id as owner_id
|
||||
`SELECT ev.record_id as record_id, ev.slot_name as slot, e.content as content, ev.template_id as template_id
|
||||
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
||||
WHERE ev.record_id IN (${placeholders})`,
|
||||
)
|
||||
.bind(...chunk)
|
||||
.all<{ record_id: string; slot: string; content: string; template_id: string; owner_id: string | null }>();
|
||||
.all<{ record_id: string; slot: string; content: string; template_id: string }>();
|
||||
for (const r of evRes.results ?? []) {
|
||||
let rec = byId.get(r.record_id);
|
||||
if (!rec) {
|
||||
rec = { record_id: r.record_id, template_id: r.template_id, values: {}, owner_id: null };
|
||||
rec = { record_id: r.record_id, template_id: r.template_id, values: {} };
|
||||
byId.set(r.record_id, rec);
|
||||
}
|
||||
rec.values[r.slot] = r.content;
|
||||
if (rec.owner_id == null && r.owner_id != null) rec.owner_id = r.owner_id;
|
||||
}
|
||||
}
|
||||
return ids.map((id) => byId.get(id)).filter((r): r is RecordResult => !!r);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
getEntry,
|
||||
listEntries,
|
||||
updateEntry,
|
||||
upsertEntry,
|
||||
deleteEntry,
|
||||
searchEntries,
|
||||
isDeprecatedEntry,
|
||||
@@ -426,6 +427,21 @@ entryRoutes.get('/backfill-library/status', async (c) => {
|
||||
return c.json({ success: true, ...status });
|
||||
});
|
||||
|
||||
// PUT /entries/:id — 以呼叫端指定的 id 整列覆寫(不存在就新建)。KV 退休 #16/#17。
|
||||
//
|
||||
// 與 POST / 的差別:POST 的 id 是隨機的,同一份資產每存一次多一列;PUT 讓「同一份資產永遠
|
||||
// 是同一列」,所以重跑遷移、重複部署同一支工作流都不會長出重複資料(冪等)。
|
||||
// 與 PATCH /:id 的差別:PATCH 是部分更新(沒帶的欄位留著),PUT 是整列取代
|
||||
// ——呼叫端手上是完整定義時要的是後者,否則「刪掉一個欄位」永遠做不到。
|
||||
entryRoutes.put('/:id', async (c) => {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body || !body.entry_type) return c.json({ success: false, error: 'entry_type required' }, 400);
|
||||
const entry = await upsertEntry(c.env.DB, c.req.param('id'), body);
|
||||
// 與 POST / 同款:標了 embed:true 的才進 Vectorize,fire-and-forget、失敗不致命。
|
||||
if (embedEnabled(c.env)) c.executionCtx.waitUntil(embedOnWrite(c.env, entry).catch(() => {}));
|
||||
return c.json({ success: true, entry });
|
||||
});
|
||||
|
||||
// PATCH /entries/:id
|
||||
entryRoutes.patch('/:id', async (c) => {
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
|
||||
+3
-16
@@ -1,25 +1,13 @@
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { Env } from "./types.js";
|
||||
import { partnerAuthMiddleware, type AuthPath } from "./middleware/partner-auth.js";
|
||||
import { partnerAuthMiddleware } from "./middleware/partner-auth.js";
|
||||
import { handleMcpRequest } from "./mcp-handler.js";
|
||||
import { resolveKnowledgeIdentity } from "./lib/portal-client.js";
|
||||
import type { PortalIdentity } from "./oauth/store.js";
|
||||
import { inspectorHtml } from "./pages/inspector.js";
|
||||
import { kbdbFetch } from "./lib/kbdb-client.js";
|
||||
import { registerOAuthRoutes } from "./oauth/routes.js";
|
||||
|
||||
const _app = new Hono<{
|
||||
Bindings: Env;
|
||||
Variables: {
|
||||
org_namespace: string;
|
||||
partner_token: string;
|
||||
// 登入者身分(以帳密走 OAuth 連進來時才有)+ 這條連線是哪種憑據。
|
||||
// 知識面工具(kbdb_*)據此決定走 portal 資料面還是既有 KBDB 直連(見 lib/portal-client.ts)。
|
||||
portal?: PortalIdentity;
|
||||
auth_path: AuthPath;
|
||||
};
|
||||
}>();
|
||||
const _app = new Hono<{ Bindings: Env; Variables: { org_namespace: string; partner_token: string } }>();
|
||||
|
||||
// ── OAuth 2.1 server 路由(掛在 worker 根路徑,非 /mcp)──────────────────────────
|
||||
// well-known / authorize / token / register 必須在 origin 根,claude.ai 遠端 connector 才發現得到。
|
||||
@@ -273,8 +261,7 @@ app.options("/mcp", (c) => {
|
||||
app.post("/", partnerAuthMiddleware, async (c) => {
|
||||
const orgNamespace = c.get("org_namespace");
|
||||
const partnerToken = c.get("partner_token");
|
||||
const identity = resolveKnowledgeIdentity(c.get("auth_path"), c.get("portal"));
|
||||
return handleMcpRequest(c.req.raw, c.env, orgNamespace, partnerToken, identity);
|
||||
return handleMcpRequest(c.req.raw, c.env, orgNamespace, partnerToken);
|
||||
});
|
||||
|
||||
// 輸出根 app(_app):與 basePath('/mcp') 的 app 共享同一份 router,故 OAuth 根路由與
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
|
||||
import type { Env } from "../types.js";
|
||||
import { kbdbFetch } from "./kbdb-client.js";
|
||||
import { portalFetch, type KnowledgeIdentity } from "./portal-client.js";
|
||||
|
||||
/** 全館視圖一行(kbdb GET /map 的 libraries[] 元素;top_entities 已是 top-3 名字)。 */
|
||||
export interface LibraryMapRow {
|
||||
@@ -87,45 +86,25 @@ const MAP_FETCH_TIMEOUT_MS = 1500;
|
||||
const CACHE_TTL_OK_MS = 5 * 60 * 1000;
|
||||
const CACHE_TTL_FAIL_MS = 60 * 1000;
|
||||
|
||||
/**
|
||||
* 快取以「身分」分格(2026-08-12)。
|
||||
*
|
||||
* 為什麼不能共用一格:地圖本身就是情報(哪些庫存在、各有多少關聯、核心 entity 是誰)。
|
||||
* 以帳密連線時只該看到自己有權限的庫;若跟服務級連線共用同一格快取,先連上的那個人
|
||||
* 會把自己的視野留給下一個人——那是跨帳號外洩,不是效能問題。
|
||||
*/
|
||||
const instructionsCache = new Map<string, { text: string | null; expiresAt: number }>();
|
||||
let instructionsCache: { text: string | null; expiresAt: number } | null = null;
|
||||
|
||||
/** 測試用:清掉 isolate 內快取(prod 不呼叫)。 */
|
||||
export function __resetLibraryMapInstructionsCacheForTests(): void {
|
||||
instructionsCache.clear();
|
||||
instructionsCache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 組 MCP server instructions 的藏書地圖段(design §4 / §6「session 啟動 → instructions 已含
|
||||
* 全館地圖(push 零查詢)」)。任何失敗(超時/HTTP 錯/空庫/壞 JSON)→ null(caller 靜默略過)。
|
||||
*
|
||||
* 以帳密連線(identity.kind === 'portal')時走 cypher `/portal/data/map`——只拿得到這個
|
||||
* 帳號有權限的庫;服務級憑據維持既有 KBDB `/map` 直連。舊 token(stale)不給地圖。
|
||||
*/
|
||||
export async function buildLibraryMapInstructions(
|
||||
env: Env,
|
||||
identity: KnowledgeIdentity,
|
||||
): Promise<string | null> {
|
||||
if (identity.kind === "stale") return null;
|
||||
// 快取 key:portal 用 session(=這個人這次登入),service 用固定字串。
|
||||
// session token 只當 Map 的 key 活在 isolate 記憶體內,不落地、不寫 log。
|
||||
const cacheKey = identity.kind === "portal" ? `portal:${identity.portal.session}` : "service";
|
||||
export async function buildLibraryMapInstructions(env: Env): Promise<string | null> {
|
||||
const now = Date.now();
|
||||
const hit = instructionsCache.get(cacheKey);
|
||||
if (hit && hit.expiresAt > now) return hit.text;
|
||||
if (instructionsCache && instructionsCache.expiresAt > now) return instructionsCache.text;
|
||||
|
||||
let text: string | null = null;
|
||||
try {
|
||||
const res = await Promise.race([
|
||||
identity.kind === "portal"
|
||||
? portalFetch(env, identity.portal.session, "/portal/data/map")
|
||||
: kbdbFetch(env, "/map"),
|
||||
kbdbFetch(env, "/map"),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("library map fetch timeout")), MAP_FETCH_TIMEOUT_MS),
|
||||
),
|
||||
@@ -145,13 +124,6 @@ export async function buildLibraryMapInstructions(
|
||||
text = null;
|
||||
}
|
||||
|
||||
instructionsCache.set(cacheKey, {
|
||||
text,
|
||||
expiresAt: now + (text ? CACHE_TTL_OK_MS : CACHE_TTL_FAIL_MS),
|
||||
});
|
||||
// isolate 內的快取,不做失效協議;但別讓不同帳號的格子無上限長大(isolate 可活很久)。
|
||||
if (instructionsCache.size > 64) {
|
||||
for (const [k, v] of instructionsCache) if (v.expiresAt <= now) instructionsCache.delete(k);
|
||||
}
|
||||
instructionsCache = { text, expiresAt: now + (text ? CACHE_TTL_OK_MS : CACHE_TTL_FAIL_MS) };
|
||||
return text;
|
||||
}
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
/**
|
||||
* Portal 資料面 client — 「授權的 AI」用登入者的身分查東西的唯一管道。
|
||||
*
|
||||
* leo 2026-08-12:「人類進 Portal 輸入帳密表示你是主人,可以查到你權限所有東西;
|
||||
* AI 透過輸入帳密的 MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」
|
||||
* 「掛上 MCP 並輸入帳密,那個動作本身就是授權」⇒ **下游不得再要求第二次認證**。
|
||||
*
|
||||
* 所以這裡帶的是 **portal session token**(同意頁輸入帳密時 cypher 發的那張,
|
||||
* 與人類在 portal 網頁上拿到的完全同一種),不是任何服務內部金鑰。
|
||||
* 端點是 cypher 的 `/portal/data/*`——庫過濾、租戶注入、停用即時生效全在那邊 server 側做完,
|
||||
* 本檔不做任何判斷(薄殼鐵律 rule 07:能力長在 API,介面只轉換)。
|
||||
*
|
||||
* 走既有 CYPHER_EXECUTOR service binding,不新增 binding、不新增金鑰。
|
||||
*/
|
||||
|
||||
import type { Env } from "../types.js";
|
||||
import type { PortalIdentity } from "../oauth/store.js";
|
||||
import { errorResponse } from "./cypher-client.js";
|
||||
|
||||
export interface PortalCallOpts {
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
query?: Record<string, string | number | undefined>;
|
||||
}
|
||||
|
||||
/** 用登入者的 session 打 cypher 的 portal 資料面。 */
|
||||
export async function portalFetch(
|
||||
env: Env,
|
||||
session: string,
|
||||
path: string,
|
||||
opts: PortalCallOpts = {},
|
||||
): Promise<Response> {
|
||||
if (!env.CYPHER_EXECUTOR) {
|
||||
throw new Error("CYPHER_EXECUTOR service binding not configured");
|
||||
}
|
||||
const url = new URL(`https://cypher${path}`);
|
||||
for (const [k, v] of Object.entries(opts.query ?? {})) {
|
||||
if (v !== undefined && v !== "") url.searchParams.set(k, String(v));
|
||||
}
|
||||
return env.CYPHER_EXECUTOR.fetch(url.toString(), {
|
||||
method: opts.method ?? "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${session}`,
|
||||
},
|
||||
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 知識面工具的身分解析結果。
|
||||
*
|
||||
* 三態刻意分開,因為「查不到」和「沒有」不可以長得一樣(leo 的老原則):
|
||||
* - portal :有登入者 → 走 portal 資料面(權限=這個人的權限)
|
||||
* - service :服務級憑據(static token / partner key)→ 維持既有 KBDB 直連(零回歸)
|
||||
* - stale :OAuth token 但沒帶身分(本次改版前簽發的舊 token)→ **誠實要求重新連線**,
|
||||
* 不偷偷退回服務金鑰那條老路(那正是要修掉的「不管誰登入都看到同一格」)
|
||||
*/
|
||||
export type KnowledgeIdentity =
|
||||
| { kind: "portal"; portal: PortalIdentity }
|
||||
| { kind: "service" }
|
||||
| { kind: "stale" };
|
||||
|
||||
export function resolveKnowledgeIdentity(
|
||||
authPath: "oauth" | "service",
|
||||
portal: PortalIdentity | undefined,
|
||||
): KnowledgeIdentity {
|
||||
if (authPath !== "oauth") return { kind: "service" };
|
||||
return portal?.session ? { kind: "portal", portal } : { kind: "stale" };
|
||||
}
|
||||
|
||||
/** 舊 token(沒帶身分)時的統一回覆:講清楚怎麼修,不假裝查不到資料。 */
|
||||
export function staleIdentityError() {
|
||||
return errorResponse(
|
||||
"identity_missing",
|
||||
"這條 MCP 連線是舊版簽發的 token,裡面沒有登入者身分,因此查不到任何知識內容。" +
|
||||
"重新連線一次(在 claude.ai 的 connector 設定裡重新授權、輸入你的 Portal 帳密)即可——" +
|
||||
"不需要另外找任何 credential 或金鑰。",
|
||||
[
|
||||
"到 claude.ai → Settings → Connectors,把這個 connector 重新連線一次(會跳出輸入 Portal 帳密的頁面)",
|
||||
"重連後 kbdb_* 全部工具都會用你這個帳號的權限查詢",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* portal 資料面的錯誤 → 給 AI 看的訊息。
|
||||
* 401/403 特別處理:那代表**登入階段過期或帳號被停用**,不是「資料不存在」——
|
||||
* 兩者混在一起會讓 AI 對使用者說「你的知識庫是空的」,那是畫面在說謊。
|
||||
*/
|
||||
export async function portalError(res: Response, what: string) {
|
||||
const detail = await res.text().catch(() => "");
|
||||
if (res.status === 401) {
|
||||
return errorResponse(
|
||||
"session_expired",
|
||||
`${what}失敗:登入階段已過期(portal session 到期或已登出)。`,
|
||||
[
|
||||
"到 claude.ai → Settings → Connectors 重新連線這個 connector(重新輸入 Portal 帳密)",
|
||||
"重連後權限與你在 portal 網頁上看到的一致",
|
||||
],
|
||||
detail,
|
||||
);
|
||||
}
|
||||
if (res.status === 403) {
|
||||
return errorResponse(
|
||||
"forbidden",
|
||||
`${what}失敗:這個帳號沒有這項權限(帳號可能已停用,或沒有被授權該知識庫)。`,
|
||||
["請知識庫管理員在 portal 的帳號管理裡確認你的狀態與可用知識庫"],
|
||||
detail,
|
||||
);
|
||||
}
|
||||
return errorResponse(`portal_${res.status}`, `${what}失敗(HTTP ${res.status})`, ["稍後重試"], detail);
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
||||
import { registerAllTools } from "./tools/registry.js";
|
||||
import { buildLibraryMapInstructions } from "./lib/library-map.js";
|
||||
import type { KnowledgeIdentity } from "./lib/portal-client.js";
|
||||
import { Env } from "./types.js";
|
||||
|
||||
export async function handleMcpRequest(
|
||||
@@ -10,16 +9,11 @@ export async function handleMcpRequest(
|
||||
env: Env,
|
||||
orgNamespace: string,
|
||||
partnerToken: string,
|
||||
identity: KnowledgeIdentity,
|
||||
): Promise<Response> {
|
||||
// library-map SDD M4(design §4/§6):連線時把全館藏書地圖嵌進 server instructions,
|
||||
// session 一開就知道館裡有哪些庫(push 零查詢)。builder 內建 timeout+isolate TTL 快取
|
||||
//(選型理由見 lib/library-map.ts 檔頭);任何失敗回 null → 靜默略過,絕不擋 MCP 連線(鐵律)。
|
||||
//
|
||||
// 🔴 2026-08-12:以帳密連線時**改用登入者的身分**組地圖——否則 instructions 會把
|
||||
// 整個知識庫的庫名一次推給一個可能只有部分權限的帳號(地圖本身就是情報)。
|
||||
// 快取也因此改成 per-session key(見 lib/library-map.ts)。
|
||||
const mapInstructions = await buildLibraryMapInstructions(env, identity);
|
||||
const mapInstructions = await buildLibraryMapInstructions(env);
|
||||
|
||||
// 2026-07-30(leo 問「人類說『幫我用 arcrun 寫 xxx』,Haiku 會知道要用這些資源嗎?
|
||||
// 如果不會,要寫什麼在外面讓它一聽到就知道?」):
|
||||
@@ -66,7 +60,7 @@ export async function handleMcpRequest(
|
||||
{ instructions },
|
||||
);
|
||||
|
||||
registerAllTools(server, env, orgNamespace, partnerToken, identity);
|
||||
registerAllTools(server, env, orgNamespace, partnerToken);
|
||||
await server.connect(transport);
|
||||
|
||||
return transport.handleRequest(request);
|
||||
|
||||
@@ -1,19 +1,9 @@
|
||||
import { Context, Next } from "hono";
|
||||
import { Env } from "../types.js";
|
||||
import { getAccessToken, type PortalIdentity } from "../oauth/store.js";
|
||||
import { getAccessToken } from "../oauth/store.js";
|
||||
import { constantTimeEqual } from "../oauth/crypto.js";
|
||||
import { originOf, resourceUri, wwwAuthenticateHeader } from "../oauth/metadata.js";
|
||||
|
||||
/**
|
||||
* 這條連線是**用誰的身分**進來的。決定知識面(kbdb_*)走哪條路:
|
||||
* - "oauth":有人在同意頁輸入過 Portal 帳密 → 帶著他的 portal session 走 portal 資料面,
|
||||
* 權限=他在 portal 網頁上看得到的那些(庫過濾照吃)。
|
||||
* - "service":static token / partner key 這類**服務級**憑據(本身就是真祕密,
|
||||
* 代表整個實例或整個租戶,不是某個人)→ 維持既有的 KBDB 直連行為,零回歸。
|
||||
* 兩條路刻意分開命名,因為「這張 token 背後有沒有一個人」正是本次要能分辨的事。
|
||||
*/
|
||||
export type AuthPath = "oauth" | "service";
|
||||
|
||||
/**
|
||||
* MCP / GUI 端點認證中介層。
|
||||
*
|
||||
@@ -29,15 +19,7 @@ export type AuthPath = "oauth" | "service";
|
||||
* 已從預設路徑移除;只在明確設 ALLOW_PLAINTEXT_NAMESPACE="true" 的遷移情境才恢復。
|
||||
*/
|
||||
export async function partnerAuthMiddleware(
|
||||
c: Context<{
|
||||
Bindings: Env;
|
||||
Variables: {
|
||||
org_namespace: string;
|
||||
partner_token: string;
|
||||
portal?: PortalIdentity;
|
||||
auth_path: AuthPath;
|
||||
};
|
||||
}>,
|
||||
c: Context<{ Bindings: Env; Variables: { org_namespace: string; partner_token: string } }>,
|
||||
next: Next
|
||||
) {
|
||||
const origin = originOf(c.req.url);
|
||||
@@ -68,11 +50,6 @@ export async function partnerAuthMiddleware(
|
||||
}
|
||||
c.set("org_namespace", at.namespace);
|
||||
c.set("partner_token", at.namespace); // 下游 cypher 用 namespace 當 X-Arcrun-API-Key(與 CLI 同一份身份)
|
||||
// 登入者的身分(2026-08-12):知識面工具(kbdb_*)帶著它打 cypher 的 portal 資料面,
|
||||
// 權限與這個人在 portal 網頁上看到的完全一致。舊 token 沒有這欄 → undefined,
|
||||
// 知識面工具會要求重新連線(不偷偷退回服務金鑰那條老路)。
|
||||
c.set("portal", at.portal);
|
||||
c.set("auth_path", "oauth");
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
@@ -83,7 +60,6 @@ export async function partnerAuthMiddleware(
|
||||
const ns = c.env.MCP_OWNER_NAMESPACE || "leo";
|
||||
c.set("org_namespace", ns);
|
||||
c.set("partner_token", ns);
|
||||
c.set("auth_path", "service");
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
@@ -103,7 +79,6 @@ export async function partnerAuthMiddleware(
|
||||
}
|
||||
c.set("org_namespace", info.org_namespace);
|
||||
c.set("partner_token", token);
|
||||
c.set("auth_path", "service");
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
@@ -114,7 +89,6 @@ export async function partnerAuthMiddleware(
|
||||
if (c.env.ALLOW_PLAINTEXT_NAMESPACE === "true") {
|
||||
c.set("org_namespace", token);
|
||||
c.set("partner_token", token);
|
||||
c.set("auth_path", "service");
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
+6
-56
@@ -14,7 +14,6 @@ import {
|
||||
consumeAuthCode,
|
||||
putAccessToken,
|
||||
AUTH_CODE_TTL_SECONDS,
|
||||
type PortalIdentity,
|
||||
} from "./store.js";
|
||||
import {
|
||||
originOf,
|
||||
@@ -35,25 +34,10 @@ const CORS_JSON = {
|
||||
"Cache-Control": "no-store",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* **工作流面**(arcrun_* 工具)的租戶代號。知識面(kbdb_*)已不再讀它——
|
||||
* 那邊改成跟著登入者的 portal session 走(見 store.ts PortalIdentity)。
|
||||
*
|
||||
* 為什麼這裡還留著、而且還有預設值:cypher 的 workflow API 是用「租戶代號當 opaque key」
|
||||
* (X-Arcrun-API-Key)認的,不吃 portal session;要拆掉它得先在 cypher 開一組
|
||||
* 吃 portal session 的 workflow 端點。那是下一步,不在本次範圍——
|
||||
* 硬拆會把現在好好的 arcrun_* 弄壞。**誠實記在這裡,不假裝已經解決。**
|
||||
*
|
||||
* ⚠️ 預設值 "leo" 的**知識面**用法已消滅:它曾經是「不管誰登入都看到同一格」的根因
|
||||
* (namespace 直接當 KBDB 的 owner_id 用)。現在它只當工作流面的 API key。
|
||||
*/
|
||||
function workflowTenant(env: Env): string {
|
||||
function ownerNamespace(env: Env): string {
|
||||
return env.MCP_OWNER_NAMESPACE || "leo";
|
||||
}
|
||||
|
||||
/** portal session TTL 讀不到時的保守假設(秒):短的那邊贏,寧可早點要求重連。 */
|
||||
const FALLBACK_PORTAL_SESSION_TTL = 604800; // 7 天(cypher portal.ts 的預設值)
|
||||
|
||||
function tokenTtl(env: Env): number {
|
||||
const n = parseInt(env.MCP_TOKEN_TTL ?? "", 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : DEFAULT_TOKEN_TTL;
|
||||
@@ -264,13 +248,7 @@ export function registerOAuthRoutes<
|
||||
}
|
||||
// 認證下沉到 cypher 的 /portal/login(唯一真相源;同樣吃它的節流與停用檢查)。
|
||||
// 走 service binding(MCP 與 cypher 同帳號,屬 D28 允許的零件級組合)。
|
||||
//
|
||||
// 🔴 2026-08-12(leo:「用登入能做的 mcp 就應該能做,結果要你去打 MCP 時自己找
|
||||
// credential 問題很大」):這裡**接住登入回來的身分**,不再只留 `res.ok`。
|
||||
// 舊版把身分丟掉 ⇒ 查詢時無身分可帶 ⇒ 只好去撈服務內部金鑰(KBDB_INTERNAL_TOKEN)
|
||||
// 直打 KBDB ⇒ 繞過所有庫過濾、而且不管誰登入都看到同一格。根因就在這幾行。
|
||||
let portal: PortalIdentity | null = null;
|
||||
let portalTtl = FALLBACK_PORTAL_SESSION_TTL;
|
||||
let loginOk = false;
|
||||
try {
|
||||
const res = await c.env.CYPHER_EXECUTOR.fetch(
|
||||
new Request("https://cypher/portal/login", {
|
||||
@@ -279,34 +257,11 @@ export function registerOAuthRoutes<
|
||||
body: JSON.stringify({ email, password }),
|
||||
}),
|
||||
);
|
||||
if (res.ok) {
|
||||
const body = (await res.json().catch(() => null)) as {
|
||||
session_token?: unknown;
|
||||
display_name?: unknown;
|
||||
role?: unknown;
|
||||
libraries?: unknown;
|
||||
session_expires_in?: unknown;
|
||||
} | null;
|
||||
const session = typeof body?.session_token === "string" ? body.session_token : "";
|
||||
if (session) {
|
||||
portal = {
|
||||
session,
|
||||
display_name: typeof body?.display_name === "string" ? body.display_name : "",
|
||||
role: typeof body?.role === "string" ? body.role : "user",
|
||||
libraries: Array.isArray(body?.libraries)
|
||||
? body.libraries.filter((x): x is string => typeof x === "string")
|
||||
: [],
|
||||
};
|
||||
const ttl = Number(body?.session_expires_in);
|
||||
if (Number.isFinite(ttl) && ttl > 0) portalTtl = ttl;
|
||||
}
|
||||
}
|
||||
loginOk = res.ok;
|
||||
} catch {
|
||||
return c.html(consentPage(consent, "暫時無法驗證帳密,請稍後再試。"), 503);
|
||||
}
|
||||
if (!portal) {
|
||||
// 帳密不對,或這台 cypher 舊到還不回 session_token。兩者都不可以發碼——
|
||||
// 發了也是一張沒有身分的 token,查什麼都得再找一次 credential,正是要修的病。
|
||||
if (!loginOk) {
|
||||
return c.html(consentPage(consent, "帳號或密碼不正確,請重試。"), 401);
|
||||
}
|
||||
if (!c.env.OAUTH_KV) {
|
||||
@@ -320,9 +275,7 @@ export function registerOAuthRoutes<
|
||||
code_challenge_method: "S256",
|
||||
scope: consent.scope,
|
||||
resource: consent.resource,
|
||||
namespace: workflowTenant(c.env),
|
||||
portal,
|
||||
portal_session_expires_in: portalTtl,
|
||||
namespace: ownerNamespace(c.env),
|
||||
});
|
||||
const location = redirectWith(redirectUri, {
|
||||
code,
|
||||
@@ -365,9 +318,7 @@ export function registerOAuthRoutes<
|
||||
return err("invalid_grant", "PKCE verification failed");
|
||||
}
|
||||
|
||||
// token 活不過它底下的 portal session:否則第 8 天會出現「MCP 還連著、卻什麼都查不到」
|
||||
// ——使用者看到的是壞掉,實際是身分過期。兩者一起到期,重連就是重新輸入帳密,一次搞定。
|
||||
const ttl = Math.min(tokenTtl(c.env), data.portal_session_expires_in || FALLBACK_PORTAL_SESSION_TTL);
|
||||
const ttl = tokenTtl(c.env);
|
||||
const accessToken = randomToken(32);
|
||||
await putAccessToken(
|
||||
c.env.OAUTH_KV,
|
||||
@@ -376,7 +327,6 @@ export function registerOAuthRoutes<
|
||||
namespace: data.namespace,
|
||||
client_id: data.client_id,
|
||||
scope: data.scope,
|
||||
portal: data.portal,
|
||||
// RFC 8707:aud 一律用「本 server canonical resource URI」(非 client 原樣值)。
|
||||
// authorize 已只存 canonical,這裡再以當前 origin 重算一次確保與 partner-auth 嚴格比對一致。
|
||||
aud: resourceUri(originOf(c.req.url)),
|
||||
|
||||
@@ -4,28 +4,6 @@
|
||||
// KV key 一律用 SHA-256 hex(不把 raw code/token 當 key)→ 就算 KV list 也拿不到可用憑證。
|
||||
import { sha256Hex } from "./crypto.js";
|
||||
|
||||
/**
|
||||
* 登入者的身分(authorize 時用帳密換到,之後跟著 token 走)。
|
||||
*
|
||||
* leo 2026-08-12:「掛上 MCP 並輸入帳密,那個動作本身就是授權。」
|
||||
* ⇒ 驗完帳密**不能只留一個布林值**——身分要接住並攜帶,下游才不必再要一次認證。
|
||||
*
|
||||
* `session` 是 cypher `/portal/login` 發的 portal session token,與人類在 portal 網頁上
|
||||
* 拿到的完全同一種。它是「取得的暫時性認證」,正合本檔開頭的儲存鐵律(可進 KV、帶 TTL);
|
||||
* access_token 的 TTL 會被夾到不超過它(見 routes.ts),兩者一起到期,不會出現
|
||||
* 「MCP 還連著、底下 session 早死」的鬼打牆。
|
||||
*
|
||||
* display_name / role / libraries 只是**給人看的回報值**(arcrun_whoami)。
|
||||
* 真正的權限判定每次都由 cypher 回讀 user record 現算——這裡的副本不是判準,
|
||||
* 所以管理員改權限或停用帳號會立刻生效,不必等 token 過期。
|
||||
*/
|
||||
export interface PortalIdentity {
|
||||
session: string;
|
||||
display_name: string;
|
||||
role: string;
|
||||
libraries: string[];
|
||||
}
|
||||
|
||||
/** authorization code 綁定的資料(一次性;/token 驗證後即刪)。 */
|
||||
export interface AuthCodeData {
|
||||
client_id: string;
|
||||
@@ -37,10 +15,6 @@ export interface AuthCodeData {
|
||||
resource: string;
|
||||
/** 換發後 token 綁定的資料分區(owner namespace)。 */
|
||||
namespace: string;
|
||||
/** 這張 code 是誰換的(帳密驗過的那個人)。 */
|
||||
portal: PortalIdentity;
|
||||
/** portal session 剩餘秒數(authorize 當下);access_token TTL 不得超過它。 */
|
||||
portal_session_expires_in: number;
|
||||
}
|
||||
|
||||
/** access token 綁定的資料。 */
|
||||
@@ -52,11 +26,6 @@ export interface AccessTokenData {
|
||||
aud: string;
|
||||
/** 過期時間(epoch 秒),與 KV TTL 雙保險。 */
|
||||
exp: number;
|
||||
/**
|
||||
* 持這張 token 的是誰。**舊 token(本次改版前簽發的)沒有這欄** → undefined,
|
||||
* 知識面工具會誠實要求重新連線,而不是偷偷退回服務金鑰那條老路(fail-closed)。
|
||||
*/
|
||||
portal?: PortalIdentity;
|
||||
}
|
||||
|
||||
const CODE_PREFIX = "oauth:code:";
|
||||
|
||||
@@ -5,75 +5,31 @@
|
||||
* 治本是給 AI 無腦入口:問工具拿身份。CLI 有 acr whoami,MCP 必須對齊(薄殼一致,rule 07 §5)——
|
||||
* 否則「AI 偏好 MCP」時又得繞回 curl。
|
||||
*
|
||||
* 2026-08-12 改:以帳密連線時,「我是誰」的答案是**登入的那個人**(display_name / role /
|
||||
* 可用知識庫),不是一個租戶代號。原本回的 account_namespace 是租戶字串——那東西一旦落到
|
||||
* 呼叫端手上就能拿去直打 /kbdb/*、繞過所有庫過濾(portal-data.ts 檔頭紅線),所以登入身分下
|
||||
* 不再回它。工作流面(arcrun_*)仍用它當 API key,但那只在 server 內部用。
|
||||
*
|
||||
* 薄殼:只如實回報 MCP 已解析的身分,不做推斷、不打任何查詢。
|
||||
* 薄殼:只回報 MCP 已解析的 orgNamespace(綁哪個帳號)+ cypher binding 連向,無業務邏輯。
|
||||
*/
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { toolName } from "../brand.js";
|
||||
import { Env } from "../types.js";
|
||||
import type { KnowledgeIdentity } from "../lib/portal-client.js";
|
||||
|
||||
export function registerWhoami(
|
||||
server: McpServer,
|
||||
env: Env,
|
||||
orgNamespace: string,
|
||||
identity: KnowledgeIdentity,
|
||||
) {
|
||||
export function registerWhoami(server: McpServer, env: Env, orgNamespace: string) {
|
||||
server.tool(
|
||||
toolName("whoami"),
|
||||
"回報這個 MCP 連線目前生效的身份:以帳密連線時回「登入的是誰、能看哪些知識庫」;" +
|
||||
"服務級 token 連線時回綁定的帳號 namespace。部署 / 觸發 / 查 workflow 前先 call 此 tool 確認身份," +
|
||||
"**不要自己 curl 猜帳號 URL**(會打到錯帳號)。",
|
||||
"回報這個 MCP 連線目前生效的身份:綁哪個帳號 / namespace、cypher 連向哪。" +
|
||||
"部署 / 觸發 / 查 workflow 前先 call 此 tool 確認帳號,**不要自己 curl 猜帳號 URL**(會打到錯帳號)。",
|
||||
{},
|
||||
async () => {
|
||||
const base = {
|
||||
// 薄殼:MCP 透過 service binding(CYPHER_EXECUTOR)連 cypher,binding 本身決定連哪台;
|
||||
// 身份來自啟動時解析的 orgNamespace(綁哪個帳號的資料分區)。這裡只如實回報,不做推斷。
|
||||
const identity = {
|
||||
account_namespace: orgNamespace || "(未設)",
|
||||
cypher: "service-binding:CYPHER_EXECUTOR",
|
||||
kbdb: "service-binding:KBDB",
|
||||
note:
|
||||
"此 MCP 已綁定上述帳號。部署/觸發/查詢都走這個身份;勿自行 curl 其他 URL 猜帳號。",
|
||||
};
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(identity, null, 2) }],
|
||||
};
|
||||
|
||||
if (identity.kind === "portal") {
|
||||
const { display_name, role, libraries } = identity.portal;
|
||||
return json({
|
||||
...base,
|
||||
auth: "portal-login(這條連線是有人輸入 Portal 帳密授權的)",
|
||||
logged_in_as: display_name || "(未設顯示名稱)",
|
||||
role,
|
||||
libraries: libraries.length ? libraries : ["(尚未被授權任何知識庫)"],
|
||||
knowledge_scope:
|
||||
libraries.includes("*")
|
||||
? "全部知識庫(此帳號有全庫權限)"
|
||||
: `僅限上列知識庫——kbdb_* 查得到的東西與這個帳號在 portal 網頁上看得到的完全一致`,
|
||||
note:
|
||||
"你是「主人授權的 AI」:主人查得到的你查得到,主人查不到的你也查不到。" +
|
||||
"kbdb_* 不需要任何額外的 credential / 金鑰 / kbdb_base——已經登入過了,不會再問第二次。",
|
||||
});
|
||||
}
|
||||
|
||||
if (identity.kind === "stale") {
|
||||
return json({
|
||||
...base,
|
||||
auth: "舊版 token(沒有登入者身分)",
|
||||
knowledge_scope: "查不到任何知識內容",
|
||||
note:
|
||||
"這條連線是本次改版前簽發的 token。到 claude.ai → Settings → Connectors " +
|
||||
"重新連線一次(輸入 Portal 帳密)即可恢復,不需要找任何 credential。",
|
||||
});
|
||||
}
|
||||
|
||||
return json({
|
||||
...base,
|
||||
auth: "service token(static token / partner key,代表整個實例或租戶,不是某個人)",
|
||||
account_namespace: orgNamespace || "(未設)",
|
||||
note: "此 MCP 已綁定上述帳號。部署/觸發/查詢都走這個身份;勿自行 curl 其他 URL 猜帳號。",
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function json(obj: unknown) {
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(obj, null, 2) }] };
|
||||
}
|
||||
|
||||
+59
-148
@@ -1,27 +1,23 @@
|
||||
/**
|
||||
* KBDB 資料層 MCP 薄殼(kbdb-base Phase 9.1,HANDOFF §2)
|
||||
*
|
||||
* rule 07 §5(薄殼鐵律):能力長在 API,MCP 只做介面轉換 + 暴露,無業務邏輯。
|
||||
*
|
||||
* ── 2026-08-12:改用「登入進來的那個人的身分」查詢 ────────────────────────────
|
||||
* leo:「人類進 Portal 輸入帳密表示你是主人,可以查到你權限所有東西;AI 透過輸入帳密的
|
||||
* MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」
|
||||
* 「掛上 MCP 並輸入帳密,那個動作本身就是授權」⇒ 下游不得再要求第二次認證。
|
||||
*
|
||||
* 之前的路:MCP 驗完帳密只留一個布林值 → 查詢時無身分可帶 → 只好帶**服務內部金鑰**
|
||||
* (KBDB_INTERNAL_TOKEN)直打 KBDB。那條路繞過所有庫過濾,而且不管誰登入都看到同一格。
|
||||
*
|
||||
* 現在的路(identity.kind === 'portal'):帶登入者的 portal session 打 cypher
|
||||
* `/portal/data/*`——庫過濾/租戶注入/停用即時生效全在 server 側,與人類走 portal 網頁
|
||||
* 是**同一道閘、同一份權限**。MCP 這邊一個判斷都不做。
|
||||
*
|
||||
* 服務級憑據(static token / partner key,identity.kind === 'service')維持既有 KBDB 直連,
|
||||
* 零回歸——那類憑據本身就是真祕密、代表整個實例或租戶,不是某個人。
|
||||
* rule 07 §5(薄殼鐵律):能力長在基本盤 API,MCP 只做介面轉換 + 暴露,無業務邏輯。
|
||||
* 全走既有 kbdbFetch(KBDB service binding)打基本盤 HTTP API(kbdb/src/routes/*)。
|
||||
*
|
||||
* KBDB 鐵律(leo 2026-06-14,頂層 DECISION-kbdb-v3-baseplane.md):
|
||||
* - 任何人不准動表;**不提供建表 / SQL tool**。
|
||||
* - AI 想存新類型的資料時只有「建 template(name+slots)+ 填 record(slot→content)」可用。
|
||||
* - 薄殼只調 HTTP API,不直連 D1、不寫 SQL。
|
||||
* - AI 想存新類型的資料時只有「建 template(name+slots)+ 填 record(slot→content)」可用
|
||||
* ——類 Supabase 萬用表,schema 由 template/slot 表達,不是真的 CREATE TABLE。
|
||||
* - 薄殼只調基本盤 HTTP API,不直連 D1、不寫 SQL。
|
||||
*
|
||||
* 基本盤 API 契約(已存在,kbdb/src/routes):
|
||||
* POST /templates { name, slots[], description?, created_by? } → { template }
|
||||
* GET /templates → { templates[], count }
|
||||
* GET /templates/:idOrName → { template }
|
||||
* POST /records { template, values:{slot:content}, owner_id? } → { record }
|
||||
* GET /records/by-template/:t ?owner_id= → { records[], count }
|
||||
* GET /records/:recordId → { record }
|
||||
* GET /entries/search ?q=&owner_id= → { entries[], count, mode:'keyword' }
|
||||
*/
|
||||
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
@@ -29,32 +25,22 @@ import { z } from "zod";
|
||||
import type { Env } from "../types.js";
|
||||
import { kbdbFetch } from "../lib/kbdb-client.js";
|
||||
import { errorResponse, successResponse } from "../lib/cypher-client.js";
|
||||
import {
|
||||
portalFetch,
|
||||
portalError,
|
||||
staleIdentityError,
|
||||
type KnowledgeIdentity,
|
||||
} from "../lib/portal-client.js";
|
||||
|
||||
/** 走 portal 資料面時,呼叫端傳的 owner_id 一律無效(server 用登入者的歸屬)——如實告訴 AI。 */
|
||||
const OWNER_IGNORED_HINT =
|
||||
"owner_id 在登入身分下不生效:查詢範圍由你的帳號權限決定(與你在 portal 網頁看到的一致)";
|
||||
|
||||
/** 註冊全部 KBDB 資料層工具(kbdb-base Phase 9.1)。不含建表/SQL tool(鐵律)。 */
|
||||
export function registerAllKbdbDataTools(server: McpServer, env: Env, identity: KnowledgeIdentity) {
|
||||
registerCreateTemplate(server, env, identity);
|
||||
registerListTemplates(server, env, identity);
|
||||
registerCreateRecord(server, env, identity);
|
||||
registerGetRecord(server, env, identity);
|
||||
registerQuery(server, env, identity);
|
||||
registerSearch(server, env, identity);
|
||||
export function registerAllKbdbDataTools(server: McpServer, env: Env) {
|
||||
registerCreateTemplate(server, env);
|
||||
registerListTemplates(server, env);
|
||||
registerCreateRecord(server, env);
|
||||
registerGetRecord(server, env);
|
||||
registerQuery(server, env);
|
||||
registerSearch(server, env);
|
||||
}
|
||||
|
||||
/**
|
||||
* kbdb_create_template — 建一個 template(= 萬用表裡的一種「虛擬表/資料形狀」)。
|
||||
* 這是 AI 想存「新類型資料」時的唯一入口:沒有建表 API,改用 template + slots 描述欄位。
|
||||
*/
|
||||
export function registerCreateTemplate(server: McpServer, env: Env, identity: KnowledgeIdentity) {
|
||||
export function registerCreateTemplate(server: McpServer, env: Env) {
|
||||
server.tool(
|
||||
"kbdb_create_template",
|
||||
"建一個 KBDB template(萬用表裡的一種資料形狀,類 Supabase 的虛擬表)。KBDB 不能建真的資料表——" +
|
||||
@@ -64,24 +50,16 @@ export function registerCreateTemplate(server: McpServer, env: Env, identity: Kn
|
||||
name: z.string().min(1).describe("template 名稱(唯一識別,之後填 record 用這個名字),如 'contact' / 'note'"),
|
||||
slots: z.array(z.string().min(1)).min(1).describe("欄位名清單,如 ['name','email','phone']"),
|
||||
description: z.string().optional().describe("這個 template 用途的簡述(選填)"),
|
||||
created_by: z.string().optional().describe("建立者標記(選填;登入身分下由 server 記錄,不吃此值)"),
|
||||
created_by: z.string().optional().describe("建立者標記(選填)"),
|
||||
},
|
||||
async ({ name, slots, description, created_by }) => {
|
||||
if (identity.kind === "stale") return staleIdentityError();
|
||||
try {
|
||||
const res =
|
||||
identity.kind === "portal"
|
||||
? await portalFetch(env, identity.portal.session, "/portal/data/templates", {
|
||||
method: "POST",
|
||||
body: { name, slots, description },
|
||||
})
|
||||
: await kbdbFetch(env, "/templates", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, slots, description, created_by }),
|
||||
});
|
||||
const res = await kbdbFetch(env, "/templates", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, slots, description, created_by }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (identity.kind === "portal") return portalError(res, `建 template「${name}」`);
|
||||
return errorResponse("create_template_failed", `建 template 失敗`, ["檢查 name 是否重複", "確認 slots 是非空字串陣列"], await res.text().catch(() => ""));
|
||||
}
|
||||
const data = await res.json();
|
||||
@@ -96,28 +74,17 @@ export function registerCreateTemplate(server: McpServer, env: Env, identity: Kn
|
||||
}
|
||||
|
||||
/** kbdb_list_templates — 列出所有已建的 template(看有哪些資料形狀可用)。 */
|
||||
export function registerListTemplates(server: McpServer, env: Env, identity: KnowledgeIdentity) {
|
||||
export function registerListTemplates(server: McpServer, env: Env) {
|
||||
server.tool(
|
||||
"kbdb_list_templates",
|
||||
"列出 KBDB 裡所有 template(已定義的資料形狀)。要存資料前先看有沒有現成 template 可用,沒有再 kbdb_create_template。",
|
||||
{},
|
||||
async () => {
|
||||
if (identity.kind === "stale") return staleIdentityError();
|
||||
try {
|
||||
const res =
|
||||
identity.kind === "portal"
|
||||
? await portalFetch(env, identity.portal.session, "/portal/data/templates")
|
||||
: await kbdbFetch(env, "/templates");
|
||||
if (!res.ok) {
|
||||
if (identity.kind === "portal") return portalError(res, "列 template");
|
||||
return errorResponse("list_templates_failed", `列 template 失敗`, ["稍後重試"], await res.text().catch(() => ""));
|
||||
}
|
||||
const res = await kbdbFetch(env, "/templates");
|
||||
if (!res.ok) return errorResponse("list_templates_failed", `列 template 失敗`, ["稍後重試"], await res.text().catch(() => ""));
|
||||
const data = await res.json();
|
||||
return successResponse(data, [
|
||||
"每個 template 的 slots_json 是它的欄位清單",
|
||||
"填資料用 kbdb_create_record",
|
||||
"template 是全域共享的「資料形狀」定義(schema),不含任何人的內容——內容的權限在 record/entry 那層",
|
||||
]);
|
||||
return successResponse(data, ["每個 template 的 slots_json 是它的欄位清單", "填資料用 kbdb_create_record"]);
|
||||
} catch (e) {
|
||||
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
|
||||
}
|
||||
@@ -126,7 +93,7 @@ export function registerListTemplates(server: McpServer, env: Env, identity: Kno
|
||||
}
|
||||
|
||||
/** kbdb_create_record — 依某 template 填一筆 record(slot → 內容)。 */
|
||||
export function registerCreateRecord(server: McpServer, env: Env, identity: KnowledgeIdentity) {
|
||||
export function registerCreateRecord(server: McpServer, env: Env) {
|
||||
server.tool(
|
||||
"kbdb_create_record",
|
||||
"依某 template 填一筆 record(一列資料)。values 是 {slot名: 內容},slot 名要對得上 template 的 slots。" +
|
||||
@@ -134,34 +101,23 @@ export function registerCreateRecord(server: McpServer, env: Env, identity: Know
|
||||
{
|
||||
template: z.string().min(1).describe("template 的 name 或 id"),
|
||||
values: z.record(z.string()).describe("欄位內容 {slot名: 字串內容},如 {name:'Leo', email:'leo@x.com'}"),
|
||||
owner_id: z.string().optional().describe("資料歸屬標記(選填;登入身分下一律由 server 定成你的歸屬,不吃此值)"),
|
||||
owner_id: z.string().optional().describe("資料歸屬標記(選填,如專案 id / 用戶 id)"),
|
||||
},
|
||||
async ({ template, values, owner_id }) => {
|
||||
if (identity.kind === "stale") return staleIdentityError();
|
||||
try {
|
||||
const res =
|
||||
identity.kind === "portal"
|
||||
? await portalFetch(env, identity.portal.session, "/portal/data/records", {
|
||||
method: "POST",
|
||||
body: { template, values },
|
||||
})
|
||||
: await kbdbFetch(env, "/records", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ template, values, owner_id }),
|
||||
});
|
||||
const res = await kbdbFetch(env, "/records", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ template, values, owner_id }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (identity.kind === "portal") return portalError(res, `填 record(template「${template}」)`);
|
||||
return errorResponse("create_record_failed", `填 record 失敗`, [
|
||||
`確認 template「${template}」存在(kbdb_list_templates)`,
|
||||
"values 的 slot 名要對得上 template 的 slots",
|
||||
], await res.text().catch(() => ""));
|
||||
}
|
||||
const data = await res.json();
|
||||
return successResponse(data, [
|
||||
`已存入。用 kbdb_query(template='${template}') 列出此 template 的所有 record`,
|
||||
...(identity.kind === "portal" ? [OWNER_IGNORED_HINT] : []),
|
||||
]);
|
||||
return successResponse(data, [`已存入。用 kbdb_query(template='${template}') 列出此 template 的所有 record`]);
|
||||
} catch (e) {
|
||||
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
|
||||
}
|
||||
@@ -170,7 +126,7 @@ export function registerCreateRecord(server: McpServer, env: Env, identity: Know
|
||||
}
|
||||
|
||||
/** kbdb_get_record — 用 record_id 取單筆 record。 */
|
||||
export function registerGetRecord(server: McpServer, env: Env, identity: KnowledgeIdentity) {
|
||||
export function registerGetRecord(server: McpServer, env: Env) {
|
||||
server.tool(
|
||||
"kbdb_get_record",
|
||||
"用 record_id 取一筆 record 的所有欄位內容。record_id 從 kbdb_create_record 回傳或 kbdb_query 列出取得。",
|
||||
@@ -178,23 +134,10 @@ export function registerGetRecord(server: McpServer, env: Env, identity: Knowled
|
||||
record_id: z.string().min(1).describe("record 的 id(rec_xxx)"),
|
||||
},
|
||||
async ({ record_id }) => {
|
||||
if (identity.kind === "stale") return staleIdentityError();
|
||||
try {
|
||||
const res =
|
||||
identity.kind === "portal"
|
||||
? await portalFetch(env, identity.portal.session, `/portal/data/records/${encodeURIComponent(record_id)}`)
|
||||
: await kbdbFetch(env, `/records/${encodeURIComponent(record_id)}`);
|
||||
if (res.status === 404) {
|
||||
// 登入身分下,「不是你的」與「不存在」刻意同回 404(不洩存在性,portal 同一條紅線)。
|
||||
return errorResponse("not_found", `查無 record「${record_id}」(不存在,或不在你的權限範圍內)`, [
|
||||
"確認 record_id 正確",
|
||||
"用 kbdb_query 列出某 template 的 record 取 id",
|
||||
]);
|
||||
}
|
||||
if (!res.ok) {
|
||||
if (identity.kind === "portal") return portalError(res, "取 record");
|
||||
return errorResponse("get_record_failed", `取 record 失敗`, ["稍後重試"], await res.text().catch(() => ""));
|
||||
}
|
||||
const res = await kbdbFetch(env, `/records/${encodeURIComponent(record_id)}`);
|
||||
if (res.status === 404) return errorResponse("not_found", `record「${record_id}」不存在`, ["確認 record_id 正確", "用 kbdb_query 列出某 template 的 record 取 id"]);
|
||||
if (!res.ok) return errorResponse("get_record_failed", `取 record 失敗`, ["稍後重試"], await res.text().catch(() => ""));
|
||||
const data = await res.json();
|
||||
return successResponse(data);
|
||||
} catch (e) {
|
||||
@@ -205,39 +148,21 @@ export function registerGetRecord(server: McpServer, env: Env, identity: Knowled
|
||||
}
|
||||
|
||||
/** kbdb_query — 列出某 template 底下的所有 record(結構化查詢)。 */
|
||||
export function registerQuery(server: McpServer, env: Env, identity: KnowledgeIdentity) {
|
||||
export function registerQuery(server: McpServer, env: Env) {
|
||||
server.tool(
|
||||
"kbdb_query",
|
||||
"列出某 template 底下的所有 record(結構化查詢,按 template 取整批資料)。要按關鍵字找內容用 kbdb_search。",
|
||||
{
|
||||
template: z.string().min(1).describe("template 的 name 或 id"),
|
||||
owner_id: z.string().optional().describe("只取某歸屬的 record(選填;登入身分下不生效,範圍由你的權限決定)"),
|
||||
owner_id: z.string().optional().describe("只取某歸屬的 record(選填)"),
|
||||
},
|
||||
async ({ template, owner_id }) => {
|
||||
if (identity.kind === "stale") return staleIdentityError();
|
||||
try {
|
||||
const res =
|
||||
identity.kind === "portal"
|
||||
? await portalFetch(
|
||||
env,
|
||||
identity.portal.session,
|
||||
`/portal/data/records/by-template/${encodeURIComponent(template)}`,
|
||||
)
|
||||
: await kbdbFetch(
|
||||
env,
|
||||
`/records/by-template/${encodeURIComponent(template)}` +
|
||||
(owner_id ? `?owner_id=${encodeURIComponent(owner_id)}` : ""),
|
||||
);
|
||||
if (!res.ok) {
|
||||
if (identity.kind === "portal") return portalError(res, `查詢 template「${template}」的 record`);
|
||||
return errorResponse("query_failed", `查詢 record 失敗`, [`確認 template「${template}」存在`], await res.text().catch(() => ""));
|
||||
}
|
||||
const path = `/records/by-template/${encodeURIComponent(template)}` + (owner_id ? `?owner_id=${encodeURIComponent(owner_id)}` : "");
|
||||
const res = await kbdbFetch(env, path);
|
||||
if (!res.ok) return errorResponse("query_failed", `查詢 record 失敗`, [`確認 template「${template}」存在`], await res.text().catch(() => ""));
|
||||
const data = await res.json();
|
||||
return successResponse(data, [
|
||||
"用 kbdb_get_record(record_id) 取單筆全文",
|
||||
"按關鍵字找內容改用 kbdb_search",
|
||||
...(identity.kind === "portal" ? [OWNER_IGNORED_HINT] : []),
|
||||
]);
|
||||
return successResponse(data, ["用 kbdb_get_record(record_id) 取單筆全文", "按關鍵字找內容改用 kbdb_search"]);
|
||||
} catch (e) {
|
||||
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
|
||||
}
|
||||
@@ -250,38 +175,26 @@ export function registerQuery(server: McpServer, env: Env, identity: KnowledgeId
|
||||
* 語義/關鍵字都在同一 KBDB MCP(用戶資料 RAG),不分散(issue #7 / D17 邊界)。
|
||||
* mode=semantic 但沒開 vectorize → base 自動降級 keyword + 回 capability_hint(發現閉環,叫 CC 幫開)。
|
||||
*/
|
||||
export function registerSearch(server: McpServer, env: Env, identity: KnowledgeIdentity) {
|
||||
export function registerSearch(server: McpServer, env: Env) {
|
||||
server.tool(
|
||||
"kbdb_search",
|
||||
"搜尋 KBDB 內容。mode='keyword'(預設,D1 LIKE 關鍵字,基本盤永遠可用)或 'semantic'(AI 向量語義搜尋," +
|
||||
"需先開 embed 模組)。語義沒開時會自動降級關鍵字並告訴你怎麼開。要按 template 取整批結構化資料用 kbdb_query。",
|
||||
{
|
||||
q: z.string().min(1).describe("搜尋關鍵字 / 語義查詢句"),
|
||||
owner_id: z.string().optional().describe("限定某歸屬範圍內搜(選填;登入身分下不生效,範圍由你的權限決定)"),
|
||||
owner_id: z.string().optional().describe("限定某歸屬範圍內搜(選填)"),
|
||||
source: z.string().optional().describe("只搜某來源(ingest source.uri,選填)"),
|
||||
mode: z.enum(["keyword", "semantic"]).optional().describe("keyword(預設)或 semantic(需開 vectorize)"),
|
||||
},
|
||||
async ({ q, owner_id, source, mode }) => {
|
||||
if (identity.kind === "stale") return staleIdentityError();
|
||||
try {
|
||||
let res: Response;
|
||||
if (identity.kind === "portal") {
|
||||
// /portal/data/search 只吃在權限範圍內「再收窄」的 filter;owner_id/library 由 server 定死。
|
||||
res = await portalFetch(env, identity.portal.session, "/portal/data/search", {
|
||||
query: { q, mode },
|
||||
});
|
||||
} else {
|
||||
const qs = new URLSearchParams({ q });
|
||||
if (owner_id) qs.set("owner_id", owner_id);
|
||||
if (source) qs.set("source", source);
|
||||
if (mode) qs.set("mode", mode);
|
||||
res = await kbdbFetch(env, `/entries/search?${qs.toString()}`);
|
||||
}
|
||||
if (!res.ok) {
|
||||
if (identity.kind === "portal") return portalError(res, "搜尋");
|
||||
return errorResponse("search_failed", `搜尋失敗`, ["稍後重試"], await res.text().catch(() => ""));
|
||||
}
|
||||
const data = (await res.json()) as { mode?: string; capability_hint?: string; note?: string };
|
||||
const qs = new URLSearchParams({ q });
|
||||
if (owner_id) qs.set("owner_id", owner_id);
|
||||
if (source) qs.set("source", source);
|
||||
if (mode) qs.set("mode", mode);
|
||||
const res = await kbdbFetch(env, `/entries/search?${qs.toString()}`);
|
||||
if (!res.ok) return errorResponse("search_failed", `搜尋失敗`, ["稍後重試"], await res.text().catch(() => ""));
|
||||
const data = (await res.json()) as { mode?: string; capability_hint?: string };
|
||||
// base 回 capability_hint → 語義沒開、已降級 keyword。把它當 next-step 傳給 AI(發現閉環)。
|
||||
const hints =
|
||||
data.capability_hint
|
||||
@@ -289,8 +202,6 @@ export function registerSearch(server: McpServer, env: Env, identity: KnowledgeI
|
||||
: data.mode === "semantic"
|
||||
? ["mode:semantic = AI 向量語義搜尋"]
|
||||
: ["mode:keyword = D1 LIKE(基本盤)", "想要語義搜尋:mode='semantic'(需先開 vectorize)"];
|
||||
if (identity.kind === "portal") hints.push(OWNER_IGNORED_HINT);
|
||||
if (data.note) hints.push(data.note);
|
||||
return successResponse(data, hints);
|
||||
} catch (e) {
|
||||
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
|
||||
|
||||
@@ -23,12 +23,6 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import type { Env } from "../types.js";
|
||||
import { cypherFetch, errorResponse, successResponse } from "../lib/cypher-client.js";
|
||||
import {
|
||||
portalFetch,
|
||||
portalError,
|
||||
staleIdentityError,
|
||||
type KnowledgeIdentity,
|
||||
} from "../lib/portal-client.js";
|
||||
|
||||
/** graph 查詢 workflow 名(與 registry/examples/graph-neighbors/workflow.yaml 的 name 一致)。 */
|
||||
export const GRAPH_NEIGHBORS_WORKFLOW = "graph_neighbors";
|
||||
@@ -41,13 +35,8 @@ const INSTALL_HINTS = [
|
||||
];
|
||||
|
||||
/** 註冊全部 KBDB graph 查詢工具(issue #68)。 */
|
||||
export function registerAllKbdbGraphTools(
|
||||
server: McpServer,
|
||||
env: Env,
|
||||
orgNamespace: string,
|
||||
identity: KnowledgeIdentity,
|
||||
) {
|
||||
registerGraphNeighbors(server, env, orgNamespace, identity);
|
||||
export function registerAllKbdbGraphTools(server: McpServer, env: Env, orgNamespace: string) {
|
||||
registerGraphNeighbors(server, env, orgNamespace);
|
||||
// graph_traverse:repo 內目前只有 graph-neighbors 有 workflow 定義(registry/examples/),
|
||||
// traverse 尚無可對齊的 input 形狀 → 不猜、不過度工程;等 workflow 進 registry 再加薄殼。
|
||||
}
|
||||
@@ -56,12 +45,7 @@ export function registerAllKbdbGraphTools(
|
||||
* kbdb_graph_neighbors — knowledge graph 1-hop/N-hop 鄰居查詢。
|
||||
* 薄殼調 GET /q/{ns}/graph_neighbors,結果(最終節點輸出)原樣回給 MCP client。
|
||||
*/
|
||||
export function registerGraphNeighbors(
|
||||
server: McpServer,
|
||||
env: Env,
|
||||
orgNamespace: string,
|
||||
identity: KnowledgeIdentity,
|
||||
) {
|
||||
export function registerGraphNeighbors(server: McpServer, env: Env, orgNamespace: string) {
|
||||
server.tool(
|
||||
"kbdb_graph_neighbors",
|
||||
"knowledge graph 鄰居查詢(1-hop/N-hop 關係遍歷):給一個節點名,沿 KBDB triplet" +
|
||||
@@ -76,10 +60,10 @@ export function registerGraphNeighbors(
|
||||
depth: z.number().int().min(1).max(10).optional().describe(
|
||||
"最大跳數(N-hop),預設 1(只看直接鄰居)",
|
||||
),
|
||||
kbdb_base: z.string().min(1).optional().describe(
|
||||
"【登入身分下不需要,留空即可】你自己部署的 KBDB 對外 base URL。" +
|
||||
"以帳密連線的 MCP 由 server 端自己知道要查哪個庫——不必、也不該由你指定" +
|
||||
"(指定了也不會採用)。只有服務級 token(static token / partner key)連線時才需要填。",
|
||||
kbdb_base: z.string().min(1).describe(
|
||||
"你自己部署的 KBDB 對外 base URL(如 https://arcrun-kbdb.<你的subdomain>.workers.dev " +
|
||||
"或 KBDB custom domain)。workflow 刻意不寫死任何一家的庫——" +
|
||||
"帶錯(或照抄別人的值)=查詢打進別人的庫",
|
||||
),
|
||||
template: z.string().optional().describe(
|
||||
"triplet 記錄的 template 名,預設 'graph_triplet'(以實際部署的 kbdb-graph-plugin " +
|
||||
@@ -90,43 +74,6 @@ export function registerGraphNeighbors(
|
||||
),
|
||||
},
|
||||
async ({ subject, depth, kbdb_base, template, directed }) => {
|
||||
if (identity.kind === "stale") return staleIdentityError();
|
||||
|
||||
// ── 登入身分:走 cypher 的 portal 資料面(與人類在 portal 按「關聯」同一支端點)──
|
||||
// 那支已經有 D-4 graph 粗閘(沒有 graph 來源庫權限 → 403),也已經處理好
|
||||
// 「這台實例沒裝 graph plugin 就改用 tenant 的 graph_neighbors workflow」的兩條路。
|
||||
// ⇒ MCP 不必要 kbdb_base、不必知道租戶、不必再認證一次。
|
||||
if (identity.kind === "portal") {
|
||||
try {
|
||||
const res = await portalFetch(
|
||||
env,
|
||||
identity.portal.session,
|
||||
`/portal/data/graph/neighbors/${encodeURIComponent(subject)}`,
|
||||
{ query: { depth: depth ?? 1 } },
|
||||
);
|
||||
if (!res.ok) return portalError(res, `查「${subject}」的鄰居`);
|
||||
const out = (await res.json().catch(() => null)) as
|
||||
| { neighbors?: unknown[]; edges?: unknown[]; count?: number }
|
||||
| null;
|
||||
return successResponse(out, [
|
||||
`${out?.count ?? 0} 個鄰居(depth 上限 ${depth ?? 1})`,
|
||||
"count=0 且不確定資料有沒有進圖:kbdb_query(template='triplet') 看三元組記錄",
|
||||
"找關鍵字內容改用 kbdb_search;取單筆全文用 kbdb_get_record",
|
||||
"查詢範圍=你這個帳號被授權的知識庫(與 portal 網頁上的關聯檢視一致)",
|
||||
]);
|
||||
} catch (e) {
|
||||
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 服務級憑據:既有路徑(打 /q/:ns/graph_neighbors workflow),行為零變更 ──
|
||||
if (!kbdb_base) {
|
||||
return errorResponse(
|
||||
"kbdb_base_required",
|
||||
"以服務級 token 連線時,graph 查詢需要 kbdb_base(你自己 KBDB 的對外 URL)",
|
||||
["改用帳密連線(OAuth)則不需要此參數", "或帶上 kbdb_base 再試一次"],
|
||||
);
|
||||
}
|
||||
if (!orgNamespace) {
|
||||
return errorResponse(
|
||||
"no_namespace",
|
||||
|
||||
+11
-36
@@ -22,12 +22,6 @@ import type { Env } from "../types.js";
|
||||
import { kbdbFetch } from "../lib/kbdb-client.js";
|
||||
import { errorResponse, successResponse } from "../lib/cypher-client.js";
|
||||
import { entityNames, parseSlotArray, type LibraryMapRow } from "../lib/library-map.js";
|
||||
import {
|
||||
portalFetch,
|
||||
portalError,
|
||||
staleIdentityError,
|
||||
type KnowledgeIdentity,
|
||||
} from "../lib/portal-client.js";
|
||||
|
||||
/**
|
||||
* 空庫/404 時的指引(誠實回報+給下一步,鐵律:不假綠)。
|
||||
@@ -45,8 +39,8 @@ const RECOMPUTE_HINTS = [
|
||||
];
|
||||
|
||||
/** 註冊全部藏書地圖工具(library-map M4)。 */
|
||||
export function registerAllKbdbMapTools(server: McpServer, env: Env, identity: KnowledgeIdentity) {
|
||||
registerGetMap(server, env, identity);
|
||||
export function registerAllKbdbMapTools(server: McpServer, env: Env) {
|
||||
registerGetMap(server, env);
|
||||
}
|
||||
|
||||
/** 單庫詳圖回傳形狀(GET /map/:library 的 map,slot 陣列已 parse 成物件)。 */
|
||||
@@ -68,7 +62,7 @@ interface LibraryMapDetail {
|
||||
* kbdb_get_map — 藏書地圖。無參數=全館(每庫一行);帶 library=該庫詳圖。
|
||||
* design §6 retrieval 流程的第一站:地圖 → get_map(library) 細節 → graph/search 進庫。
|
||||
*/
|
||||
export function registerGetMap(server: McpServer, env: Env, identity: KnowledgeIdentity) {
|
||||
export function registerGetMap(server: McpServer, env: Env) {
|
||||
server.tool(
|
||||
"kbdb_get_map",
|
||||
"藏書地圖:KBDB 全館導覽。不帶參數=全館地圖(每庫一行:庫名+narrative+核心 top 3 entities+" +
|
||||
@@ -79,26 +73,15 @@ export function registerGetMap(server: McpServer, env: Env, identity: KnowledgeI
|
||||
library: z.string().min(1).optional().describe(
|
||||
"庫名(如 'kb'/'notes')。帶了回該庫詳圖;不帶回全館地圖(先看全館再挑庫)",
|
||||
),
|
||||
owner_id: z.string().optional().describe(
|
||||
"限定某資料歸屬範圍(選填;登入身分下不生效,看得到哪些庫由你的帳號權限決定)",
|
||||
),
|
||||
owner_id: z.string().optional().describe("限定某資料歸屬範圍(選填,與其他 kbdb_* 工具同義)"),
|
||||
},
|
||||
async ({ library, owner_id }) => {
|
||||
if (identity.kind === "stale") return staleIdentityError();
|
||||
try {
|
||||
// 登入身分:走 cypher 的 portal 資料面 —— 只會回這個帳號有權限的庫
|
||||
//(KBDB 的 /map 對權限無知,會回全館;過濾在 cypher 那邊 server 側做)。
|
||||
const isPortal = identity.kind === "portal";
|
||||
const qs = !isPortal && owner_id ? `?owner_id=${encodeURIComponent(owner_id)}` : "";
|
||||
const mapFetch = (path: string) =>
|
||||
identity.kind === "portal"
|
||||
? portalFetch(env, identity.portal.session, `/portal/data${path}`)
|
||||
: kbdbFetch(env, path);
|
||||
const qs = owner_id ? `?owner_id=${encodeURIComponent(owner_id)}` : "";
|
||||
|
||||
if (!library) {
|
||||
// 全館地圖:每庫一行(library+narrative+top 3 entities+triplet_count)。
|
||||
const res = await mapFetch(`/map${qs}`);
|
||||
if (!res.ok && isPortal) return portalError(res, "取全館地圖");
|
||||
const res = await kbdbFetch(env, `/map${qs}`);
|
||||
if (!res.ok) {
|
||||
return errorResponse(
|
||||
"map_fetch_failed",
|
||||
@@ -107,7 +90,7 @@ export function registerGetMap(server: McpServer, env: Env, identity: KnowledgeI
|
||||
await res.text().catch(() => ""),
|
||||
);
|
||||
}
|
||||
const data = (await res.json()) as { libraries?: LibraryMapRow[]; count?: number; note?: string };
|
||||
const data = (await res.json()) as { libraries?: LibraryMapRow[]; count?: number };
|
||||
const libraries = (Array.isArray(data.libraries) ? data.libraries : []).map((l) => ({
|
||||
...l,
|
||||
// 防禦:top_entities 若是 JSON 字串形就 parse 成名字清單(失敗當空,誠實不 crash)。
|
||||
@@ -118,13 +101,8 @@ export function registerGetMap(server: McpServer, env: Env, identity: KnowledgeI
|
||||
// 空庫誠實回報:不是錯誤(端點正常)。地圖是讀時即時核對重算的(見 RECOMPUTE_HINTS
|
||||
// 註解),所以「地圖是空的」現在真的等於「這個租戶目前沒有任何三元組資料」,
|
||||
// 不再是「沒人跑過 recompute」那種曖昧狀態。
|
||||
// 登入身分下還有第二種可能:這個帳號一個庫都沒被授權——「沒權限看」與「沒有資料」
|
||||
// 不可以長得一樣,所以分開講(cypher 端會附 note 說明)。
|
||||
return successResponse({ libraries: [], count: 0 }, [
|
||||
isPortal
|
||||
? "看不到任何庫:可能是這個知識庫真的還沒有三元組資料,也可能是你的帳號還沒被授權任何庫——請向管理員確認你的可用知識庫"
|
||||
: "全館地圖是空的:這個租戶目前沒有任何三元組資料(不是地圖沒算,是真的還沒有資料)",
|
||||
...(data.note ? [data.note] : []),
|
||||
"全館地圖是空的:這個租戶目前沒有任何三元組資料(不是地圖沒算,是真的還沒有資料)",
|
||||
...RECOMPUTE_HINTS,
|
||||
]);
|
||||
}
|
||||
@@ -135,7 +113,7 @@ export function registerGetMap(server: McpServer, env: Env, identity: KnowledgeI
|
||||
}
|
||||
|
||||
// 單庫詳圖:完整 slots(slot 陣列 parse 成物件再回)。
|
||||
const res = await mapFetch(`/map/${encodeURIComponent(library)}${qs}`);
|
||||
const res = await kbdbFetch(env, `/map/${encodeURIComponent(library)}${qs}`);
|
||||
if (res.status === 404) {
|
||||
// 地圖是讀時即時核對重算的:只要這個庫「已知」(有三元組、entries 蓋過章、或登記過),
|
||||
// 上一步就會自動把它補成一筆 triplet_count:0 的地圖,走不到這個分支。真的落到 404,
|
||||
@@ -143,13 +121,10 @@ export function registerGetMap(server: McpServer, env: Env, identity: KnowledgeI
|
||||
// (可能打錯字,或這個庫在別的租戶/別的 owner_id 底下)。
|
||||
return errorResponse(
|
||||
"map_not_found",
|
||||
isPortal
|
||||
? `查無庫「${library}」——這個名字不存在,或不在你被授權的知識庫範圍內(兩者刻意同一句話,不洩漏某個庫存不存在)`
|
||||
: `查無庫「${library}」——這個名字在這個租戶的資料裡從沒出現過(不是「這庫是空的」,是根本沒有這個庫;地圖是即時核對重算的,不是忘了 recompute)`,
|
||||
["kbdb_get_map 不帶參數看全館有哪些庫(確認庫名/確認你有權限的庫)", ...RECOMPUTE_HINTS],
|
||||
`查無庫「${library}」——這個名字在這個租戶的資料裡從沒出現過(不是「這庫是空的」,是根本沒有這個庫;地圖是即時核對重算的,不是忘了 recompute)`,
|
||||
["kbdb_get_map 不帶參數看全館有哪些庫(確認庫名)", ...RECOMPUTE_HINTS],
|
||||
);
|
||||
}
|
||||
if (!res.ok && isPortal) return portalError(res, `取庫「${library}」詳圖`);
|
||||
if (!res.ok) {
|
||||
return errorResponse(
|
||||
"map_fetch_failed",
|
||||
|
||||
@@ -20,15 +20,8 @@ import { registerAllKbdbDataTools } from "./kbdb_data.js";
|
||||
import { registerAllKbdbGraphTools } from "./kbdb_graph.js";
|
||||
import { registerAllKbdbMapTools } from "./kbdb_map.js";
|
||||
import { registerWhoami } from "./arcrun_whoami.js";
|
||||
import type { KnowledgeIdentity } from "../lib/portal-client.js";
|
||||
|
||||
export function registerAllTools(
|
||||
server: McpServer,
|
||||
env: Env,
|
||||
orgNamespace: string,
|
||||
partnerToken: string,
|
||||
identity: KnowledgeIdentity,
|
||||
) {
|
||||
export function registerAllTools(server: McpServer, env: Env, orgNamespace: string, partnerToken: string) {
|
||||
registerSearchComponents(server, env, orgNamespace);
|
||||
// 🔴 2026-07-21 leo 拍板停用:零件走 PR、專業等級;recipe/workflow/app 誰都可以做。
|
||||
// 零件貢獻**只有一條路=PR 人審**(leo 2026-08-01:「已經沒有 publish 了,
|
||||
@@ -60,15 +53,13 @@ export function registerAllTools(
|
||||
registerAllRecipeTools(server, env);
|
||||
// kbdb-base Phase 9.1: KBDB 資料層薄殼(template/record/query/search,HANDOFF §2)
|
||||
// 鐵律:不提供建表/SQL tool,AI 只有 template+slot 可用(類 Supabase 萬用表)
|
||||
// 2026-08-12:知識面(kbdb_*)全部改吃 identity——以帳密連線者走 portal 資料面
|
||||
// (權限=那個人的權限),服務級憑據維持既有 KBDB 直連。見 lib/portal-client.ts。
|
||||
registerAllKbdbDataTools(server, env, identity);
|
||||
registerAllKbdbDataTools(server, env);
|
||||
// issue #68: KBDB graph 查詢薄殼(kbdb_graph_neighbors,調 /q/:ns/graph_neighbors 同步查詢端點)
|
||||
// 補齊 D17「KBDB MCP=RAG 套餐」第三模式:關鍵字/語義之外的圖(關係遍歷)
|
||||
registerAllKbdbGraphTools(server, env, orgNamespace, identity);
|
||||
registerAllKbdbGraphTools(server, env, orgNamespace);
|
||||
// library-map SDD M4(Arcrun#39): 藏書地圖薄殼(kbdb_get_map,調 kbdb GET /map//map/:library)
|
||||
// retrieval 第一站:先看地圖定位庫,再 search/graph 進庫(design §6)
|
||||
registerAllKbdbMapTools(server, env, identity);
|
||||
registerAllKbdbMapTools(server, env);
|
||||
// §7.8 P1 D2: whoami(與 CLI acr whoami 對齊,AI 不繞 CLI 自己 curl 猜帳號)
|
||||
registerWhoami(server, env, orgNamespace, identity);
|
||||
registerWhoami(server, env, orgNamespace);
|
||||
}
|
||||
|
||||
+4
-18
@@ -2,15 +2,6 @@ export interface Env {
|
||||
COMPONENT_REGISTRY: Fetcher;
|
||||
CYPHER_EXECUTOR: Fetcher;
|
||||
KBDB: Fetcher;
|
||||
/**
|
||||
* KBDB 的服務內部金鑰。
|
||||
*
|
||||
* 2026-08-12 後**知識面(kbdb_*)以帳密連線時完全不用它**——那條路改走 cypher 的
|
||||
* `/portal/data/*`,帶的是登入者自己的 portal session。它現在只剩兩個用途:
|
||||
* ① 官方 SaaS 的 partner-key 驗證(middleware/partner-auth.ts 第 3 條)
|
||||
* ② 服務級 token(static token)連線時的既有 KBDB 直連(零回歸)
|
||||
* 兩者都拆掉之後,這個 binding 才能從 MCP 移除。
|
||||
*/
|
||||
KBDB_INTERNAL_TOKEN: string;
|
||||
API_KEY?: string;
|
||||
// Platform telemetry / feedback aggregation key (optional)
|
||||
@@ -29,16 +20,11 @@ export interface Env {
|
||||
// 短效認證儲存:authorization code(TTL ~600s)+ access token(TTL = MCP_TOKEN_TTL)。
|
||||
// 只放「取得的暫時性認證」,key 用 SHA-256 hash(KV list 不外洩可用 token)。長效機密不進 KV。
|
||||
OAUTH_KV?: KVNamespace;
|
||||
// 【已停用,2026-07-30】舊的 owner 祕密。把關改成「使用者自己的 Portal 帳密」——
|
||||
// 沒人給得了封測者這把祕密(安裝器產生後從不顯示、CF secret 又讀不回),
|
||||
// 而且全實例共用一把、分不出是誰連上來的。程式已不再讀它;欄位留著只為不讓舊 toml 炸掉。
|
||||
// Owner 祕密(CF Secret,非 KV、非明碼 var):/authorize 同意頁的把關密碼。
|
||||
// 只有 owner 知道 → 「只知 URL + 明碼 namespace」的人走不完 OAuth,拿不到 token。
|
||||
// 未設 → OAuth /authorize 回 503(拒絕在無把關下發碼,不留不安全預設)。
|
||||
MCP_OWNER_SECRET?: string;
|
||||
// **工作流面**(arcrun_* 工具)的租戶代號,當 cypher 的 X-Arcrun-API-Key 用。預設 "leo"。
|
||||
//
|
||||
// ⚠️ 2026-08-12 起**知識面(kbdb_*)不再讀這個欄位**:那邊改成跟著登入者的 portal session
|
||||
// 走(oauth/store.ts PortalIdentity)。此欄位曾被當成 KBDB 的 owner_id ⇒ 不管誰登入
|
||||
// 都看到同一格、而且是全部——那個用法已經消滅。
|
||||
// 要連工作流面也拆掉它,得先在 cypher 開一組吃 portal session 的 workflow 端點(下一步)。
|
||||
// OAuth 換發出的 access_token 綁定的 namespace(owner 的資料分區)。預設 "leo"。
|
||||
MCP_OWNER_NAMESPACE?: string;
|
||||
// access_token 存活秒數(同時是 KV TTL)。字串(toml var)。預設 2592000(30 天)。
|
||||
// 過期後 claude.ai 重走 OAuth(owner 重輸祕密)——刻意不做 refresh token 以免長效機密落地。
|
||||
|
||||
+12
-202
@@ -59,55 +59,14 @@ async function pkcePair() {
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
/**
|
||||
* cypher `/portal/login` 的假替身(2026-08-12 起 MCP 的把關就是這支——用使用者自己的
|
||||
* Portal 帳密,沒有另一把 owner secret)。帳密對 → 回 session_token + 身分欄位;不對 → 401。
|
||||
*/
|
||||
const GOOD_EMAIL = "leo@example.com";
|
||||
const GOOD_PASSWORD = "correct horse";
|
||||
|
||||
function cypherMock(
|
||||
over: {
|
||||
/** null = 登入成功但**不回** session_token(舊版 cypher);預設回 "sess-abc" */
|
||||
sessionToken?: string | null;
|
||||
displayName?: string;
|
||||
role?: string;
|
||||
libraries?: string[];
|
||||
sessionExpiresIn?: number;
|
||||
} = {},
|
||||
): { fetcher: Fetcher; calls: Array<{ email: string; password: string }> } {
|
||||
const calls: Array<{ email: string; password: string }> = [];
|
||||
const fetcher = {
|
||||
async fetch(req: Request) {
|
||||
const body = (await req.json()) as { email: string; password: string };
|
||||
calls.push(body);
|
||||
if (body.email !== GOOD_EMAIL || body.password !== GOOD_PASSWORD) {
|
||||
return new Response(JSON.stringify({ error: "email 或密碼錯誤" }), { status: 401 });
|
||||
}
|
||||
const sessionToken = over.sessionToken === undefined ? "sess-abc" : over.sessionToken;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
...(sessionToken ? { session_token: sessionToken } : {}),
|
||||
display_name: over.displayName ?? "Leo",
|
||||
role: over.role ?? "admin",
|
||||
libraries: over.libraries ?? ["*"],
|
||||
session_expires_in: over.sessionExpiresIn ?? 604800,
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
},
|
||||
} as unknown as Fetcher;
|
||||
return { fetcher, calls };
|
||||
}
|
||||
|
||||
function baseEnv(over: Partial<Env> = {}): Env {
|
||||
return {
|
||||
COMPONENT_REGISTRY: {} as Fetcher,
|
||||
CYPHER_EXECUTOR: cypherMock().fetcher,
|
||||
CYPHER_EXECUTOR: {} as Fetcher,
|
||||
KBDB: {} as Fetcher,
|
||||
KBDB_INTERNAL_TOKEN: "internal",
|
||||
OAUTH_KV: makeKV(),
|
||||
MCP_OWNER_SECRET: "s3cr3t-owner",
|
||||
MCP_OWNER_NAMESPACE: "leo",
|
||||
...over,
|
||||
} as Env;
|
||||
@@ -162,8 +121,6 @@ describe("oauth/store", () => {
|
||||
scope: "mcp",
|
||||
resource: "https://mcp/mcp",
|
||||
namespace: "leo",
|
||||
portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["*"] },
|
||||
portal_session_expires_in: 604800,
|
||||
});
|
||||
const first = await consumeAuthCode(kv, "code-1");
|
||||
expect(first?.namespace).toBe("leo");
|
||||
@@ -314,11 +271,7 @@ describe("oauth flow (整合)", () => {
|
||||
)}&code_challenge=${challenge}&code_challenge_method=S256&state=xyz&scope=mcp`,
|
||||
);
|
||||
expect(ok.status).toBe(200);
|
||||
const consentHtml = await ok.text();
|
||||
// 同意頁問的是 Portal 帳密(不是另一把 owner secret)
|
||||
expect(consentHtml).toContain("Portal");
|
||||
expect(consentHtml).toContain('name="email"');
|
||||
expect(consentHtml).toContain('name="password"');
|
||||
expect(await ok.text()).toContain("Owner 祕密");
|
||||
// 缺 PKCE → 400
|
||||
const bad = await app.req(
|
||||
`/authorize?response_type=code&client_id=c1&redirect_uri=${encodeURIComponent(
|
||||
@@ -328,19 +281,18 @@ describe("oauth flow (整合)", () => {
|
||||
expect(bad.status).toBe(400);
|
||||
});
|
||||
|
||||
it("GET /authorize:不需要任何 owner 祕密就看得到同意頁(封測者接自己的 AI 不會死在這頁)", async () => {
|
||||
// 舊行為:未設 MCP_OWNER_SECRET → 503 ⇒ 每個封測者都卡住。現在把關是 Portal 帳密。
|
||||
const app = buildApp(baseEnv());
|
||||
it("GET /authorize:MCP_OWNER_SECRET 未設 → 503(不留不安全預設)", async () => {
|
||||
const app = buildApp(baseEnv({ MCP_OWNER_SECRET: undefined }));
|
||||
const { challenge } = await pkcePair();
|
||||
const r = await app.req(
|
||||
`/authorize?response_type=code&client_id=c1&redirect_uri=${encodeURIComponent(
|
||||
"https://claude.ai/cb",
|
||||
)}&code_challenge=${challenge}&code_challenge_method=S256`,
|
||||
);
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.status).toBe(503);
|
||||
});
|
||||
|
||||
it("完整 code→token:正確 Portal 帳密 + 正確 verifier → access_token", async () => {
|
||||
it("完整 code→token:正確 owner 祕密 + 正確 verifier → access_token", async () => {
|
||||
const env = baseEnv();
|
||||
const app = buildApp(env);
|
||||
const { verifier, challenge } = await pkcePair();
|
||||
@@ -358,8 +310,7 @@ describe("oauth flow (整合)", () => {
|
||||
code_challenge_method: "S256",
|
||||
scope: "mcp",
|
||||
resource: "https://mcp.arcrun.dev/mcp",
|
||||
email: GOOD_EMAIL,
|
||||
password: GOOD_PASSWORD,
|
||||
owner_secret: "s3cr3t-owner",
|
||||
}).toString(),
|
||||
redirect: "manual",
|
||||
});
|
||||
@@ -393,143 +344,6 @@ describe("oauth flow (整合)", () => {
|
||||
expect(at?.aud).toBe("https://mcp.arcrun.dev/mcp");
|
||||
});
|
||||
|
||||
// ── 2026-08-12:身分要接住並攜帶(本次修的病根)─────────────────────────────
|
||||
describe("登入者身分跟著 token 走(leo:掛上 MCP 並輸入帳密=授權,下游不得再問一次)", () => {
|
||||
it("驗完帳密不是只留布林值:token 帶得出 portal session 與該帳號的可用知識庫", async () => {
|
||||
const env = baseEnv({ CYPHER_EXECUTOR: cypherMock({ libraries: ["kb"], displayName: "小明", role: "user" }).fetcher });
|
||||
const app = buildApp(env);
|
||||
const { verifier, challenge } = await pkcePair();
|
||||
const redirect = "https://claude.ai/cb";
|
||||
const authRes = await app.req("/authorize", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: "c1",
|
||||
redirect_uri: redirect,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
email: GOOD_EMAIL,
|
||||
password: GOOD_PASSWORD,
|
||||
}).toString(),
|
||||
redirect: "manual",
|
||||
});
|
||||
const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
|
||||
const tokRes = await app.req("/token", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
code_verifier: verifier,
|
||||
redirect_uri: redirect,
|
||||
}).toString(),
|
||||
});
|
||||
const at = await getAccessToken(env.OAUTH_KV!, (await tokRes.json()).access_token);
|
||||
expect(at?.portal?.session).toBe("sess-abc");
|
||||
expect(at?.portal?.display_name).toBe("小明");
|
||||
expect(at?.portal?.role).toBe("user");
|
||||
expect(at?.portal?.libraries).toEqual(["kb"]);
|
||||
});
|
||||
|
||||
it("**不同帳號登入 → token 帶的身分跟著換**(不是不管誰登入都同一格)", async () => {
|
||||
// 兩個帳號權限不同:一個全庫、一個只有 kb。token 裡的身分必須各自不同。
|
||||
const envA = baseEnv({ CYPHER_EXECUTOR: cypherMock({ sessionToken: "sess-A", displayName: "Leo", libraries: ["*"] }).fetcher });
|
||||
const envB = baseEnv({ CYPHER_EXECUTOR: cypherMock({ sessionToken: "sess-B", displayName: "小明", libraries: ["kb"] }).fetcher });
|
||||
|
||||
async function tokenFor(env: Env) {
|
||||
const app = buildApp(env);
|
||||
const { verifier, challenge } = await pkcePair();
|
||||
const redirect = "https://claude.ai/cb";
|
||||
const a = await app.req("/authorize", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: "c1",
|
||||
redirect_uri: redirect,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
email: GOOD_EMAIL,
|
||||
password: GOOD_PASSWORD,
|
||||
}).toString(),
|
||||
redirect: "manual",
|
||||
});
|
||||
const code = new URL(a.headers.get("location")!).searchParams.get("code")!;
|
||||
const t = await app.req("/token", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
code_verifier: verifier,
|
||||
redirect_uri: redirect,
|
||||
}).toString(),
|
||||
});
|
||||
return getAccessToken(env.OAUTH_KV!, (await t.json()).access_token);
|
||||
}
|
||||
|
||||
const a = await tokenFor(envA);
|
||||
const b = await tokenFor(envB);
|
||||
expect(a?.portal?.session).not.toBe(b?.portal?.session);
|
||||
expect(a?.portal?.libraries).toEqual(["*"]);
|
||||
expect(b?.portal?.libraries).toEqual(["kb"]);
|
||||
});
|
||||
|
||||
it("access_token 活不過它底下的 portal session(TTL 取兩者較小)", async () => {
|
||||
const env = baseEnv({
|
||||
MCP_TOKEN_TTL: "2592000", // 30 天
|
||||
CYPHER_EXECUTOR: cypherMock({ sessionExpiresIn: 3600 }).fetcher, // session 只有 1 小時
|
||||
});
|
||||
const app = buildApp(env);
|
||||
const { verifier, challenge } = await pkcePair();
|
||||
const redirect = "https://claude.ai/cb";
|
||||
const a = await app.req("/authorize", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: "c1",
|
||||
redirect_uri: redirect,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
email: GOOD_EMAIL,
|
||||
password: GOOD_PASSWORD,
|
||||
}).toString(),
|
||||
redirect: "manual",
|
||||
});
|
||||
const code = new URL(a.headers.get("location")!).searchParams.get("code")!;
|
||||
const t = await app.req("/token", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
code_verifier: verifier,
|
||||
redirect_uri: redirect,
|
||||
}).toString(),
|
||||
});
|
||||
expect((await t.json()).expires_in).toBe(3600);
|
||||
});
|
||||
|
||||
it("cypher 回 200 但沒給 session_token(舊版 cypher)→ 不發碼(不發一張沒有身分的 token)", async () => {
|
||||
const app = buildApp(baseEnv({ CYPHER_EXECUTOR: cypherMock({ sessionToken: null }).fetcher }));
|
||||
const { challenge } = await pkcePair();
|
||||
const r = await app.req("/authorize", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: "c1",
|
||||
redirect_uri: "https://claude.ai/cb",
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
email: GOOD_EMAIL,
|
||||
password: GOOD_PASSWORD,
|
||||
}).toString(),
|
||||
redirect: "manual",
|
||||
});
|
||||
expect(r.status).toBe(401);
|
||||
expect(r.headers.get("location")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("錯誤 owner 祕密 → 401、不發 code", async () => {
|
||||
const app = buildApp(baseEnv());
|
||||
const { challenge } = await pkcePair();
|
||||
@@ -541,8 +355,7 @@ describe("oauth flow (整合)", () => {
|
||||
redirect_uri: "https://claude.ai/cb",
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
email: GOOD_EMAIL,
|
||||
password: "WRONG",
|
||||
owner_secret: "WRONG",
|
||||
}).toString(),
|
||||
redirect: "manual",
|
||||
});
|
||||
@@ -564,8 +377,7 @@ describe("oauth flow (整合)", () => {
|
||||
redirect_uri: redirect,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
email: GOOD_EMAIL,
|
||||
password: GOOD_PASSWORD,
|
||||
owner_secret: "s3cr3t-owner",
|
||||
}).toString(),
|
||||
redirect: "manual",
|
||||
});
|
||||
@@ -633,8 +445,7 @@ describe("oauth resource(RFC 8707)簽發端把關", () => {
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
resource,
|
||||
email: GOOD_EMAIL,
|
||||
password: GOOD_PASSWORD,
|
||||
owner_secret: "s3cr3t-owner",
|
||||
}).toString(),
|
||||
redirect: "manual",
|
||||
});
|
||||
@@ -759,8 +570,7 @@ describe("oauth store drift guard:OAUTH_KV 的 put 一律帶 TTL", () => {
|
||||
redirect_uri: redirect,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
email: GOOD_EMAIL,
|
||||
password: GOOD_PASSWORD,
|
||||
owner_secret: "s3cr3t-owner",
|
||||
}).toString(),
|
||||
redirect: "manual",
|
||||
});
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
/**
|
||||
* kbdb_* 資料層工具:**用登入進來的那個人的身分查詢**(2026-08-12)。
|
||||
*
|
||||
* leo:「人類進 Portal 輸入帳密表示你是主人,可以查到你權限所有東西;AI 透過輸入帳密的
|
||||
* MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」
|
||||
* 「掛上 MCP 並輸入帳密,那個動作本身就是授權」⇒ 下游不得再要求第二次認證。
|
||||
*
|
||||
* 本檔守三件事:
|
||||
* ① 以帳密連線時,查詢**帶登入者的 portal session** 打 cypher `/portal/data/*`
|
||||
* ——不再拿 KBDB 的服務內部金鑰直打 KBDB(那條路繞過所有庫過濾)。
|
||||
* ② 呼叫端自帶的 owner_id **一律不生效**(範圍由帳號權限決定,不由呼叫端指定)。
|
||||
* ③ 舊 token(沒有身分)**fail-closed**:誠實要求重新連線,不偷偷退回服務金鑰那條老路。
|
||||
* ④ 服務級憑據(static token / partner key)維持既有 KBDB 直連(零回歸)。
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import type { Env } from "../../../src/types.js";
|
||||
import { registerAllKbdbDataTools } from "../../../src/tools/kbdb_data.js";
|
||||
import type { KnowledgeIdentity } from "../../../src/lib/portal-client.js";
|
||||
|
||||
type ToolHandler = (args: Record<string, unknown>) => Promise<{
|
||||
content: { type: string; text: string }[];
|
||||
isError?: boolean;
|
||||
}>;
|
||||
|
||||
function makeServer() {
|
||||
const tools = new Map<string, { description: string; handler: ToolHandler }>();
|
||||
const server = {
|
||||
tool(name: string, description: string, _schema: unknown, handler: ToolHandler) {
|
||||
tools.set(name, { description, handler });
|
||||
},
|
||||
};
|
||||
return { server: server as unknown as McpServer, tools };
|
||||
}
|
||||
|
||||
/** 兩個 binding 都掛上,才驗得出「該走哪一條」——走錯的那條會被記錄下來。 */
|
||||
function makeEnv(respond: (which: "cypher" | "kbdb", url: URL, init?: RequestInit) => Response) {
|
||||
const cypherCalls: { url: URL; init?: RequestInit }[] = [];
|
||||
const kbdbCalls: { url: URL; init?: RequestInit }[] = [];
|
||||
const env = {
|
||||
CYPHER_EXECUTOR: {
|
||||
fetch: async (input: string, init?: RequestInit) => {
|
||||
const url = new URL(input);
|
||||
cypherCalls.push({ url, init });
|
||||
return respond("cypher", url, init);
|
||||
},
|
||||
},
|
||||
KBDB: {
|
||||
fetch: async (input: string, init?: RequestInit) => {
|
||||
const url = new URL(input);
|
||||
kbdbCalls.push({ url, init });
|
||||
return respond("kbdb", url, init);
|
||||
},
|
||||
},
|
||||
KBDB_INTERNAL_TOKEN: "service-key-should-not-be-used-on-portal-path",
|
||||
} as unknown as Env;
|
||||
return { env, cypherCalls, kbdbCalls };
|
||||
}
|
||||
|
||||
function parseResult(r: { content: { text: string }[] }) {
|
||||
return JSON.parse(r.content[0].text) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
const PORTAL: KnowledgeIdentity = {
|
||||
kind: "portal",
|
||||
portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["kb"] },
|
||||
};
|
||||
const SERVICE: KnowledgeIdentity = { kind: "service" };
|
||||
const STALE: KnowledgeIdentity = { kind: "stale" };
|
||||
|
||||
function tools(identity: KnowledgeIdentity, respond: Parameters<typeof makeEnv>[0]) {
|
||||
const { server, tools } = makeServer();
|
||||
const e = makeEnv(respond);
|
||||
registerAllKbdbDataTools(server, e.env, identity);
|
||||
return { tools, ...e };
|
||||
}
|
||||
|
||||
const OK = () => new Response(JSON.stringify({ success: true, entries: [], records: [], count: 0 }));
|
||||
|
||||
describe("kbdb_* 以登入者身分查詢(portal 資料面)", () => {
|
||||
const cases: Array<{ tool: string; args: Record<string, unknown>; path: string; method?: string }> = [
|
||||
{ tool: "kbdb_search", args: { q: "火星座標" }, path: "/portal/data/search" },
|
||||
{ tool: "kbdb_query", args: { template: "triplet" }, path: "/portal/data/records/by-template/triplet" },
|
||||
{ tool: "kbdb_get_record", args: { record_id: "rec_1" }, path: "/portal/data/records/rec_1" },
|
||||
{ tool: "kbdb_list_templates", args: {}, path: "/portal/data/templates" },
|
||||
{ tool: "kbdb_create_template", args: { name: "contact", slots: ["name"] }, path: "/portal/data/templates", method: "POST" },
|
||||
{ tool: "kbdb_create_record", args: { template: "contact", values: { name: "Leo" } }, path: "/portal/data/records", method: "POST" },
|
||||
];
|
||||
|
||||
for (const c of cases) {
|
||||
it(`${c.tool} → 打 ${c.path},帶登入者 session,完全不碰 KBDB 服務金鑰`, async () => {
|
||||
const { tools: t, cypherCalls, kbdbCalls } = tools(PORTAL, OK);
|
||||
const res = await t.get(c.tool)!.handler(c.args);
|
||||
expect(res.isError).toBeUndefined();
|
||||
|
||||
// 走的是 cypher 的 portal 資料面,不是 KBDB 直連
|
||||
expect(kbdbCalls, `${c.tool} 不該直打 KBDB`).toHaveLength(0);
|
||||
expect(cypherCalls).toHaveLength(1);
|
||||
expect(cypherCalls[0].url.pathname).toBe(c.path);
|
||||
expect(cypherCalls[0].init?.method ?? "GET").toBe(c.method ?? "GET");
|
||||
|
||||
// 帶的是「那個人的 session」,不是任何服務金鑰
|
||||
const auth = new Headers(cypherCalls[0].init!.headers as HeadersInit).get("Authorization");
|
||||
expect(auth).toBe("Bearer sess-abc");
|
||||
expect(auth).not.toContain("service-key");
|
||||
});
|
||||
}
|
||||
|
||||
it("呼叫端自帶 owner_id 一律不生效(不讓呼叫端自己挑租戶/歸屬)", async () => {
|
||||
const { tools: t, cypherCalls } = tools(PORTAL, OK);
|
||||
await t.get("kbdb_search")!.handler({ q: "x", owner_id: "someone-else" });
|
||||
await t.get("kbdb_query")!.handler({ template: "triplet", owner_id: "someone-else" });
|
||||
for (const call of cypherCalls) {
|
||||
expect(call.url.searchParams.get("owner_id")).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("寫入時 owner_id 不從呼叫端 body 走(server 定死成登入者的歸屬)", async () => {
|
||||
const { tools: t, cypherCalls } = tools(PORTAL, OK);
|
||||
await t.get("kbdb_create_record")!.handler({
|
||||
template: "contact",
|
||||
values: { name: "Leo" },
|
||||
owner_id: "someone-else",
|
||||
});
|
||||
const body = JSON.parse(String(cypherCalls[0].init!.body)) as Record<string, unknown>;
|
||||
expect(body).not.toHaveProperty("owner_id");
|
||||
});
|
||||
|
||||
it("越庫寫入被擋(403)→ 誠實講是權限問題", async () => {
|
||||
const { tools: t } = tools(PORTAL, () =>
|
||||
new Response(JSON.stringify({ error: '無「secret」庫的權限,不能寫入該庫' }), { status: 403 }),
|
||||
);
|
||||
const res = await t.get("kbdb_create_record")!.handler({
|
||||
template: "note",
|
||||
values: { library: "secret", body: "x" },
|
||||
});
|
||||
expect(res.isError).toBe(true);
|
||||
expect(parseResult(res).error_code).toBe("forbidden");
|
||||
});
|
||||
|
||||
it("查不是自己的 record(404)→ 與「不存在」同一句話(不洩存在性)", async () => {
|
||||
const { tools: t } = tools(PORTAL, () =>
|
||||
new Response(JSON.stringify({ error: "找不到這筆資料" }), { status: 404 }),
|
||||
);
|
||||
const res = await t.get("kbdb_get_record")!.handler({ record_id: "rec_someone_else" });
|
||||
expect(res.isError).toBe(true);
|
||||
expect(parseResult(res).error_code).toBe("not_found");
|
||||
expect(String(parseResult(res).human_message)).toContain("不在你的權限範圍內");
|
||||
});
|
||||
|
||||
it("session 過期(401)→ session_expired,不謊稱資料是空的", async () => {
|
||||
const { tools: t } = tools(PORTAL, () =>
|
||||
new Response(JSON.stringify({ error: "session 無效或已過期" }), { status: 401 }),
|
||||
);
|
||||
const res = await t.get("kbdb_search")!.handler({ q: "x" });
|
||||
expect(res.isError).toBe(true);
|
||||
expect(parseResult(res).error_code).toBe("session_expired");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fail-closed:舊 token 沒有身分就查不到東西(不退回服務金鑰)", () => {
|
||||
for (const name of [
|
||||
"kbdb_search",
|
||||
"kbdb_query",
|
||||
"kbdb_get_record",
|
||||
"kbdb_list_templates",
|
||||
"kbdb_create_template",
|
||||
"kbdb_create_record",
|
||||
]) {
|
||||
it(`${name} → identity_missing,且一個查詢都不發`, async () => {
|
||||
const { tools: t, cypherCalls, kbdbCalls } = tools(STALE, OK);
|
||||
const res = await t.get(name)!.handler({
|
||||
q: "x",
|
||||
template: "t",
|
||||
record_id: "r",
|
||||
name: "n",
|
||||
slots: ["a"],
|
||||
values: { a: "b" },
|
||||
});
|
||||
expect(res.isError).toBe(true);
|
||||
expect(parseResult(res).error_code).toBe("identity_missing");
|
||||
expect(cypherCalls).toHaveLength(0);
|
||||
expect(kbdbCalls).toHaveLength(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("回歸:服務級憑據維持既有 KBDB 直連", () => {
|
||||
it("kbdb_search 仍直打 KBDB /entries/search,且照舊吃 owner_id", async () => {
|
||||
const { tools: t, cypherCalls, kbdbCalls } = tools(SERVICE, OK);
|
||||
const res = await t.get("kbdb_search")!.handler({ q: "x", owner_id: "leo" });
|
||||
expect(res.isError).toBeUndefined();
|
||||
expect(cypherCalls).toHaveLength(0);
|
||||
expect(kbdbCalls).toHaveLength(1);
|
||||
expect(kbdbCalls[0].url.pathname).toBe("/entries/search");
|
||||
expect(kbdbCalls[0].url.searchParams.get("owner_id")).toBe("leo");
|
||||
});
|
||||
|
||||
it("kbdb_query / kbdb_get_record 路徑不變", async () => {
|
||||
const { tools: t, kbdbCalls } = tools(SERVICE, OK);
|
||||
await t.get("kbdb_query")!.handler({ template: "triplet" });
|
||||
await t.get("kbdb_get_record")!.handler({ record_id: "rec_1" });
|
||||
expect(kbdbCalls.map((c) => c.url.pathname)).toEqual([
|
||||
"/records/by-template/triplet",
|
||||
"/records/rec_1",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -5,17 +5,6 @@ import {
|
||||
registerGraphNeighbors,
|
||||
GRAPH_NEIGHBORS_WORKFLOW,
|
||||
} from "../../../src/tools/kbdb_graph.js";
|
||||
import type { KnowledgeIdentity } from "../../../src/lib/portal-client.js";
|
||||
|
||||
/** 服務級憑據(static token / partner key)——既有路徑,行為零變更。 */
|
||||
const SERVICE: KnowledgeIdentity = { kind: "service" };
|
||||
/** 有人輸入 Portal 帳密授權的連線——走 cypher 的 portal 資料面。 */
|
||||
const PORTAL: KnowledgeIdentity = {
|
||||
kind: "portal",
|
||||
portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["*"] },
|
||||
};
|
||||
/** 本次改版前簽發的舊 token(沒有身分)。 */
|
||||
const STALE: KnowledgeIdentity = { kind: "stale" };
|
||||
|
||||
// ── 假 McpServer:只攔 tool 註冊,抓出 handler 直接呼叫 ─────────────────────────
|
||||
type ToolHandler = (args: Record<string, unknown>) => Promise<{
|
||||
@@ -56,7 +45,7 @@ describe("kbdb_graph_neighbors: registration", () => {
|
||||
it("registers under kbdb_* prefix (D17 KBDB MCP boundary)", () => {
|
||||
const { server, tools } = makeServer();
|
||||
const { env } = makeEnv(() => new Response("{}"));
|
||||
registerGraphNeighbors(server, env, "leo", SERVICE);
|
||||
registerGraphNeighbors(server, env, "leo");
|
||||
expect(tools.has("kbdb_graph_neighbors")).toBe(true);
|
||||
expect(tools.get("kbdb_graph_neighbors")!.description).toContain("graph");
|
||||
});
|
||||
@@ -72,7 +61,7 @@ describe("kbdb_graph_neighbors: request shape", () => {
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
registerGraphNeighbors(server, env, "leo", SERVICE);
|
||||
registerGraphNeighbors(server, env, "leo");
|
||||
const res = await tools.get("kbdb_graph_neighbors")!.handler({
|
||||
subject: "Arcrun",
|
||||
depth: 2,
|
||||
@@ -99,7 +88,7 @@ describe("kbdb_graph_neighbors: request shape", () => {
|
||||
const { env, calls } = makeEnv(
|
||||
() => new Response(JSON.stringify({ success: true, neighbors: [], count: 0 })),
|
||||
);
|
||||
registerGraphNeighbors(server, env, "leo", SERVICE);
|
||||
registerGraphNeighbors(server, env, "leo");
|
||||
await tools.get("kbdb_graph_neighbors")!.handler({
|
||||
subject: "A",
|
||||
kbdb_base: "https://kbdb.example.com",
|
||||
@@ -119,7 +108,7 @@ describe("kbdb_graph_neighbors: honest errors (鐵律:workflow 沒裝不 crash
|
||||
const { env } = makeEnv(
|
||||
() => new Response(JSON.stringify({ error: '找不到 workflow "graph_neighbors"' }), { status: 404 }),
|
||||
);
|
||||
registerGraphNeighbors(server, env, "leo", SERVICE);
|
||||
registerGraphNeighbors(server, env, "leo");
|
||||
const res = await tools.get("kbdb_graph_neighbors")!.handler({
|
||||
subject: "A",
|
||||
kbdb_base: "https://kbdb.example.com",
|
||||
@@ -135,7 +124,7 @@ describe("kbdb_graph_neighbors: honest errors (鐵律:workflow 沒裝不 crash
|
||||
const { env } = makeEnv(
|
||||
() => new Response(JSON.stringify({ success: false, error: "boom", trace: [] }), { status: 500 }),
|
||||
);
|
||||
registerGraphNeighbors(server, env, "leo", SERVICE);
|
||||
registerGraphNeighbors(server, env, "leo");
|
||||
const res = await tools.get("kbdb_graph_neighbors")!.handler({
|
||||
subject: "A",
|
||||
kbdb_base: "https://kbdb.example.com",
|
||||
@@ -154,7 +143,7 @@ describe("kbdb_graph_neighbors: honest errors (鐵律:workflow 沒裝不 crash
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
registerGraphNeighbors(server, env, "leo", SERVICE);
|
||||
registerGraphNeighbors(server, env, "leo");
|
||||
const res = await tools.get("kbdb_graph_neighbors")!.handler({
|
||||
subject: "A",
|
||||
kbdb_base: "https://kbdb.example.com",
|
||||
@@ -167,7 +156,7 @@ describe("kbdb_graph_neighbors: honest errors (鐵律:workflow 沒裝不 crash
|
||||
it("empty orgNamespace → no_namespace error, no fetch made", async () => {
|
||||
const { server, tools } = makeServer();
|
||||
const { env, calls } = makeEnv(() => new Response("{}"));
|
||||
registerGraphNeighbors(server, env, "", SERVICE);
|
||||
registerGraphNeighbors(server, env, "");
|
||||
const res = await tools.get("kbdb_graph_neighbors")!.handler({
|
||||
subject: "A",
|
||||
kbdb_base: "https://kbdb.example.com",
|
||||
@@ -177,77 +166,3 @@ describe("kbdb_graph_neighbors: honest errors (鐵律:workflow 沒裝不 crash
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 2026-08-12:以帳密連線時走登入者的身分(leo:主人查得到的,授權的 AI 就查得到)──
|
||||
describe("kbdb_graph_neighbors: 登入身分(portal 資料面)", () => {
|
||||
it("打 cypher 的 /portal/data/graph/neighbors,且帶的是登入者的 session(不是服務金鑰)", async () => {
|
||||
const { server, tools } = makeServer();
|
||||
const { env, calls } = makeEnv(
|
||||
() =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
neighbors: [{ node: "B", predicate: "uses", from: "A", depth: 1 }],
|
||||
edges: [],
|
||||
count: 1,
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
registerGraphNeighbors(server, env, "leo", PORTAL);
|
||||
const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A", depth: 2 });
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].url.pathname).toBe("/portal/data/graph/neighbors/A");
|
||||
expect(calls[0].url.searchParams.get("depth")).toBe("2");
|
||||
const headers = new Headers(calls[0].init!.headers as HeadersInit);
|
||||
expect(headers.get("Authorization")).toBe("Bearer sess-abc");
|
||||
|
||||
expect(res.isError).toBeUndefined();
|
||||
expect((parseResult(res).data as { count: number }).count).toBe(1);
|
||||
});
|
||||
|
||||
it("**不需要 kbdb_base**:已經登入過了,不再要第二次「證明你是誰/你的庫在哪」", async () => {
|
||||
const { server, tools } = makeServer();
|
||||
const { env, calls } = makeEnv(
|
||||
() => new Response(JSON.stringify({ neighbors: [], edges: [], count: 0 })),
|
||||
);
|
||||
registerGraphNeighbors(server, env, "leo", PORTAL);
|
||||
const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A" });
|
||||
expect(res.isError).toBeUndefined();
|
||||
expect(calls).toHaveLength(1);
|
||||
// 呼叫端就算硬塞 kbdb_base 也不會被拿去用(server 自己知道要查哪個庫)
|
||||
expect(calls[0].url.searchParams.get("kbdb_base")).toBeNull();
|
||||
});
|
||||
|
||||
it("session 過期(401)→ 誠實說是登入過期,不說「查不到資料」", async () => {
|
||||
const { server, tools } = makeServer();
|
||||
const { env } = makeEnv(
|
||||
() => new Response(JSON.stringify({ error: "session 無效或已過期" }), { status: 401 }),
|
||||
);
|
||||
registerGraphNeighbors(server, env, "leo", PORTAL);
|
||||
const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A" });
|
||||
expect(res.isError).toBe(true);
|
||||
expect(parseResult(res).error_code).toBe("session_expired");
|
||||
});
|
||||
|
||||
it("無 graph 權限(403)→ 誠實回沒權限,不假裝「沒有關聯」", async () => {
|
||||
const { server, tools } = makeServer();
|
||||
const { env } = makeEnv(
|
||||
() => new Response(JSON.stringify({ error: "無知識圖譜檢視權限" }), { status: 403 }),
|
||||
);
|
||||
registerGraphNeighbors(server, env, "leo", PORTAL);
|
||||
const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A" });
|
||||
expect(res.isError).toBe(true);
|
||||
expect(parseResult(res).error_code).toBe("forbidden");
|
||||
});
|
||||
|
||||
it("舊 token(沒有身分)→ 不偷偷退回服務金鑰那條老路,要求重新連線", async () => {
|
||||
const { server, tools } = makeServer();
|
||||
const { env, calls } = makeEnv(() => new Response("{}"));
|
||||
registerGraphNeighbors(server, env, "leo", STALE);
|
||||
const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A" });
|
||||
expect(res.isError).toBe(true);
|
||||
expect(parseResult(res).error_code).toBe("identity_missing");
|
||||
expect(calls).toHaveLength(0); // 一個查詢都沒發出去(fail-closed)
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,32 +7,6 @@ import {
|
||||
renderLibraryMapLines,
|
||||
__resetLibraryMapInstructionsCacheForTests,
|
||||
} from "../../../src/lib/library-map.js";
|
||||
import type { KnowledgeIdentity } from "../../../src/lib/portal-client.js";
|
||||
|
||||
/** 服務級憑據(static token / partner key)——既有 KBDB 直連路徑,行為零變更。 */
|
||||
const SERVICE: KnowledgeIdentity = { kind: "service" };
|
||||
/** 有人輸入 Portal 帳密授權的連線——走 cypher 的 portal 資料面(只看得到自己有權限的庫)。 */
|
||||
const PORTAL: KnowledgeIdentity = {
|
||||
kind: "portal",
|
||||
portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["kb"] },
|
||||
};
|
||||
/** 本次改版前簽發的舊 token(沒有身分)。 */
|
||||
const STALE: KnowledgeIdentity = { kind: "stale" };
|
||||
|
||||
/** 假 CYPHER_EXECUTOR binding(portal 資料面用)。 */
|
||||
function makePortalEnv(respond: (url: URL, init?: RequestInit) => Response) {
|
||||
const calls: { url: URL; init?: RequestInit }[] = [];
|
||||
const env = {
|
||||
CYPHER_EXECUTOR: {
|
||||
fetch: async (input: string, init?: RequestInit) => {
|
||||
const url = new URL(input);
|
||||
calls.push({ url, init });
|
||||
return respond(url, init);
|
||||
},
|
||||
},
|
||||
} as unknown as Env;
|
||||
return { env, calls };
|
||||
}
|
||||
|
||||
// ── 假 McpServer:只攔 tool 註冊,抓出 handler 直接呼叫(比照 kbdb-graph.test.ts)──────
|
||||
type ToolHandler = (args: Record<string, unknown>) => Promise<{
|
||||
@@ -82,7 +56,7 @@ describe("kbdb_get_map: registration", () => {
|
||||
it("registers under kbdb_* prefix (D17) with the 'call this first' hint in description", () => {
|
||||
const { server, tools } = makeServer();
|
||||
const { env } = makeEnv(() => new Response("{}"));
|
||||
registerGetMap(server, env, SERVICE);
|
||||
registerGetMap(server, env);
|
||||
expect(tools.has("kbdb_get_map")).toBe(true);
|
||||
// 任務規格:description 必含「不確定該查什麼時,先呼叫此工具」
|
||||
expect(tools.get("kbdb_get_map")!.description).toContain("不確定該查什麼時,先呼叫此工具");
|
||||
@@ -95,7 +69,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
|
||||
const { env, calls } = makeEnv(
|
||||
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
|
||||
);
|
||||
registerGetMap(server, env, SERVICE);
|
||||
registerGetMap(server, env);
|
||||
const res = await tools.get("kbdb_get_map")!.handler({});
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
@@ -116,7 +90,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
|
||||
const { env, calls } = makeEnv(
|
||||
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
|
||||
);
|
||||
registerGetMap(server, env, SERVICE);
|
||||
registerGetMap(server, env);
|
||||
await tools.get("kbdb_get_map")!.handler({ owner_id: "leo" });
|
||||
expect(calls[0].url.searchParams.get("owner_id")).toBe("leo");
|
||||
});
|
||||
@@ -131,7 +105,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
|
||||
const { env } = makeEnv(
|
||||
() => new Response(JSON.stringify({ success: true, libraries: [row], count: 1 })),
|
||||
);
|
||||
registerGetMap(server, env, SERVICE);
|
||||
registerGetMap(server, env);
|
||||
const res = await tools.get("kbdb_get_map")!.handler({});
|
||||
const data = parseResult(res).data as {
|
||||
libraries: { top_entities: string[]; triplet_count: number }[];
|
||||
@@ -145,7 +119,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
|
||||
const { env } = makeEnv(
|
||||
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
|
||||
);
|
||||
registerGetMap(server, env, SERVICE);
|
||||
registerGetMap(server, env);
|
||||
const res = await tools.get("kbdb_get_map")!.handler({});
|
||||
const body = parseResult(res);
|
||||
expect(body.ok).toBe(true);
|
||||
@@ -161,7 +135,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
|
||||
const { env } = makeEnv(
|
||||
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
|
||||
);
|
||||
registerGetMap(server, env, SERVICE);
|
||||
registerGetMap(server, env);
|
||||
const res = await tools.get("kbdb_get_map")!.handler({});
|
||||
const body = parseResult(res);
|
||||
const hintsText = JSON.stringify(body.hints);
|
||||
@@ -175,7 +149,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
|
||||
it("HTTP error → map_fetch_failed with recompute hint, not a crash", async () => {
|
||||
const { server, tools } = makeServer();
|
||||
const { env } = makeEnv(() => new Response("boom", { status: 500 }));
|
||||
registerGetMap(server, env, SERVICE);
|
||||
registerGetMap(server, env);
|
||||
const res = await tools.get("kbdb_get_map")!.handler({});
|
||||
expect(res.isError).toBe(true);
|
||||
const body = parseResult(res);
|
||||
@@ -204,7 +178,7 @@ describe("kbdb_get_map: 單庫詳圖(library 參數)", () => {
|
||||
const { env, calls } = makeEnv(
|
||||
() => new Response(JSON.stringify({ success: true, map: DETAIL })),
|
||||
);
|
||||
registerGetMap(server, env, SERVICE);
|
||||
registerGetMap(server, env);
|
||||
const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" });
|
||||
expect(calls[0].url.pathname).toBe("/map/kb");
|
||||
const map = (parseResult(res).data as { map: typeof DETAIL }).map;
|
||||
@@ -223,7 +197,7 @@ describe("kbdb_get_map: 單庫詳圖(library 參數)", () => {
|
||||
triplet_count: "111",
|
||||
};
|
||||
const { env } = makeEnv(() => new Response(JSON.stringify({ success: true, map: raw })));
|
||||
registerGetMap(server, env, SERVICE);
|
||||
registerGetMap(server, env);
|
||||
const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" });
|
||||
expect(res.isError).toBeUndefined();
|
||||
const map = (parseResult(res).data as { map: Record<string, unknown> }).map;
|
||||
@@ -238,7 +212,7 @@ describe("kbdb_get_map: 單庫詳圖(library 參數)", () => {
|
||||
const { env } = makeEnv(
|
||||
() => new Response(JSON.stringify({ success: false, error: "not found" }), { status: 404 }),
|
||||
);
|
||||
registerGetMap(server, env, SERVICE);
|
||||
registerGetMap(server, env);
|
||||
const res = await tools.get("kbdb_get_map")!.handler({ library: "ghost" });
|
||||
expect(res.isError).toBe(true);
|
||||
const body = parseResult(res);
|
||||
@@ -259,7 +233,7 @@ describe("kbdb_get_map: 單庫詳圖(library 參數)", () => {
|
||||
},
|
||||
},
|
||||
} as unknown as Env;
|
||||
registerGetMap(server, env, SERVICE);
|
||||
registerGetMap(server, env);
|
||||
const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" });
|
||||
expect(res.isError).toBe(true);
|
||||
expect(parseResult(res).error_code).toBe("internal_error");
|
||||
@@ -284,7 +258,7 @@ describe("buildLibraryMapInstructions", () => {
|
||||
}),
|
||||
),
|
||||
);
|
||||
const text = await buildLibraryMapInstructions(env, SERVICE);
|
||||
const text = await buildLibraryMapInstructions(env);
|
||||
expect(text).not.toBeNull();
|
||||
// design §4 格式:{library}:{narrative}|核心:{top3}|{triplet_count} triplets
|
||||
expect(text!).toContain("kb:leo 的知識庫主庫|核心:00-INDEX、kb/00-INDEX、Gitea|111 triplets");
|
||||
@@ -295,7 +269,7 @@ describe("buildLibraryMapInstructions", () => {
|
||||
|
||||
it("HTTP error → null(靜默略過,不 throw 不擋連線)", async () => {
|
||||
const { env } = makeEnv(() => new Response("boom", { status: 500 }));
|
||||
await expect(buildLibraryMapInstructions(env, SERVICE)).resolves.toBeNull();
|
||||
await expect(buildLibraryMapInstructions(env)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("binding throws → null(靜默略過)", async () => {
|
||||
@@ -306,22 +280,22 @@ describe("buildLibraryMapInstructions", () => {
|
||||
},
|
||||
},
|
||||
} as unknown as Env;
|
||||
await expect(buildLibraryMapInstructions(env, SERVICE)).resolves.toBeNull();
|
||||
await expect(buildLibraryMapInstructions(env)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("empty libraries → null(沒地圖就不注入,不塞空段落)", async () => {
|
||||
const { env } = makeEnv(
|
||||
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
|
||||
);
|
||||
await expect(buildLibraryMapInstructions(env, SERVICE)).resolves.toBeNull();
|
||||
await expect(buildLibraryMapInstructions(env)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("caches within TTL:same isolate 第二次不再打 /map", async () => {
|
||||
const { env, calls } = makeEnv(
|
||||
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
|
||||
);
|
||||
const first = await buildLibraryMapInstructions(env, SERVICE);
|
||||
const second = await buildLibraryMapInstructions(env, SERVICE);
|
||||
const first = await buildLibraryMapInstructions(env);
|
||||
const second = await buildLibraryMapInstructions(env);
|
||||
expect(second).toBe(first);
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
@@ -344,95 +318,3 @@ describe("renderLibraryMapLines", () => {
|
||||
expect(renderLibraryMapLines([])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── 2026-08-12:地圖也要跟著登入者的權限走 ────────────────────────────────────
|
||||
// 地圖本身就是情報(有哪些庫、各有多少關聯、核心 entity 是誰)——不能整館推給
|
||||
// 一個只有部分權限的帳號。
|
||||
describe("藏書地圖:登入身分(portal 資料面)", () => {
|
||||
beforeEach(() => __resetLibraryMapInstructionsCacheForTests());
|
||||
|
||||
it("kbdb_get_map 打 /portal/data/map,帶登入者 session,不碰 KBDB 服務金鑰", async () => {
|
||||
const { server, tools } = makeServer();
|
||||
const { env, calls } = makePortalEnv(
|
||||
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
|
||||
);
|
||||
registerGetMap(server, env, PORTAL);
|
||||
const res = await tools.get("kbdb_get_map")!.handler({});
|
||||
expect(res.isError).toBeUndefined();
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].url.pathname).toBe("/portal/data/map");
|
||||
expect(new Headers(calls[0].init!.headers as HeadersInit).get("Authorization")).toBe("Bearer sess-abc");
|
||||
});
|
||||
|
||||
it("呼叫端硬塞 owner_id 也不生效(查詢範圍由帳號權限決定,不由呼叫端指定)", async () => {
|
||||
const { server, tools } = makeServer();
|
||||
const { env, calls } = makePortalEnv(
|
||||
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
|
||||
);
|
||||
registerGetMap(server, env, PORTAL);
|
||||
await tools.get("kbdb_get_map")!.handler({ owner_id: "someone-else" });
|
||||
expect(calls[0].url.searchParams.get("owner_id")).toBeNull();
|
||||
});
|
||||
|
||||
it("查沒權限的庫 → 與「不存在」同一句話(不洩存在性)", async () => {
|
||||
const { server, tools } = makeServer();
|
||||
const { env } = makePortalEnv(() => new Response(JSON.stringify({ error: "找不到這筆資料" }), { status: 404 }));
|
||||
registerGetMap(server, env, PORTAL);
|
||||
const res = await tools.get("kbdb_get_map")!.handler({ library: "secret-lib" });
|
||||
expect(res.isError).toBe(true);
|
||||
const body = parseResult(res);
|
||||
expect(body.error_code).toBe("map_not_found");
|
||||
expect(String(body.human_message)).toContain("不在你被授權");
|
||||
});
|
||||
|
||||
it("session 過期(401)→ session_expired,不說「地圖是空的」", async () => {
|
||||
const { server, tools } = makeServer();
|
||||
const { env } = makePortalEnv(
|
||||
() => new Response(JSON.stringify({ error: "session 無效或已過期" }), { status: 401 }),
|
||||
);
|
||||
registerGetMap(server, env, PORTAL);
|
||||
const res = await tools.get("kbdb_get_map")!.handler({});
|
||||
expect(res.isError).toBe(true);
|
||||
expect(parseResult(res).error_code).toBe("session_expired");
|
||||
});
|
||||
|
||||
it("舊 token(沒身分)→ identity_missing,且一個查詢都不發(fail-closed)", async () => {
|
||||
const { server, tools } = makeServer();
|
||||
const { env, calls } = makePortalEnv(() => new Response("{}"));
|
||||
registerGetMap(server, env, STALE);
|
||||
const res = await tools.get("kbdb_get_map")!.handler({});
|
||||
expect(res.isError).toBe(true);
|
||||
expect(parseResult(res).error_code).toBe("identity_missing");
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("instructions 的地圖也走 portal 資料面(連線開場推的庫名不得超出權限)", async () => {
|
||||
const { env, calls } = makePortalEnv(
|
||||
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
|
||||
);
|
||||
const text = await buildLibraryMapInstructions(env, PORTAL);
|
||||
expect(text).toContain("kb");
|
||||
expect(calls[0].url.pathname).toBe("/portal/data/map");
|
||||
});
|
||||
|
||||
it("**快取不跨身分共用**:不同 session 各自打一次,不會拿到別人的視野", async () => {
|
||||
const { env, calls } = makePortalEnv(
|
||||
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
|
||||
);
|
||||
const other: KnowledgeIdentity = {
|
||||
kind: "portal",
|
||||
portal: { session: "sess-other", display_name: "小明", role: "user", libraries: ["notes"] },
|
||||
};
|
||||
await buildLibraryMapInstructions(env, PORTAL);
|
||||
await buildLibraryMapInstructions(env, other);
|
||||
expect(calls).toHaveLength(2); // 兩次真的各打一次
|
||||
await buildLibraryMapInstructions(env, PORTAL);
|
||||
expect(calls).toHaveLength(2); // 同一 session 第二次才吃快取
|
||||
});
|
||||
|
||||
it("舊 token → 不給地圖(instructions 不外洩任何庫名)", async () => {
|
||||
const { env, calls } = makePortalEnv(() => new Response("{}"));
|
||||
expect(await buildLibraryMapInstructions(env, STALE)).toBeNull();
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# verify-kv-retirement.sh — 把「換掉 KV,資產還在」真的做一次
|
||||
#
|
||||
# KV 退休(Leo/Arcrun#16 + #17)。交辦的驗收條件逐字是:
|
||||
# 「證明『換掉/重建那個暫存層,資產還在』——不是說明它會在,是**真的弄一次給我看**」
|
||||
# 「既有的東西要能搬過去,而且搬的過程不能弄丟任何一筆(搬之前先數,搬之後再數)」
|
||||
# 這支腳本就是那一次。它做的事,照順序:
|
||||
#
|
||||
# 1. 開一台**全新的空**本機實例(local D1 + local KV,跑真的 migrations)
|
||||
# 2. 用平常那條路(POST /webhooks/named、POST /recipes、POST /auth-recipes)
|
||||
# 放進 9 支工作流 + 3 份 recipe——9 是照 2026-08-12 那天真的消失的數量
|
||||
# 3. 數一次(KV 幾筆、KBDB 幾筆)
|
||||
# 4. **把整個 KV 層砍掉重建**(rm -rf 那顆 KV 的本機儲存 → 重開 worker)
|
||||
# =模擬 Arcrun#97 那天發生的事:worker 被綁到一顆全新的空 KV
|
||||
# 5. 再數一次,並且**真的觸發一支工作流**確認它還跑得動
|
||||
#
|
||||
# 通過的定義(不通就 exit 1,不留模稜兩可):
|
||||
# 砍掉 KV 之後,列出來仍然是 9 支、recipe 仍在、工作流仍然跑得出結果。
|
||||
#
|
||||
# ⚠️ 全程只碰本機(--local + --persist-to 到暫存目錄),**不碰任何線上實例**。
|
||||
# 腳本裡沒有任何 --remote、沒有任何真實帳號憑證。
|
||||
#
|
||||
# 用法: bash scripts/verify-kv-retirement.sh
|
||||
# 需要: node 22+、pnpm、可執行 npx wrangler / curl 的 shell
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
WORK="$(mktemp -d)"
|
||||
KBDB_PORT=8801
|
||||
CYPHER_PORT=8802
|
||||
TOKEN="e2e-local-token"
|
||||
TENANT="leo-e2e"
|
||||
WF_COUNT=9
|
||||
|
||||
KBDB="http://127.0.0.1:${KBDB_PORT}"
|
||||
CYPHER="http://127.0.0.1:${CYPHER_PORT}"
|
||||
|
||||
kbdb_pid=""; cypher_pid=""
|
||||
cleanup() {
|
||||
[ -n "$kbdb_pid" ] && kill "$kbdb_pid" 2>/dev/null || true
|
||||
[ -n "$cypher_pid" ] && kill "$cypher_pid" 2>/dev/null || true
|
||||
rm -rf "$WORK"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
say() { printf '\n\033[1m== %s\033[0m\n' "$*"; }
|
||||
fail() { printf '\n\033[31m❌ %s\033[0m\n' "$*"; exit 1; }
|
||||
|
||||
wait_for() { # wait_for <url> <label>
|
||||
for _ in $(seq 1 60); do
|
||||
if curl -sf -m 2 "$1" >/dev/null 2>&1; then return 0; fi
|
||||
sleep 1
|
||||
done
|
||||
fail "$2 沒有起來($1)"
|
||||
}
|
||||
|
||||
start_cypher() {
|
||||
( cd "$REPO_ROOT/cypher-executor" && \
|
||||
npx wrangler dev --local --port "$CYPHER_PORT" \
|
||||
--config wrangler.test.toml \
|
||||
--persist-to "$WORK/cypher-state" \
|
||||
--var "KBDB_BASE_URL:$KBDB" \
|
||||
--var "KBDB_INTERNAL_TOKEN:$TOKEN" \
|
||||
--show-interactive-dev-session=false >"$WORK/cypher.log" 2>&1 ) &
|
||||
cypher_pid=$!
|
||||
wait_for "$CYPHER/health" "cypher-executor"
|
||||
}
|
||||
|
||||
# ── 1. 空實例:真的跑 migrations ───────────────────────────────────────────────
|
||||
say "1. 開一台全新的空實例(local D1 + local KV,跑真的 migrations)"
|
||||
( cd "$REPO_ROOT/kbdb" && npx wrangler d1 migrations apply DB --local --persist-to "$WORK/kbdb-state" )
|
||||
|
||||
( cd "$REPO_ROOT/kbdb" && \
|
||||
npx wrangler dev --local --port "$KBDB_PORT" \
|
||||
--persist-to "$WORK/kbdb-state" \
|
||||
--var "KBDB_INTERNAL_TOKEN:$TOKEN" \
|
||||
--show-interactive-dev-session=false >"$WORK/kbdb.log" 2>&1 ) &
|
||||
kbdb_pid=$!
|
||||
wait_for "$KBDB/health" "kbdb"
|
||||
start_cypher
|
||||
|
||||
# ── 2. 用平常那條路放資產進去 ─────────────────────────────────────────────────
|
||||
say "2. 放進 $WF_COUNT 支工作流 + 3 份 recipe(走的是 acr push 用的同一組端點)"
|
||||
for i in $(seq 1 "$WF_COUNT"); do
|
||||
curl -sf -X POST "$CYPHER/webhooks/named" \
|
||||
-H 'Content-Type: application/json' -H "X-Arcrun-API-Key: $TENANT" \
|
||||
-d "{\"name\":\"wf_$i\",\"description\":\"驗證用工作流 $i:把 text 轉大寫\",
|
||||
\"graph\":{\"id\":\"wf_$i\",\"nodes\":[{\"id\":\"upper\",\"type\":\"Component\",\"componentId\":\"comp_uppercase\"}],\"edges\":[]}}" \
|
||||
>/dev/null || fail "部署 wf_$i 失敗"
|
||||
done
|
||||
|
||||
for svc in alpha beta; do
|
||||
curl -sf -X POST "$CYPHER/recipes" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"canonical_id\":\"${svc}_send\",\"endpoint\":\"https://example.invalid/$svc\",\"description\":\"驗證用 recipe $svc\"}" \
|
||||
>/dev/null || fail "建立 recipe $svc 失敗"
|
||||
done
|
||||
curl -sf -X POST "$CYPHER/auth-recipes" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"service":"alpha","primitive":"static_key","base_url":"https://example.invalid",
|
||||
"required_secrets":[{"key":"alpha_token","label":"Token","help_url":"https://example.invalid/docs"}],
|
||||
"inject":{"header":{"Authorization":"Bearer {{secret.alpha_token}}"}}}' \
|
||||
>/dev/null || fail "建立 auth recipe 失敗"
|
||||
|
||||
# ── 3. 搬之前先數 ─────────────────────────────────────────────────────────────
|
||||
say "3. 數一次(/storage/audit:KV 幾筆、KBDB 幾筆、差幾筆)"
|
||||
curl -s "$CYPHER/storage/audit" | tee "$WORK/audit-before.json"; echo
|
||||
before_wf="$(curl -s "$CYPHER/webhooks/named" -H "X-Arcrun-API-Key: $TENANT" | grep -o '"total":[0-9]*' | cut -d: -f2)"
|
||||
echo "部署後列出來的工作流數:$before_wf"
|
||||
[ "$before_wf" = "$WF_COUNT" ] || fail "還沒開始拆就對不上:期望 $WF_COUNT,實得 $before_wf"
|
||||
|
||||
# ── 4. 把 KV 層砍掉重建(模擬 2026-08-12 那天) ────────────────────────────────
|
||||
say "4. 砍掉整個 KV 層並重建 —— 模擬 Arcrun#97 那天『worker 被綁到一顆全新的空 KV』"
|
||||
kill "$cypher_pid" 2>/dev/null || true; wait "$cypher_pid" 2>/dev/null || true; cypher_pid=""
|
||||
rm -rf "$WORK/cypher-state" # ← 這一行就是「那個暫存層被換掉」
|
||||
echo "已刪除:$WORK/cypher-state(cypher 的整顆本機 KV)"
|
||||
start_cypher
|
||||
|
||||
# ── 5. 再數一次,而且真的跑一支 ───────────────────────────────────────────────
|
||||
say "5. KV 全空之後,再數一次"
|
||||
after_json="$(curl -s "$CYPHER/webhooks/named" -H "X-Arcrun-API-Key: $TENANT")"
|
||||
echo "$after_json" | head -c 400; echo
|
||||
after_wf="$(echo "$after_json" | grep -o '"total":[0-9]*' | cut -d: -f2)"
|
||||
echo "KV 砍掉重建後列出來的工作流數:$after_wf"
|
||||
[ "$after_wf" = "$WF_COUNT" ] || fail "工作流少了:期望 $WF_COUNT,實得 $after_wf —— 資產沒有被保住"
|
||||
|
||||
recipes_after="$(curl -s "$CYPHER/recipes" | grep -o '"count":[0-9]*' | cut -d: -f2)"
|
||||
echo "KV 砍掉重建後的 recipe 數:$recipes_after"
|
||||
[ "${recipes_after:-0}" -ge 2 ] || fail "recipe 少了:期望 >=2,實得 ${recipes_after:-0}"
|
||||
|
||||
auth_after="$(curl -s "$CYPHER/auth-recipes/alpha" | grep -c '"success":true' || true)"
|
||||
[ "$auth_after" = "1" ] || fail "auth recipe 不見了"
|
||||
echo "auth recipe alpha:仍在"
|
||||
|
||||
say "5b. 不只是列得出來——真的觸發一支工作流"
|
||||
run="$(curl -s -X POST "$CYPHER/webhooks/named/wf_3/trigger" \
|
||||
-H 'Content-Type: application/json' -H "X-Arcrun-API-Key: $TENANT" \
|
||||
-d '{"text":"still here"}')"
|
||||
echo "$run" | head -c 400; echo
|
||||
echo "$run" | grep -q 'STILL HERE' || fail "工作流列得出來卻跑不動——那不算資產還在"
|
||||
|
||||
say "結論"
|
||||
printf '\033[32m✅ 通:整個 KV 層被砍掉重建之後,%s 支工作流、recipe、auth recipe 全部還在,且工作流真的跑得出結果。\033[0m\n' "$WF_COUNT"
|
||||
echo " 資產的家=KBDB(D1,一份資產一列 entry);KV 只是快取,砍掉會自己長回來。"
|
||||
@@ -8,6 +8,75 @@
|
||||
|
||||
## 待裁決
|
||||
|
||||
### P-KV|KV 退休:工作流與 recipe 的家搬到 KBDB(Leo/Arcrun#16 + #17)— 2026-08-12
|
||||
|
||||
**觸發**:leo 2026-08-12 原話——
|
||||
> 「我要的是寫進 KBDB,不是 KV,他的 Recipes、Cypher 是一段話,文字,數據,一個 entry」
|
||||
> 「因為對 KV 的使用有禁令,但卻會把資產放在這裡,這不是違法嗎?」
|
||||
> 「現在是我幫他寫工作流,未來是他的 AI 自己寫工作流,
|
||||
> **如果零件和工作流的 recipe 不見了,是很可怕的事情**」
|
||||
|
||||
同日實害:一次例行更新讓使用者的九支工作流在畫面上全部消失(Arcrun#97)。
|
||||
#97 已修掉直接原因(`cli/src/lib/resource-resolver.ts`:不再照名字猜使用者的資源、
|
||||
不再擅自新建一顆空的綁上去)。**本案修的是更下面那一句**:使用者的資產本來就不該
|
||||
只存在於一個會被換掉的暫存層裡——#97 修的是「別再換錯」,這裡修的是「換了也不會怎樣」。
|
||||
|
||||
**為什麼要走規格層(D35)**:`.claude/rules/01-tech-stack.md`「資料儲存」那張表把
|
||||
workflow 定義寫在 `WEBHOOKS` KV、recipe 寫在 `RECIPES` KV。改掉真相來源=改規格。
|
||||
現行 active SDD 是 `workflow-discovery`,本案不在它的 tasks 內,故依 D35 第 3 條
|
||||
寫 proposal 停下等 leo confirm。**#16 已被多份 SDD 引用為前提**
|
||||
(`arcrun/artifact-sharing/` design K2「KBDB 是唯一公庫後端」、requirements Out of Scope、
|
||||
tasks 1.5/5.2 都寫明「遷移本體由 #16 負責」),所以這不是新方向,是那些卷等的那一塊落地。
|
||||
|
||||
**提議的規格(三句)**
|
||||
1. **資產的真相來源=KBDB**(D1 `entries` 一列一份資產)。KV 降級為可丟棄的快取。
|
||||
2. **零 SQL、永不加表**(D38):新增四個 `entry_type`
|
||||
(`workflow_def` / `api_recipe` / `auth_recipe` / `prompt_recipe`),
|
||||
各在 `templates` 表 seed 一列定義(`kbdb/migrations/0005_arcrun_asset_templates.sql`,
|
||||
手法同 0003/0004)。定義本體打包進 `metadata_json`,比照 `execution_log`/`recipe_stat` 既有先例。
|
||||
3. **衍生資料不進 KBDB**:`idx:*`(recipe 反查)、`cron-idx:_all` 算得回來,
|
||||
留在 KV,讀不到就從 KBDB 重算(不讓 KBDB 長出垃圾列)。
|
||||
|
||||
**實作形狀(已寫在 `feat/kv-retire-recipes-16-17` 分支,未合併)**
|
||||
- 換 binding 而不是改呼叫端:`src/index.ts` 入口把 `WEBHOOKS`/`RECIPES`
|
||||
換成 KBDB 撐腰的包裝(`src/lib/durable-store.ts`),四十幾處呼叫端一行不動。
|
||||
理由:逐處改寫一定會漏,**漏掉的那一處就是下一次「東西不見了」的入口**。
|
||||
- 「哪些 key 是資產」集中成一張表(`src/lib/asset-keys.ts`),是唯一需要人看懂的東西。
|
||||
- 讀=KV 先行、miss 回源 KBDB 並補快取;寫=先 KBDB 再 KV,KBDB 失敗就拋錯(禁假綠);
|
||||
**列舉一律走 KBDB**——空 KV 列出來是「零筆」而不是「查不到」,那正是消失的形狀。
|
||||
- 搬遷與盤點:`GET /storage/audit`、`POST /storage/migrate-to-kbdb`(只增不刪、冪等、逐筆回報)。
|
||||
- KBDB 端只加一個通用原語:`PUT /entries/:id`(指定 id 的整列 upsert),零 schema 異動。
|
||||
|
||||
**影響分析**
|
||||
- 現行 active SDD `workflow-discovery`:**不受影響**。它的 `entry_type='workflow'`
|
||||
搜尋 entry 照舊雙寫,本案刻意用另一個型別 `workflow_def` 存定義本體、且不標 `embed`,
|
||||
以免同一支工作流嵌兩份向量。search/backfill 兩支端點一行未動。
|
||||
- `artifact-sharing`:本案就是它 K2 等的 #16。落地後可拆 tasks 1.5 的 KV 過渡轉接(5.2)。
|
||||
- credential:**不碰**。憑證走 CF Workers Secrets + D1 目錄(rule 01),不在本案範圍。
|
||||
- 匿名 webhook(`webhooks.ts` 的 `put(token, record)`):**目前沒搬**,仍是 KV-only。
|
||||
它也是使用者建出來的東西,但不在 #16/#17 的字面範圍內——在此列出,請 leo 裁要不要納入。
|
||||
- 效能:資產讀取多一層快取判斷;快取命中時與現況相同,miss 時多一次 KBDB 往返。
|
||||
`list` 一律回源,但會順手把整批補進快取,所以「列出來再逐筆讀」總共只多一次往返。
|
||||
|
||||
**尚未完成/誠實限制(決定要不要 confirm 前請先看這段)**
|
||||
- **端到端證據沒跑**。實作環境(雲端工人沙箱)不放行執行測試與 HTTP
|
||||
(`vitest`/`node`/`curl` 皆被權限閘擋下),所以「砍掉 KV、資產還在」這一次
|
||||
**我沒有真的做出來給你看**。已跑到的只有:5 份 migration 在本機 D1 全部套用成功、
|
||||
兩顆 worker 都能以改動後的程式碼在本機開起來、`tsc` 錯誤數與改動前一致(7 個既有錯,未新增)。
|
||||
- 那一次驗證已經寫成可執行的腳本 `scripts/verify-kv-retirement.sh`
|
||||
(開空實例 → 放 9 支工作流+3 份 recipe → 數一次 → **砍掉整個 KV 層重建** → 再數一次
|
||||
→ 真的觸發一支確認跑得動),**在能執行的機器上跑一次就是那個證據**。
|
||||
- 因此本案的狀態是 **◐ 半通**:程式碼與遷移路徑齊備,證據缺一份。
|
||||
**建議 confirm 的順序是「先跑那支腳本、綠了再合併」**,不要因為程式碼看起來完整就先併——
|
||||
這件事的整個重點就是不要再有「看起來好好的,其實東西不見了」。
|
||||
|
||||
**⏸ 停在這裡等 leo 裁**:
|
||||
① 方向 confirm 嗎(資產真相來源改 KBDB、KV 降快取)?
|
||||
② 匿名 webhook 要不要一起納入?
|
||||
③ KV 舊資料要不要清(本案只增不刪,清是另一個決定)?
|
||||
|
||||
---
|
||||
|
||||
### P2|fan-out 並行執行(一個節點的多條出邊目前是循序跑)— 2026-08-03
|
||||
|
||||
**觸發**:leo 08-03 原話——「這是在測試中的計畫,**希望體驗很好**,我發現用 gemma4 的反應非常慢。」
|
||||
|
||||
Reference in New Issue
Block a user