diff --git a/console-ui/public/portal/index.html b/console-ui/public/portal/index.html
index cbdb768..dcfd1a7 100644
--- a/console-ui/public/portal/index.html
+++ b/console-ui/public/portal/index.html
@@ -371,6 +371,7 @@ if (!window.ARCRUN_API_BASE) {
Gemini API Key
聊天問答與文件萃取都用這一把。
免費申請,金鑰只存在你自己的知識庫裡。
+<<<<<<< HEAD
@@ -380,6 +381,8 @@ if (!window.ARCRUN_API_BASE) {
+=======
+>>>>>>> main
@@ -430,7 +433,11 @@ if (!window.ARCRUN_API_BASE) {
+<<<<<<< HEAD
裝好同步小幫手並選好要看守的資料夾之後,每個資料夾會自動成為一個「庫」出現在下面——不需要人工新增。下面還是空的,代表小幫手還沒裝好或還沒選資料夾。
@@ -821,6 +828,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
// t131 AI 設定(合併 Gemini API Key+Claude 加強版)
(function () {
+<<<<<<< HEAD
/**
* 切換金鑰欄的「已存 / 可輸入」兩態(leo 2026-08-01 規格,arcrun-rag#10):
* 已存 → 唯讀顯示「已輸入」+「修改」鈕(**不顯示 key 本身**,D36)
@@ -849,6 +857,8 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
if (input) { input.placeholder = '貼上新的 Gemini API Key(留空取消變更)'; input.focus(); }
});
+=======
+>>>>>>> main
// 進入設定頁時讀取現有設定(GET /portal/admin/ai)
function loadAiConfig() {
if (!(S.profile && S.profile.role === 'admin')) return;
@@ -856,8 +866,13 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
.then(function (r) { return r.ok ? safeJson(r) : null; })
.then(function (d) {
if (!d) return;
+<<<<<<< HEAD
// leo 2026-08-01 規格:已存 → 唯讀「已輸入」+「修改」鈕;未存 → 一般輸入框
setAiKeySaved(!!d.has_key);
+=======
+ var ki = $('st-ai-key');
+ if (ki && d.has_key) ki.placeholder = '已設定(留空=不變更)';
+>>>>>>> main
var cb = $('st-ai-use-claude');
if (cb) {
// 有 claude 才能勾;沒有則停用並顯示提示
@@ -903,9 +918,13 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
sb.disabled = false;
if (guard401(x.status)) return;
if (!x.ok) { m.textContent = (x.d && x.d.error) || '儲存失敗'; m.style.color = '#b4462f'; return; }
+<<<<<<< HEAD
// 存好了才切回「已輸入」唯讀態。只有真的送了新 key 才切;
// 單純改 Claude 勾選(k 為空)時維持現狀,不誤報「已輸入」。
if (k) setAiKeySaved(true);
+=======
+ if ($('st-ai-key')) { $('st-ai-key').value = ''; $('st-ai-key').placeholder = '已設定(留空=不變更)'; }
+>>>>>>> main
var claudeOn = x.d && x.d.use_claude_for_extract;
m.textContent = claudeOn
? '已儲存,萃取改用 Claude Code。小幫手請重連一次生效。'
diff --git a/cypher-executor/node_modules b/cypher-executor/node_modules
new file mode 120000
index 0000000..5b81570
--- /dev/null
+++ b/cypher-executor/node_modules
@@ -0,0 +1 @@
+/Users/youlinhsieh/Documents/tech_projects/InkStoneCo/matrix/arcrun/cypher-executor/node_modules
\ No newline at end of file
diff --git a/cypher-executor/src/graph-executor.ts b/cypher-executor/src/graph-executor.ts
index 36e6808..ea02787 100644
--- a/cypher-executor/src/graph-executor.ts
+++ b/cypher-executor/src/graph-executor.ts
@@ -531,6 +531,26 @@ export class GraphExecutor {
iterResults.push(itemResult);
}
+ // t117: FOREACH 全部項目 success===false → 不再靜默,拋出含 status code 的錯誤
+ if (iterResults.length > 0) {
+ const failures = iterResults.filter(
+ r => r !== null && typeof r === 'object' && (r as Record).success === false
+ );
+ if (failures.length === iterResults.length) {
+ const first = failures[0] as Record;
+ const errParts: string[] = [];
+ if (first.error) errParts.push(String(first.error));
+ if (typeof first.status === 'number') errParts.push(`HTTP ${first.status}`);
+ const bodyData = first.data as { body?: string } | null | undefined;
+ if (bodyData && typeof bodyData.body === 'string' && bodyData.body) {
+ errParts.push(bodyData.body.slice(0, 200));
+ }
+ throw new Error(
+ `FOREACH 所有 ${iterResults.length} 項目均失敗(首項:${errParts.join(';') || '未知錯誤'})`
+ );
+ }
+ }
+
result = { ...(result as Record), results: iterResults };
break;
}
diff --git a/cypher-executor/src/lib/portal-seeds.ts b/cypher-executor/src/lib/portal-seeds.ts
index 003c922..3e151e0 100644
--- a/cypher-executor/src/lib/portal-seeds.ts
+++ b/cypher-executor/src/lib/portal-seeds.ts
@@ -37,4 +37,21 @@ export const PORTAL_TEMPLATE_SEEDS: PortalTemplateSeed[] = [
slots: ['name', 'display_name', 'description', 'status', 'graph_source'],
created_by: 'system',
},
+ {
+ // t130:rag_ingest_card.post_triplet 寫 POST /records {template:'triplet'}。
+ // 新實例若無此 template 回 400「template not found: triplet」→ 三元組全滅。
+ // slots 來源:kbdb_list_templates 核實(2026-07-19,library-map.test.ts PROD_TRIPLET_SLOTS)
+ // + library(library-map.ts M1 預案:recompute 歸庫用,ensurePortalTemplates 若缺則 PATCH 補入)。
+ name: 'triplet',
+ description: 'KBDB 知識圖譜三元組(kbdb-graph-plugin 寫入;portal 讀此 template 建鄰接圖)',
+ slots: [
+ 'subject', 'predicate', 'object',
+ 'source_block_id', 'confidence', 'clusters_json',
+ 'bridge_score', 'subject_entity_type', 'object_entity_type',
+ 'status', 'superseded_by',
+ 'source_uri', 'content_hash', 'source_anchor', 'predicate_embed',
+ 'library',
+ ],
+ created_by: 'system',
+ },
];
diff --git a/cypher-executor/src/lib/wasi-shim.ts b/cypher-executor/src/lib/wasi-shim.ts
index 7ea0aa4..6763cf1 100644
--- a/cypher-executor/src/lib/wasi-shim.ts
+++ b/cypher-executor/src/lib/wasi-shim.ts
@@ -353,8 +353,15 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
const result = await hostFunctions!.http_request!(url, method, headers, body);
// await 後重新拿 memory.buffer(grow 會產生新的 ArrayBuffer)
return writeOut(memory.buffer, outPtr, outLenPtr, new TextEncoder().encode(result));
- } catch {
- return 1;
+ } catch (e) {
+ // t117: 寫錯誤 envelope 到 WASM 輸出(main.go 讀 error key → success:false + 詳情);
+ // 取代只 return 1(WASM 寫無資訊的 "HTTP request failed")。
+ // writeOut 失敗(memory 壞)才 fallback return 1。
+ const errDetail = e instanceof Error ? e.message : String(e);
+ const errEnv = new TextEncoder().encode(
+ JSON.stringify({ error: `fetch failed: ${errDetail}`, status: 0, body: '' })
+ );
+ return writeOut(memory.buffer, outPtr, outLenPtr, errEnv);
}
})
: () => 1,
diff --git a/cypher-executor/src/routes/portal-data.ts b/cypher-executor/src/routes/portal-data.ts
index c17a5b5..dabaf9d 100644
--- a/cypher-executor/src/routes/portal-data.ts
+++ b/cypher-executor/src/routes/portal-data.ts
@@ -73,6 +73,32 @@ export function mapGraphWorkflowOutput(data: unknown): { neighbors: unknown[]; e
return { neighbors, edges, count: neighbors.length };
}
+/**
+ * 出處清單按 page_name 去重(t129):
+ * rag_chat workflow 把同一張卡拆成多個 block,每個 block 各回一筆 source(同頁名)→ 前端列一整頁重複。
+ * 後端去重:同一個 page_name / page 只保留第一筆,hit_count > 1 時附計數。
+ * page_name 優先;page 備用;兩者皆無 → key 為空字串(歸為同一「無頁名」組)。
+ * 純函式,單測用 export。
+ */
+export function dedupeSourcesByPage(sources: unknown[]): unknown[] {
+ const seen = new Map; count: number }>();
+ for (const s of sources) {
+ if (!s || typeof s !== 'object') continue;
+ const item = s as Record;
+ const page = typeof item.page_name === 'string' ? item.page_name :
+ typeof item.page === 'string' ? item.page : '';
+ const existing = seen.get(page);
+ if (existing) {
+ existing.count += 1;
+ } else {
+ seen.set(page, { item, count: 1 });
+ }
+ }
+ return [...seen.values()].map(({ item, count }) =>
+ count > 1 ? { ...item, hit_count: count } : item,
+ );
+}
+
/** 越庫/不存在 一律同一句 404(不洩存在性)。 */
function notFound(c: Context<{ Bindings: Bindings }>): Response {
return c.json({ error: '找不到這筆資料' }, 404);
@@ -121,6 +147,62 @@ export function filterDeprecatedEntries /[-鿿豈-]/.test(c);
+ const isAsciiAlnum = (c: string) => /[-鿿豈-]/.test(c);
+ let result = '';
+ for (let i = 0; i < q.length; i++) {
+ const ch = q[i];
+ if (result.length > 0) {
+ const prev = result[result.length - 1];
+ if (prev !== ' ' && ch !== ' ' &&
+ ((isCjk(prev) && /[A-Za-z0-9]/.test(ch)) || (/[A-Za-z0-9]/.test(prev) && isCjk(ch)))) {
+ result += ' ';
+ }
+ }
+ result += ch;
+ }
+ return result;
+}
+
+/**
+ * 從三元組節點名清單找最佳比對(t96 fuzzy fallback 用):
+ * 正規化後做 contains 比對;多命中取最短名(前綴/最精確優先)。純函式,單測用 export。
+ */
+export function findBestNodeMatch(searchTerm: string, nodeNames: string[]): string | null {
+ const term = normalizeCjkQuery(searchTerm).toLowerCase();
+ if (!term) return null;
+ const hits = nodeNames.filter(n => normalizeCjkQuery(n).toLowerCase().includes(term));
+ if (hits.length === 0) return null;
+ return hits.reduce((a, b) => a.length <= b.length ? a : b);
+}
+
+/** 從 KBDB triplet records 找最佳比對節點名(t96 plugin fuzzy fallback 用)。 */
+async function fuzzyFindNode(env: Bindings, tenant: string, searchTerm: string): Promise {
+ try {
+ const res = await kbdbFetch(env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant)}`);
+ if (!res.ok) return null;
+ const body = (await res.json().catch(() => null)) as { records?: { values?: Record }[] } | null;
+ if (!body || !Array.isArray(body.records)) return null;
+ const nodeNames = new Set();
+ for (const r of body.records) {
+ const v = r?.values;
+ if (!v || typeof v !== 'object') continue;
+ if (typeof v.subject === 'string' && v.subject.trim()) nodeNames.add(v.subject.trim());
+ if (typeof v.object === 'string' && v.object.trim()) nodeNames.add(v.object.trim());
+ }
+ return findBestNodeMatch(searchTerm, [...nodeNames]);
+ } catch {
+ return null; // fallback 失敗靜默略過,原本 0 結果直接回
+ }
+}
+
// GET /portal/data/search?q=&mode=&entry_type=&limit= — 三模式中的 keyword/semantic
//(graph 走 /portal/data/graph/*)。server 注入 owner_id+library;回應照 KBDB 原形
//(entries 含 metadata_json,前端自取 source 溯源;mode/capability_hint 誠實透傳——
@@ -129,8 +211,9 @@ portalDataRouter.get('/portal/data/search', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
- const q = c.req.query('q');
- if (!q) return c.json({ error: 'q 必填' }, 400);
+ const qRaw = c.req.query('q');
+ if (!qRaw) return c.json({ error: 'q 必填' }, 400);
+ const q = normalizeCjkQuery(qRaw); // t95: CJK/ASCII 邊界補空白(只動查詢端)
const libraries = parseLibraries(auth.user.values.libraries);
if (libraries.length === 0) {
@@ -205,6 +288,9 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) =>
return c.json({ error: '無知識圖譜檢視權限' }, 403);
}
+ // t95/t96: CJK 正規化後再用(避免「AI協作」找不到「AI 協作」節點)
+ const nodeName = normalizeCjkQuery(c.req.param('name'));
+
// ① tenant workflow 路徑(存在才走;input:node=path、depth=query 預設 2、namespace/owner=tenant)
const tenant = portalTenant(c.env);
const wfGraph = await getTenantWorkflowGraph(c.env, 'graph_neighbors');
@@ -214,7 +300,8 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) =>
const result = await executeWebhookGraph(
c.env,
wfGraph,
- { node: c.req.param('name'), depth, namespace: tenant, owner: tenant },
+ // t116: 補傳 kbdb_base;t128: 補傳 template(workflow fetch_triplets.url 用 {{input.template}})
+ { node: nodeName, depth, namespace: tenant, owner: tenant, kbdb_base: c.env.KBDB_BASE_URL ?? '', template: 'triplet' },
'graph_neighbors',
tenant,
c.executionCtx,
@@ -231,8 +318,23 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) =>
const headers: Record = {};
if (c.env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
try {
- const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(c.req.param('name'))}`, { headers });
- return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
+ const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(nodeName)}`, { headers });
+ if (!res.ok) {
+ return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
+ }
+ // t96: 精確命中 0 鄰居 → 試 substring fallback 找最佳節點名(如「AI 協作」→「AI 協作規範書」)
+ const resText = await res.text().catch(() => '');
+ let data: { neighbors?: unknown[]; edges?: unknown[] } | null = null;
+ try { data = JSON.parse(resText) as typeof data; } catch { /* 非 JSON → 直接透傳 */ }
+ if (data && Array.isArray(data.neighbors) && data.neighbors.length === 0 &&
+ Array.isArray(data.edges) && data.edges.length === 0) {
+ const fallbackName = await fuzzyFindNode(c.env, tenant, nodeName);
+ if (fallbackName && fallbackName !== nodeName) {
+ const res2 = await fetch(`${base}/graph/neighbors/${encodeURIComponent(fallbackName)}`, { headers });
+ return new Response(res2.body, { status: res2.status, headers: { 'Content-Type': 'application/json' } });
+ }
+ }
+ return new Response(resText, { status: res.status, headers: { 'Content-Type': 'application/json' } });
} catch (e) {
// plugin 沒部署/不可達 → 誠實 502(前端顯示「關聯服務不可達」,不假裝無關聯)
return c.json({ error: `kbdb-graph-plugin 不可達:${e instanceof Error ? e.message : String(e)}` }, 502);
@@ -315,10 +417,12 @@ portalDataRouter.get('/portal/data/chat', (c) =>
return c.json({ error: `rag_chat workflow 執行失敗:${result.error ?? '未知錯誤'}` }, 502);
}
// 回 workflow 回應內層 data:{answer, sources, graph_facts}(缺欄位誠實回空,不編造)
+ // t129: sources 按 page_name 去重——同一卡拆多 block 每個各一筆,前端列一整頁重複;後端去重後乾淨。
const inner = unwrapWorkflowData(result.data, 'answer');
+ const rawSources = Array.isArray(inner.sources) ? inner.sources : [];
return c.json({
answer: typeof inner.answer === 'string' ? inner.answer : '',
- sources: Array.isArray(inner.sources) ? inner.sources : [],
+ sources: dedupeSourcesByPage(rawSources),
graph_facts: inner.graph_facts ?? null,
});
}),
diff --git a/cypher-executor/src/routes/portal.ts b/cypher-executor/src/routes/portal.ts
index fda4d65..31a6a33 100644
--- a/cypher-executor/src/routes/portal.ts
+++ b/cypher-executor/src/routes/portal.ts
@@ -187,6 +187,18 @@ async function patchRecordValues(env: Bindings, recordId: string, values: Record
return body.record;
}
+async function deleteKbdbRecord(env: Bindings, recordId: string): Promise {
+ const res = await kbdbFetch(env, `/records/${encodeURIComponent(recordId)}`, { method: 'DELETE' });
+ if (res.status === 404) return false;
+ if (!res.ok) throw new KbdbError(`DELETE /records/${recordId} → ${res.status}`);
+ return true;
+}
+
+/** KV key for daemon's most-recently-reported active library names(t135 daemon hint)。 */
+function daemonActiveKey(env: Bindings): string {
+ return `${portalTenant(env)}:portal:daemon_active_libs`;
+}
+
export async function listRecordsByTemplate(env: Bindings, template: string): Promise {
const ns = portalNamespace(env);
const res = await kbdbFetch(env, `/records/by-template/${encodeURIComponent(template)}?owner_id=${encodeURIComponent(ns)}`);
@@ -490,6 +502,7 @@ portalRouter.get('/portal/session', (c) =>
return c.json({
valid: true,
display_name: v.display_name ?? '',
+ email: v.email ?? '', // t53:完成安裝清單在站內生 daemon config.json 要用(身分顯示欄)
role,
libraries,
graph_allowed: await hasGraphAccess(c.env, libraries),
@@ -701,13 +714,425 @@ function toPublicLibrary(rec: PortalRecord) {
};
}
+// POST /portal/daemon/libraries — body {email, password, libraries:[{name, display_name?}]}。
+// t52(leo 2026-07-26:「用戶可以看到我有 2 個庫,地端雲端都是 2 個,如果只有一個一定被罵」):
+// 小幫手回報它看守的資料夾各自對應的庫,雲端**自動登記**——庫目錄與地端資料夾一比一。
+// 認證=同 /portal/daemon/config(用戶帳密)。已存在的庫略過(冪等),不覆寫顯示名。
+portalRouter.post('/portal/daemon/libraries', (c) =>
+ run(c, async () => {
+ const body = (await c.req.json().catch(() => null)) as
+ | { email?: string; password?: string; libraries?: { name?: string; display_name?: string }[] }
+ | null;
+ const email = String(body?.email ?? '').trim().toLowerCase();
+ const password = String(body?.password ?? '');
+ if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400);
+ if (await isLocked(c.env, email)) return c.json({ error: '登入失敗次數過多,請稍後再試' }, 429);
+ const recordId = await findUserRecordId(c.env, email);
+ const rec = recordId ? await getRecordById(c.env, recordId) : null;
+ if (!rec || (rec.values.status ?? '') !== 'active'
+ || !(await verifyPassword(password, rec.values.password_hash ?? ''))) {
+ await recordLoginFail(c.env, email);
+ return c.json({ error: 'email 或密碼錯誤' }, 401);
+ }
+ await clearLoginFail(c.env, email);
+
+ const wanted = Array.isArray(body?.libraries) ? body!.libraries! : [];
+ const seeded = await ensurePortalTemplates(c.env);
+ if (seeded.errors.length > 0) {
+ return c.json({ error: `portal templates seed 失敗:${seeded.errors.join('; ')}` }, 502);
+ }
+ const existing = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE);
+ const have = new Set(existing.map((l) => String(l.values.name ?? '')));
+ const ns = portalNamespace(c.env);
+ const created: string[] = [];
+ for (const item of wanted) {
+ const name = String(item?.name ?? '').trim();
+ if (!isValidLibraryName(name) || name === '*' || have.has(name)) continue;
+ const res = await kbdbFetch(c.env, '/records', {
+ method: 'POST',
+ body: JSON.stringify({
+ template: LIBRARY_TEMPLATE,
+ owner_id: ns,
+ values: {
+ name,
+ display_name: String(item?.display_name ?? '').trim() || name,
+ description: '同步小幫手看守的資料夾',
+ status: 'active',
+ },
+ }),
+ });
+ if (!res.ok) throw new KbdbError(`POST /records(portal_library)→ ${res.status}`);
+ have.add(name);
+ created.push(name);
+ }
+ const after = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE);
+ // t135:記下本次 daemon 回報的所有庫名(48h TTL)供 GET /portal/admin/libraries 顯示「未同步」提示。
+ const activeNames = wanted.map((item) => String(item?.name ?? '').trim()).filter(Boolean);
+ if (activeNames.length > 0) {
+ await c.env.WEBHOOKS.put(daemonActiveKey(c.env), JSON.stringify(activeNames), { expirationTtl: 172800 });
+ }
+ return c.json({ success: true, created, libraries: after.map(toPublicLibrary) });
+ }),
+);
+
+// ── t122 萃取引擎設定(daemon 萃取用;與 chat-key AI 問答金鑰獨立管理)──────────────
+// KV key = {tenant}:portal:extractor_config,存在 WEBHOOKS KV(同 chat-key 手法)。
+// 金鑰不落 log;GET 只回 has_key:bool,不回明文。
+// daemon 未設定時預設 gemma(封測者不會有 claude,以 gemma 為友善預設)。
+
+interface ExtractorConfig {
+ engine: 'gemma' | 'claude';
+ gemini_api_key?: string;
+ llm_model?: string;
+}
+
+function extractorConfigKey(env: Bindings): string {
+ return `${portalTenant(env)}:portal:extractor_config`;
+}
+
+async function getExtractorConfig(env: Bindings): Promise {
+ const raw = await env.WEBHOOKS.get(extractorConfigKey(env), 'text');
+ if (!raw) return null;
+ try { return JSON.parse(raw) as ExtractorConfig; } catch { return null; }
+}
+
+// POST /portal/daemon/config — body {email, password}。同步小幫手憑「用戶剛設的帳密」
+// 直接換到自己的設定(t54,leo 07-25:「最好的就是把它的帳密直接輸入」)——
+// 用戶不必再下載 config.json 丟隱藏資料夾,托盤第一次開啟輸入網址+帳密就上工。
+// 認證=與 /portal/login 同一把(同樣吃節流與停用檢查);回傳只含連線設定,不含任何知識內容。
+// t122:extractor 改讀雲端設定(未設→預設 gemma;gemma+金鑰→一併下發金鑰)。
+portalRouter.post('/portal/daemon/config', (c) =>
+ run(c, async () => {
+ const body = (await c.req.json().catch(() => null)) as { email?: string; password?: string } | null;
+ const email = String(body?.email ?? '').trim().toLowerCase();
+ const password = String(body?.password ?? '');
+ if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400);
+ if (await isLocked(c.env, email)) {
+ return c.json({ error: '登入失敗次數過多,已暫時鎖定,請 15 分鐘後再試' }, 429);
+ }
+ const recordId = await findUserRecordId(c.env, email);
+ const rec = recordId ? await getRecordById(c.env, recordId) : null;
+ if (!rec) {
+ await recordLoginFail(c.env, email);
+ return c.json({ error: 'email 或密碼錯誤' }, 401);
+ }
+ if ((rec.values.status ?? '') !== 'active') return c.json({ error: '帳號已停用' }, 403);
+ if (!(await verifyPassword(password, rec.values.password_hash ?? ''))) {
+ await recordLoginFail(c.env, email);
+ return c.json({ error: 'email 或密碼錯誤' }, 401);
+ }
+ await clearLoginFail(c.env, email);
+ const tenant = portalTenant(c.env);
+ const extractorCfg = await getExtractorConfig(c.env);
+ const engine = extractorCfg?.engine ?? 'gemma';
+ const daemonCfg: Record = {
+ cypher_url: new URL(c.req.url).origin,
+ namespace: tenant,
+ library: 'kb',
+ extractor: engine,
+ email,
+ instance_name: String(rec.values.display_name ?? ''),
+ };
+ if (engine === 'gemma' && extractorCfg?.gemini_api_key) {
+ daemonCfg.gemini_api_key = extractorCfg.gemini_api_key;
+ }
+ if (extractorCfg?.llm_model) daemonCfg.llm_model = extractorCfg.llm_model;
+ return c.json({ success: true, config: daemonCfg });
+ }),
+);
+
+// ── t131 合併 AI 設定(Gemini API Key 同時設 chat+extractor;has_claude 由 daemon 回報)─────
+// KV key = {tenant}:portal:ai_config,存在 WEBHOOKS KV。
+// KV key = {tenant}:portal:daemon_caps,存 daemon 回報的能力(TTL 7 天)。
+
+interface AiConfig {
+ gemini_api_key?: string;
+ use_claude_for_extract?: boolean;
+}
+interface DaemonCapabilities {
+ has_claude: boolean;
+ daemon_version?: string;
+ os?: string;
+}
+
+function aiConfigKey(env: Bindings): string { return `${portalTenant(env)}:portal:ai_config`; }
+function daemonCapsKey(env: Bindings): string { return `${portalTenant(env)}:portal:daemon_caps`; }
+
+async function getAiConfig(env: Bindings): Promise {
+ const raw = await env.WEBHOOKS.get(aiConfigKey(env), 'text');
+ if (!raw) return null;
+ try { return JSON.parse(raw) as AiConfig; } catch { return null; }
+}
+async function getDaemonCaps(env: Bindings): Promise {
+ const raw = await env.WEBHOOKS.get(daemonCapsKey(env), 'text');
+ if (!raw) return null;
+ try { return JSON.parse(raw) as DaemonCapabilities; } catch { return null; }
+}
+
+// 將 ai_config 同步回 extractor_config(daemon/config 讀 extractor_config,保持相容)。
+async function syncExtractorFromAiConfig(env: Bindings, cfg: AiConfig): Promise {
+ const exCfg: ExtractorConfig = {
+ engine: cfg.use_claude_for_extract ? 'claude' : 'gemma',
+ };
+ if (!cfg.use_claude_for_extract && cfg.gemini_api_key) {
+ exCfg.gemini_api_key = cfg.gemini_api_key;
+ }
+ await env.WEBHOOKS.put(extractorConfigKey(env), JSON.stringify(exCfg));
+}
+
+// POST /portal/admin/ai — body {gemini_api_key?, use_claude_for_extract?}(t131)。
+// 同時設定 AI 問答金鑰(chat)與萃取引擎(extractor)。admin 閘。
+portalRouter.post('/portal/admin/ai', (c) =>
+ run(c, async () => {
+ const auth = await requirePortalAdmin(c);
+ if (!auth.ok) return auth.res;
+ const body = (await c.req.json().catch(() => null)) as { gemini_api_key?: string; use_claude_for_extract?: boolean } | null;
+ const newKey = String(body?.gemini_api_key ?? '').trim();
+ const useClause = typeof body?.use_claude_for_extract === 'boolean' ? body.use_claude_for_extract : undefined;
+
+ // 讀現有設定做合併(留空欄位=不變更)
+ const existing = await getAiConfig(c.env) ?? {};
+ const merged: AiConfig = {
+ gemini_api_key: newKey || existing.gemini_api_key,
+ use_claude_for_extract: useClause !== undefined ? useClause : (existing.use_claude_for_extract ?? false),
+ };
+ if (!merged.gemini_api_key) return c.json({ error: '請貼上你的 Gemini API Key' }, 400);
+
+ // 更新 chat(rag_chat workflow)——容忍 404(workflow 未安裝時暫存,安裝後再寫入)
+ if (newKey) {
+ const tenant = portalTenant(c.env);
+ const kvKey = `${tenant}:wf:rag_chat`;
+ const raw = await c.env.WEBHOOKS.get(kvKey, 'text');
+ if (raw) {
+ try {
+ const record = JSON.parse(raw) as Record;
+ const visit = (o: unknown): void => {
+ if (Array.isArray(o)) { o.forEach(visit); return; }
+ if (o && typeof o === 'object') {
+ const rec = o as Record;
+ for (const k of Object.keys(rec)) {
+ if (k.toLowerCase() === 'x-goog-api-key') { rec[k] = newKey; }
+ else visit(rec[k]);
+ }
+ }
+ };
+ visit(record['graph']);
+ visit(record['config']);
+ await c.env.WEBHOOKS.put(kvKey, JSON.stringify(record));
+ } catch { /* 工作流記錄損壞時靜默略過,金鑰仍存 ai_config */ }
+ }
+ // 若 rag_chat 不存在(raw===null),跳過,等 acr init 安裝後再用舊 chat-key 端點補入
+ }
+
+ // 存合併設定
+ await c.env.WEBHOOKS.put(aiConfigKey(c.env), JSON.stringify(merged));
+ // 同步回 extractor_config(daemon/config 走這個)
+ await syncExtractorFromAiConfig(c.env, merged);
+
+ return c.json({
+ success: true,
+ has_key: true,
+ use_claude_for_extract: merged.use_claude_for_extract ?? false,
+ });
+ }),
+);
+
+// GET /portal/admin/ai — 回 has_key/use_claude_for_extract/claude_available(t131)。
+portalRouter.get('/portal/admin/ai', (c) =>
+ run(c, async () => {
+ const auth = await requirePortalAdmin(c);
+ if (!auth.ok) return auth.res;
+ const cfg = await getAiConfig(c.env);
+ const caps = await getDaemonCaps(c.env);
+ return c.json({
+ success: true,
+ has_key: !!(cfg?.gemini_api_key),
+ use_claude_for_extract: cfg?.use_claude_for_extract ?? false,
+ claude_available: caps?.has_claude ?? false,
+ });
+ }),
+);
+
+// POST /portal/daemon/report-capabilities — body {email, password, has_claude, daemon_version?, os?}(t131)。
+// daemon 連線成功後回報本機能力;認證同 /portal/daemon/config(帳密)。
+// ⚠️ daemon 端改動屬 arcrun-rag repo,本端只做「收端點+存 KV+供 GET /portal/admin/ai 用」。
+portalRouter.post('/portal/daemon/report-capabilities', (c) =>
+ run(c, async () => {
+ const body = (await c.req.json().catch(() => null)) as { email?: string; password?: string; has_claude?: boolean; daemon_version?: string; os?: string } | null;
+ const email = String(body?.email ?? '').trim().toLowerCase();
+ const password = String(body?.password ?? '');
+ if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400);
+ if (await isLocked(c.env, email)) return c.json({ error: '登入失敗次數過多', }, 429);
+ const recordId = await findUserRecordId(c.env, email);
+ const rec = recordId ? await getRecordById(c.env, recordId) : null;
+ if (!rec) { await recordLoginFail(c.env, email); return c.json({ error: 'email 或密碼錯誤' }, 401); }
+ if ((rec.values.status ?? '') !== 'active') return c.json({ error: '帳號已停用' }, 403);
+ if (!(await verifyPassword(password, rec.values.password_hash ?? ''))) {
+ await recordLoginFail(c.env, email); return c.json({ error: 'email 或密碼錯誤' }, 401);
+ }
+ await clearLoginFail(c.env, email);
+ const caps: DaemonCapabilities = {
+ has_claude: body?.has_claude === true,
+ ...(body?.daemon_version ? { daemon_version: String(body.daemon_version) } : {}),
+ ...(body?.os ? { os: String(body.os) } : {}),
+ };
+ const TTL_7D = 7 * 24 * 60 * 60;
+ await c.env.WEBHOOKS.put(daemonCapsKey(c.env), JSON.stringify(caps), { expirationTtl: TTL_7D });
+ return c.json({ success: true });
+ }),
+);
+
+// POST /portal/admin/chat-key — body {key}。保留舊端點相容(新 UI 走 /portal/admin/ai)。
+// 舊版 setup checklist / 舊 UI 仍走這裡;只更新 rag_chat workflow,不同步 ai_config。
+portalRouter.post('/portal/admin/chat-key', (c) =>
+ run(c, async () => {
+ const auth = await requirePortalAdmin(c);
+ if (!auth.ok) return auth.res;
+ const body = (await c.req.json().catch(() => null)) as { key?: string } | null;
+ const key = String(body?.key ?? '').trim();
+ if (!key) return c.json({ error: '請貼上你的 Google AI 金鑰' }, 400);
+ const tenant = portalTenant(c.env);
+ const kvKey = `${tenant}:wf:rag_chat`;
+ const raw = await c.env.WEBHOOKS.get(kvKey, 'text');
+ if (!raw) return c.json({ error: '這個實例沒有安裝 AI 問答工作流' }, 404);
+ let record: Record;
+ try {
+ record = JSON.parse(raw) as Record;
+ } catch {
+ return c.json({ error: 'AI 問答工作流記錄損壞,請重新安裝' }, 500);
+ }
+ // 結構不動、只換金鑰值:走遍 graph/config,凡 x-goog-api-key 欄一律設為新值
+ //(現值可能是 {{credential.gemini_api_key}} 佔位、空字串或舊 key,都直接覆蓋)。
+ let replaced = 0;
+ const visit = (o: unknown): void => {
+ if (Array.isArray(o)) { o.forEach(visit); return; }
+ if (o && typeof o === 'object') {
+ const rec = o as Record;
+ for (const k of Object.keys(rec)) {
+ if (k.toLowerCase() === 'x-goog-api-key') { rec[k] = key; replaced += 1; }
+ else visit(rec[k]);
+ }
+ }
+ };
+ visit(record['graph']);
+ visit(record['config']);
+ if (replaced === 0) return c.json({ error: '工作流裡找不到金鑰欄位,請重新安裝後再試' }, 500);
+ await c.env.WEBHOOKS.put(kvKey, JSON.stringify(record));
+ return c.json({ success: true, replaced });
+ }),
+);
+
+// POST /portal/admin/extractor — body {engine, gemini_api_key?, llm_model?}(t122)。
+// 保留舊端點相容(新 UI 走 /portal/admin/ai)。
+// admin 閘(同 chat-key 等級)。金鑰不落 log;存 WEBHOOKS KV。
+portalRouter.post('/portal/admin/extractor', (c) =>
+ run(c, async () => {
+ const auth = await requirePortalAdmin(c);
+ if (!auth.ok) return auth.res;
+ const body = (await c.req.json().catch(() => null)) as { engine?: string; gemini_api_key?: string; llm_model?: string } | null;
+ const engine = String(body?.engine ?? '').trim().toLowerCase();
+ if (engine !== 'gemma' && engine !== 'claude') {
+ return c.json({ error: 'engine 只能是 gemma 或 claude' }, 400);
+ }
+ const cfg: ExtractorConfig = { engine: engine as 'gemma' | 'claude' };
+ if (engine === 'gemma') {
+ const key = String(body?.gemini_api_key ?? '').trim();
+ if (key) cfg.gemini_api_key = key;
+ }
+ const model = String(body?.llm_model ?? '').trim();
+ if (model) cfg.llm_model = model;
+ await c.env.WEBHOOKS.put(extractorConfigKey(c.env), JSON.stringify(cfg));
+ return c.json({ success: true, engine: cfg.engine, has_key: engine === 'gemma' && !!cfg.gemini_api_key });
+ }),
+);
+
+// GET /portal/admin/extractor — 回 engine + has_key(不回金鑰明文)(t122)。
+// 保留舊端點相容(新 UI 走 /portal/admin/ai)。
+portalRouter.get('/portal/admin/extractor', (c) =>
+ run(c, async () => {
+ const auth = await requirePortalAdmin(c);
+ if (!auth.ok) return auth.res;
+ const cfg = await getExtractorConfig(c.env);
+ return c.json({
+ success: true,
+ engine: cfg?.engine ?? 'gemma',
+ has_key: cfg?.engine === 'gemma' && !!cfg?.gemini_api_key,
+ llm_model: cfg?.llm_model ?? null,
+ });
+ }),
+);
+
// GET /portal/admin/libraries — 庫目錄列表。
+// t52(leo 2026-07-26:「地端 2 個資料夾、雲端就要 2 個庫,只有一個一定被罵」):
+// 除了登記簿裡的庫,**也把資料裡實際蓋過章的庫一併列出**(標 auto:true)——
+// 蓋章即現身,用戶不必先去登記;登記簿只負責顯示名/圖譜來源這些額外設定。
+// t135:讀 daemon 最近回報的 active libs(KV TTL 48h),已登記的庫若不在其中標 daemon_watching:false。
portalRouter.get('/portal/admin/libraries', (c) =>
run(c, async () => {
const auth = await requirePortalAdmin(c);
if (!auth.ok) return auth.res;
const libs = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE);
- return c.json({ success: true, libraries: libs.map(toPublicLibrary), count: libs.length });
+ // 讀 daemon 最近回報的 active lib names(若 KV 不存在 = daemon 從未回報,不標 hint)
+ let daemonActive: Set | null = null;
+ try {
+ const raw = await c.env.WEBHOOKS.get(daemonActiveKey(c.env), 'text');
+ if (raw) daemonActive = new Set((JSON.parse(raw) as string[]).map((n) => String(n).trim()));
+ } catch { /* KV 不可達不擋主流程 */ }
+ const out = libs.map((rec) => {
+ const lib = toPublicLibrary(rec);
+ const watching = daemonActive === null ? undefined : daemonActive.has(lib.name);
+ return { ...lib, ...(watching !== undefined ? { daemon_watching: watching } : {}) };
+ });
+ const known = new Set(out.map((l) => l.name));
+ // t142:資料面實際出現的庫+統計數字(卡數、三元組數)並行撈取,避免 N+1。
+ // 任一端點失敗不擋登記簿列表(誠實降級:stats 保持 0,不炸主流程)。
+ try {
+ const tenant = portalTenant(c.env);
+ const ownerParam = `owner_id=${encodeURIComponent(tenant)}`;
+ const [autoRes, cardRes, tripletRes] = await Promise.all([
+ kbdbFetch(c.env, `/entries/libraries?${ownerParam}`).catch(() => null),
+ kbdbFetch(c.env, `/entries/library-stats?${ownerParam}`).catch(() => null),
+ kbdbFetch(c.env, `/records/triplet-stats?${ownerParam}`).catch(() => null),
+ ]);
+ // 解析統計,建成 Map 供 O(1) 查找
+ const cardMap = new Map();
+ if (cardRes?.ok) {
+ const body = (await cardRes.json()) as { stats?: { library: string; card_count: number }[] };
+ for (const s of body.stats ?? []) cardMap.set(s.library, s.card_count);
+ }
+ const tripletMap = new Map();
+ if (tripletRes?.ok) {
+ const body = (await tripletRes.json()) as { stats?: { library: string; triplet_count: number }[] };
+ for (const s of body.stats ?? []) tripletMap.set(s.library, s.triplet_count);
+ }
+ // 已登記庫補入統計
+ for (const lib of out) {
+ (lib as Record).card_count = cardMap.get(lib.name) ?? 0;
+ (lib as Record).triplet_count = tripletMap.get(lib.name) ?? 0;
+ }
+ // 資料面自動出現的庫(蓋章即現身)
+ if (autoRes?.ok) {
+ const body = (await autoRes.json()) as { libraries?: string[] };
+ for (const name of body.libraries ?? []) {
+ const n = String(name ?? '').trim();
+ // general 是系統內部「未標庫」桶(未標記 entry 的 fallback),不在用戶目錄露臉
+ if (!n || n === 'general' || known.has(n)) continue;
+ known.add(n);
+ const watching = daemonActive === null ? undefined : daemonActive.has(n);
+ out.push({
+ record_id: '', name: n, display_name: n,
+ description: '資料同步時自動出現(可在此補顯示名)',
+ status: 'active', graph_source: false, auto: true,
+ card_count: cardMap.get(n) ?? 0,
+ triplet_count: tripletMap.get(n) ?? 0,
+ ...(watching !== undefined ? { daemon_watching: watching } : {}),
+ });
+ }
+ }
+ } catch {
+ // 資料面查不到不擋登記簿(誠實降級:至少顯示已登記的庫)
+ }
+ return c.json({ success: true, libraries: out, count: out.length });
}),
);
@@ -874,6 +1299,34 @@ portalRouter.get('/portal/admin/ai', (c) =>
}),
);
+// DELETE /portal/admin/libraries/by-name/:name — 移除 auto 庫(只有資料章記、無登記簿 record)。
+// 語意:把該庫的所有 entries 標 deprecated → 資料不刪、重新 ingest 可還原。
+// ⚠️ 影響資料可搜性,要求 body.confirm 等於庫名才執行(二次確認)。
+// ⚠️ 此路由必須在 DELETE /:id 之前宣告(Hono 先到先比;by-name 否則被當成 :id)。
+portalRouter.delete('/portal/admin/libraries/by-name/:name', (c) =>
+ run(c, async () => {
+ const auth = await requirePortalAdmin(c);
+ if (!auth.ok) return auth.res;
+ const name = decodeURIComponent(c.req.param('name'));
+ const body = await c.req.json().catch(() => null);
+ const confirm = String(body?.confirm ?? '').trim();
+ if (!confirm) return c.json({ error: 'body 須帶 { confirm: "<庫名>" } 才執行(移除會影響資料可搜性)' }, 400);
+ if (confirm !== name) return c.json({ error: `confirm 值「${confirm}」與庫名「${name}」不符` }, 400);
+ const ownerId = portalTenant(c.env);
+ const res = await kbdbFetch(c.env, '/entries/deprecate-by-library', {
+ method: 'PATCH',
+ body: JSON.stringify({ owner_id: ownerId, library: name }),
+ });
+ if (!res.ok) throw new KbdbError(`PATCH /entries/deprecate-by-library → ${res.status}`);
+ const data = (await res.json()) as { deprecated_count?: number };
+ return c.json({
+ success: true,
+ deprecated_count: data.deprecated_count ?? 0,
+ message: `已從自動清單移除「${name}」(共標記 ${data.deprecated_count ?? 0} 筆資料不可搜)。資料保留可還原——重新同步時會再出現。`,
+ });
+ }),
+);
+
// POST /portal/admin/ai — 存 Gemini key 與/或 Claude 偏好(role=admin 閘)。
// body: { gemini_api_key?: string, use_claude_for_extract?: boolean }
// 兩者皆選填(前端「留空=不變更」);兩者都沒給 → 400,避免看起來成功但什麼都沒做。
@@ -921,3 +1374,25 @@ portalRouter.post('/portal/admin/ai', (c) =>
});
}),
);
+
+// DELETE /portal/admin/libraries/:id — 移除已登記庫(有 record_id 的登記簿 record)。
+// 只刪登記簿那筆 record;知識資料(entries with library=name)完全不動。
+// 資料若有的話,重新同步後會以 auto 庫重新出現。
+portalRouter.delete('/portal/admin/libraries/:id', (c) =>
+ run(c, async () => {
+ const auth = await requirePortalAdmin(c);
+ if (!auth.ok) return auth.res;
+ const recordId = c.req.param('id');
+ // 成員資格驗(防憑空 id 打到不相干 record)
+ const libs = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE);
+ const target = libs.find((l) => l.record_id === recordId);
+ if (!target) return c.json({ error: '庫不存在' }, 404);
+ const found = await deleteKbdbRecord(c.env, recordId);
+ if (!found) return c.json({ error: '庫不存在' }, 404);
+ return c.json({
+ success: true,
+ name: target.values.name ?? '',
+ message: `已從目錄移除「${target.values.display_name ?? target.values.name ?? ''}」。資料仍在,重新同步會再出現。`,
+ });
+ }),
+);
diff --git a/cypher-executor/src/types.ts b/cypher-executor/src/types.ts
index 5ffe5e8..f33a553 100644
--- a/cypher-executor/src/types.ts
+++ b/cypher-executor/src/types.ts
@@ -103,6 +103,9 @@ export type Bindings = {
GITEA_TOKEN?: string; // wrangler secret(建議唯讀 scope token)
GITEA_SPRINT_REPO?: string; // 預設 Leo/InkStoneCo
GITEA_SPRINT_DIR?: string; // 預設 system-dev/docs/3-specs/autonomy-dispatch
+ // 安裝器部署時注入的 bundle 版本(格式 "YYYY-MM-DD/commit",老實例無此 var)。
+ // daemon 比對此值決定是否提示用戶更新(/health 曝露,缺 var 時回空字串)。
+ ARCRUN_BUNDLE_VERSION?: string;
// MCP access_token 存活秒數的「顯示鏡像」(console 設定頁 MCP TTL 佔位區塊用)。
// 真相住在 mcp worker 的同名 env(mcp/src/types.ts,預設 2592000=30 天);cypher 這份
// 只供顯示,兩處部署時要一致(#32 形態 config 同步教訓)。未設 → 頁面如實標「預設值」。
diff --git a/cypher-executor/tests/executor.test.ts b/cypher-executor/tests/executor.test.ts
index e192244..bb7bbd1 100644
--- a/cypher-executor/tests/executor.test.ts
+++ b/cypher-executor/tests/executor.test.ts
@@ -1,6 +1,8 @@
// Cypher Executor 端到端測試
import { SELF } from 'cloudflare:test';
import { describe, it, expect } from 'vitest';
+import { GraphExecutor } from '../src/graph-executor';
+import type { ComponentRunner, ExecutionGraph } from '../src/types';
describe('GET /', () => {
it('回傳服務狀態', async () => {
@@ -191,4 +193,60 @@ describe('POST /execute', () => {
});
expect(res.status).toBe(400);
});
+
+});
+
+// t117: FOREACH 全部項目失敗 → 錯誤訊息含 status code(GraphExecutor 單元測試)
+describe('t117: FOREACH 全項失敗 → ExecutionError 含 status code', () => {
+ it('FOREACH 所有項目 success:false(含 status 401)→ executor.execute() 拋出含 "401" 的錯誤', async () => {
+ // mock loader:任何零件都回 {success:false, status:401, error:"HTTP 401"}
+ const failLoader = async (_: string): Promise =>
+ async () => ({ success: false, status: 401, error: 'HTTP 401', data: { body: 'Unauthorized' } });
+
+ const executor = new GraphExecutor(failLoader);
+
+ const graph: ExecutionGraph = {
+ id: 'foreach-fail-t117',
+ name: 'FOREACH 全失敗',
+ nodes: [
+ { id: 'input', type: 'Input', data: { items: ['a', 'b'] } },
+ { id: 'writer', type: 'Component', componentId: 'http_request' },
+ ],
+ edges: [
+ { from: 'input', to: 'writer', type: 'FOREACH', iterator: 'item' },
+ ],
+ };
+
+ // t117 核心驗證:全部失敗 → throw(不再靜默)
+ await expect(executor.execute(graph, {})).rejects.toThrow(/401/);
+ });
+
+ it('FOREACH 部分項目成功 → 不拋出(只有全部失敗才報錯)', async () => {
+ let callCount = 0;
+ // 第一次呼叫失敗,第二次成功(部分失敗不觸發 t117 all-fail 路徑)
+ const mixedLoader = async (_: string): Promise =>
+ async () => {
+ callCount++;
+ if (callCount === 1) return { success: false, status: 401, error: 'HTTP 401' };
+ return { success: true, data: { ok: true } };
+ };
+
+ const executor = new GraphExecutor(mixedLoader);
+
+ const graph: ExecutionGraph = {
+ id: 'foreach-mixed-t117',
+ name: 'FOREACH 部分失敗',
+ nodes: [
+ { id: 'input', type: 'Input', data: { items: ['a', 'b'] } },
+ { id: 'writer', type: 'Component', componentId: 'http_request' },
+ ],
+ edges: [
+ { from: 'input', to: 'writer', type: 'FOREACH', iterator: 'item' },
+ ],
+ };
+
+ // 部分失敗 → 不拋出,正常回傳 results 陣列
+ const result = await executor.execute(graph, {});
+ expect(result).toBeDefined();
+ });
});
diff --git a/cypher-executor/tests/health.test.ts b/cypher-executor/tests/health.test.ts
new file mode 100644
index 0000000..19d2215
--- /dev/null
+++ b/cypher-executor/tests/health.test.ts
@@ -0,0 +1,27 @@
+import { describe, it, expect } from 'vitest';
+import { SELF } from 'cloudflare:test';
+import { healthRouter } from '../src/routes/health';
+import type { Bindings, ExecutionContext } from '../src/types';
+
+describe('GET /health — bundle_version 欄位', () => {
+ it('無 ARCRUN_BUNDLE_VERSION 時回空字串(老實例情境)', async () => {
+ // wrangler.test.toml 不設此 var → 走 ?? '' fallback
+ const res = await SELF.fetch('http://localhost/health');
+ const data = await res.json() as { ok: boolean; bundle_version: string };
+ expect(res.status).toBe(200);
+ expect(data.ok).toBe(true);
+ expect(data.bundle_version).toBe('');
+ });
+
+ it('有 ARCRUN_BUNDLE_VERSION 時回其值(安裝器注入情境)', async () => {
+ const fakeEnv = { ARCRUN_BUNDLE_VERSION: '2026-07-28/6d06162' } as unknown as Bindings;
+ const res = await healthRouter.fetch(
+ new Request('http://localhost/health'),
+ fakeEnv,
+ {} as ExecutionContext,
+ );
+ const data = await res.json() as { ok: boolean; bundle_version: string };
+ expect(data.ok).toBe(true);
+ expect(data.bundle_version).toBe('2026-07-28/6d06162');
+ });
+});
diff --git a/cypher-executor/tests/portal-admin.test.ts b/cypher-executor/tests/portal-admin.test.ts
index fa7f1f2..40c83ec 100644
--- a/cypher-executor/tests/portal-admin.test.ts
+++ b/cypher-executor/tests/portal-admin.test.ts
@@ -66,7 +66,7 @@ function mockListByTemplate(template: string, records: { record_id: string; valu
}
function mockTemplatesExist() {
- for (const name of ['portal_user', 'portal_library']) {
+ for (const name of ['portal_user', 'portal_library', 'triplet']) {
fetchMock
.get(KBDB)
.intercept({ path: `/templates/${name}`, method: 'GET' })
@@ -350,12 +350,262 @@ describe('/portal/admin/libraries', () => {
const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-user' });
expect(res.status).toBe(403);
});
+
+ it('GET auto 庫列表過濾 general(general 是系統桶,不在用戶目錄顯示)', async () => {
+ await seedAdminSession();
+ mockGetRecord('rec_admin', adminValues());
+ mockListByTemplate('portal_library', []);
+ // t142:GET /portal/admin/libraries 現在並行呼叫三個 kbdb 端點,三個都要 mock
+ fetchMock
+ .get(KBDB)
+ .intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
+ .reply(200, { libraries: ['kb', 'general', 'notes'] });
+ fetchMock
+ .get(KBDB)
+ .intercept({ path: (p: string) => p.startsWith('/entries/library-stats'), method: 'GET' })
+ .reply(200, { success: true, stats: [] });
+ fetchMock
+ .get(KBDB)
+ .intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
+ .reply(200, { success: true, stats: [] });
+ const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-admin' });
+ expect(res.status).toBe(200);
+ const data = (await res.json()) as { libraries: { name: string; auto?: boolean }[] };
+ const names = data.libraries.map((l) => l.name);
+ expect(names).toContain('kb');
+ expect(names).toContain('notes');
+ expect(names).not.toContain('general');
+ });
});
-// ═══════════════ 6. /portal HTML 殼(P4 admin 頁後紅線不回退)═══════════════
+// ═══════════════ t142 庫目錄卡數+三元組數 ═══════════════
+
+describe('GET /portal/admin/libraries + stats(t142)', () => {
+ it('kbdb 回傳統計 → 已登記庫帶 card_count + triplet_count', async () => {
+ await seedAdminSession();
+ mockGetRecord('rec_admin', adminValues());
+ mockListByTemplate('portal_library', [
+ { record_id: 'rec_lib_kb', values: { name: 'kb', display_name: '知識庫', status: 'active', graph_source: 'false' } },
+ ]);
+ fetchMock
+ .get(KBDB)
+ .intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
+ .reply(200, { libraries: ['kb'] });
+ fetchMock
+ .get(KBDB)
+ .intercept({ path: (p: string) => p.startsWith('/entries/library-stats'), method: 'GET' })
+ .reply(200, { success: true, stats: [{ library: 'kb', card_count: 42 }] });
+ fetchMock
+ .get(KBDB)
+ .intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
+ .reply(200, { success: true, stats: [{ library: 'kb', triplet_count: 111 }] });
+ const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-admin' });
+ expect(res.status).toBe(200);
+ const data = (await res.json()) as { libraries: { name: string; card_count?: number; triplet_count?: number }[] };
+ const kb = data.libraries.find((l) => l.name === 'kb');
+ expect(kb).toBeDefined();
+ expect(kb!.card_count).toBe(42);
+ expect(kb!.triplet_count).toBe(111);
+ });
+
+ it('auto 庫也帶 card_count + triplet_count', async () => {
+ await seedAdminSession();
+ mockGetRecord('rec_admin', adminValues());
+ mockListByTemplate('portal_library', []);
+ fetchMock
+ .get(KBDB)
+ .intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
+ .reply(200, { libraries: ['notes'] });
+ fetchMock
+ .get(KBDB)
+ .intercept({ path: (p: string) => p.startsWith('/entries/library-stats'), method: 'GET' })
+ .reply(200, { success: true, stats: [{ library: 'notes', card_count: 7 }] });
+ fetchMock
+ .get(KBDB)
+ .intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
+ .reply(200, { success: true, stats: [{ library: 'notes', triplet_count: 108 }] });
+ const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-admin' });
+ expect(res.status).toBe(200);
+ const data = (await res.json()) as { libraries: { name: string; card_count?: number; triplet_count?: number; auto?: boolean }[] };
+ const notes = data.libraries.find((l) => l.name === 'notes');
+ expect(notes).toBeDefined();
+ expect(notes!.auto).toBe(true);
+ expect(notes!.card_count).toBe(7);
+ expect(notes!.triplet_count).toBe(108);
+ });
+
+ it('庫無內容時 card_count=0 + triplet_count=0(前端顯示「還沒有內容」)', async () => {
+ await seedAdminSession();
+ mockGetRecord('rec_admin', adminValues());
+ mockListByTemplate('portal_library', [
+ { record_id: 'rec_lib_empty', values: { name: 'empty', display_name: '空庫', status: 'active', graph_source: 'false' } },
+ ]);
+ fetchMock
+ .get(KBDB)
+ .intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
+ .reply(200, { libraries: [] });
+ fetchMock
+ .get(KBDB)
+ .intercept({ path: (p: string) => p.startsWith('/entries/library-stats'), method: 'GET' })
+ .reply(200, { success: true, stats: [] });
+ fetchMock
+ .get(KBDB)
+ .intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
+ .reply(200, { success: true, stats: [] });
+ const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-admin' });
+ expect(res.status).toBe(200);
+ const data = (await res.json()) as { libraries: { name: string; card_count: number; triplet_count: number }[] };
+ const empty = data.libraries.find((l) => l.name === 'empty');
+ expect(empty).toBeDefined();
+ expect(empty!.card_count).toBe(0);
+ expect(empty!.triplet_count).toBe(0);
+ });
+});
+
+// ═══════════════ t135 庫目錄移除 ═══════════════
+
+describe('DELETE /portal/admin/libraries(t135)', () => {
+ it('DELETE /:id — 成功移除已登記庫;KBDB /records/:id DELETE 被呼叫', async () => {
+ await seedAdminSession();
+ mockGetRecord('rec_admin', adminValues());
+ // 成員驗證:list by template 回有該 record
+ mockListByTemplate('portal_library', [
+ { record_id: 'rec_lib1', values: { name: 'finance', display_name: '財務庫', status: 'active' } },
+ ]);
+ let deleteCalled = false;
+ fetchMock
+ .get(KBDB)
+ .intercept({ path: '/records/rec_lib1', method: 'DELETE' })
+ .reply(200, () => { deleteCalled = true; return { success: true }; });
+ const res = await json('DELETE', '/portal/admin/libraries/rec_lib1', undefined, { Authorization: 'Bearer tok-admin' });
+ expect(res.status).toBe(200);
+ const data = (await res.json()) as { success: boolean; name: string; message: string };
+ expect(data.success).toBe(true);
+ expect(data.name).toBe('finance');
+ expect(deleteCalled).toBe(true);
+ });
+
+ it('DELETE /:id — 庫不在目錄 → 404', async () => {
+ await seedAdminSession();
+ mockGetRecord('rec_admin', adminValues());
+ mockListByTemplate('portal_library', []); // 空目錄
+ const res = await json('DELETE', '/portal/admin/libraries/rec_lib_x', undefined, { Authorization: 'Bearer tok-admin' });
+ expect(res.status).toBe(404);
+ });
+
+ it('DELETE /:id — 非 admin → 403', async () => {
+ await seedAdminSession('tok-user', 'rec_u1');
+ mockGetRecord('rec_u1', userValues());
+ const res = await json('DELETE', '/portal/admin/libraries/rec_lib1', undefined, { Authorization: 'Bearer tok-user' });
+ expect(res.status).toBe(403);
+ });
+
+ it('DELETE /by-name/:name — confirm 符合 → 呼叫 KBDB deprecate-by-library', async () => {
+ await seedAdminSession();
+ mockGetRecord('rec_admin', adminValues());
+ let deprecateCalled = false;
+ fetchMock
+ .get(KBDB)
+ .intercept({ path: '/entries/deprecate-by-library', method: 'PATCH' })
+ .reply(200, () => { deprecateCalled = true; return { success: true, deprecated_count: 12 }; });
+ const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', { confirm: 'kb' }, { Authorization: 'Bearer tok-admin' });
+ expect(res.status).toBe(200);
+ const data = (await res.json()) as { success: boolean; deprecated_count: number };
+ expect(data.success).toBe(true);
+ expect(data.deprecated_count).toBe(12);
+ expect(deprecateCalled).toBe(true);
+ });
+
+ it('DELETE /by-name/:name — 無 confirm → 400', async () => {
+ await seedAdminSession();
+ mockGetRecord('rec_admin', adminValues());
+ const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', {}, { Authorization: 'Bearer tok-admin' });
+ expect(res.status).toBe(400);
+ });
+
+ it('DELETE /by-name/:name — confirm 不符 → 400', async () => {
+ await seedAdminSession();
+ mockGetRecord('rec_admin', adminValues());
+ const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', { confirm: 'wrong' }, { Authorization: 'Bearer tok-admin' });
+ expect(res.status).toBe(400);
+ });
+
+ it('DELETE /by-name/:name — 非 admin → 403', async () => {
+ await seedAdminSession('tok-user', 'rec_u1');
+ mockGetRecord('rec_u1', userValues());
+ const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', { confirm: 'kb' }, { Authorization: 'Bearer tok-user' });
+ expect(res.status).toBe(403);
+ });
+});
+
+// ═══════════════ 6. t122 萃取引擎金鑰雲端下發 ═══════════════
+
+describe('/portal/admin/extractor + /portal/daemon/config 萃取引擎(t122)', () => {
+ const USER_EMAIL = 'daemon@example.com';
+ const USER_PW = 'unit-test-pw-1'; // 與 storedHash 配對(beforeAll 計算)
+ const USER_RECORD = 'rec_daemon_user';
+ const EXTRACTOR_KV_KEY = 'leo:portal:extractor_config'; // wrangler.test.toml CONSOLE_TENANT=leo
+
+ /** mock email head lookup(findUserRecordId 走這個路徑)*/
+ function mockEmailLookup(email: string, recordId: string | null) {
+ const needle = new URLSearchParams({ page_name: email }).toString();
+ fetchMock
+ .get(KBDB)
+ .intercept({
+ path: (p: string) => p.startsWith('/entries?') && p.includes(needle) && p.includes(encodeURIComponent(NS)),
+ method: 'GET',
+ })
+ .reply(200, { success: true, entries: recordId ? [{ content: recordId }] : [], count: recordId ? 1 : 0 });
+ }
+
+ it('未設定 → daemon/config 下發 extractor=gemma,無 gemini_api_key', async () => {
+ // 確保 KV 沒有 extractor config
+ await env.WEBHOOKS.delete(EXTRACTOR_KV_KEY);
+ mockEmailLookup(USER_EMAIL, USER_RECORD);
+ mockGetRecord(USER_RECORD, adminValues({ email: USER_EMAIL, password_hash: storedHash }));
+ const res = await json('POST', '/portal/daemon/config', { email: USER_EMAIL, password: USER_PW });
+ expect(res.status).toBe(200);
+ const data = (await res.json()) as { success: boolean; config: Record };
+ expect(data.success).toBe(true);
+ expect(data.config.extractor).toBe('gemma');
+ expect('gemini_api_key' in data.config).toBe(false);
+ });
+
+ it('設定 gemma+金鑰後 → daemon/config 下發含 gemini_api_key', async () => {
+ await env.WEBHOOKS.put(EXTRACTOR_KV_KEY, JSON.stringify({ engine: 'gemma', gemini_api_key: 'AIza-test-key-999' }));
+ mockEmailLookup(USER_EMAIL, USER_RECORD);
+ mockGetRecord(USER_RECORD, adminValues({ email: USER_EMAIL, password_hash: storedHash }));
+ const res = await json('POST', '/portal/daemon/config', { email: USER_EMAIL, password: USER_PW });
+ expect(res.status).toBe(200);
+ const data = (await res.json()) as { success: boolean; config: Record };
+ expect(data.config.extractor).toBe('gemma');
+ expect(data.config.gemini_api_key).toBe('AIza-test-key-999');
+ // cleanup
+ await env.WEBHOOKS.delete(EXTRACTOR_KV_KEY);
+ });
+
+ it('GET /portal/admin/extractor → has_key=true,回應不含金鑰明文', async () => {
+ await env.WEBHOOKS.put(EXTRACTOR_KV_KEY, JSON.stringify({ engine: 'gemma', gemini_api_key: 'AIza-secret-key' }));
+ await seedAdminSession();
+ mockGetRecord('rec_admin', adminValues());
+ const res = await json('GET', '/portal/admin/extractor', undefined, { Authorization: 'Bearer tok-admin' });
+ expect(res.status).toBe(200);
+ const data = (await res.json()) as { success: boolean; engine: string; has_key: boolean };
+ expect(data.engine).toBe('gemma');
+ expect(data.has_key).toBe(true);
+ // 回應主體不含金鑰明文
+ const raw = JSON.stringify(data);
+ expect(raw).not.toContain('AIza-secret-key');
+ expect(raw).not.toContain('gemini_api_key');
+ // cleanup
+ await env.WEBHOOKS.delete(EXTRACTOR_KV_KEY);
+ });
+});
+
+// ═══════════════ 7. /portal HTML 殼(P4 admin 頁後紅線不回退)═══════════════
describe('GET /portal(P4 admin 頁 HTML 殼)', () => {
- it('admin view 存在;仍零租戶字串、零 /kbdb/、零 X-Arcrun-API-Key、零 Mira', async () => {
+ it('admin view 存在;仍零租戶字串、零 /kbdb/、零 X-Arcrun-API-Key、零 Mira;無 kb 種子、無登記到目錄', async () => {
const res = await SELF.fetch('http://localhost/portal');
expect(res.status).toBe(200);
const html = await res.text();
@@ -367,5 +617,171 @@ describe('GET /portal(P4 admin 頁 HTML 殼)', () => {
expect(html).not.toContain('/kbdb/');
expect(html).not.toContain('X-Arcrun-API-Key');
expect(html).not.toContain('Mira');
+ // t97a:bootstrap 後不再預埋 kb 庫
+ expect(html).not.toContain('"name": "kb"');
+ expect(html).not.toContain("name: 'kb'");
+ // t114:無「登記到目錄」按鈕
+ expect(html).not.toContain('lib-adopt');
+ expect(html).not.toContain('登記到目錄');
+ // t131:合併 AI 設定(舊兩區塊已移除)
+ expect(html).toContain('st-ai-panel');
+ expect(html).toContain('st-ai-key');
+ expect(html).toContain('st-ai-use-claude');
+ expect(html).not.toContain('st-extractor-panel');
+ expect(html).not.toContain('st-key-save'); // 舊 chat-key 存檔鈕已移除
+ });
+});
+
+// ═══════════════ 8. t131 合併 AI 設定 ═══════════════
+
+describe('/portal/admin/ai + /portal/daemon/report-capabilities(t131)', () => {
+ const USER_EMAIL = 'ai-test@example.com';
+ const USER_PW = 'unit-test-pw-1';
+ const USER_RECORD = 'rec_ai_user';
+ const AI_CONFIG_KEY = 'leo:portal:ai_config';
+ const EXTRACTOR_KV_KEY = 'leo:portal:extractor_config';
+ const DAEMON_CAPS_KEY = 'leo:portal:daemon_caps';
+
+ function aiAdminVals(): Record {
+ return { email: USER_EMAIL, display_name: 'AI 測試 admin', status: 'active', role: 'admin', password_hash: storedHash };
+ }
+
+ // 與全域 seedAdminSession 相同格式(JSON.stringify({record_id})),fetchMock 由各測試自行 mock
+ async function seedAiSession(token = 'tok-ai-admin', recordId = USER_RECORD) {
+ await env.SESSIONS_KV.put(`portal_sess:${token}`, JSON.stringify({ record_id: recordId }));
+ }
+
+ function mockAiRecord(recordId = USER_RECORD) {
+ fetchMock.get(KBDB).intercept({ path: `/records/${recordId}`, method: 'GET' }).reply(200, {
+ success: true,
+ record: { record_id: recordId, template_id: 'tpl_pu', values: aiAdminVals() },
+ });
+ }
+
+ function mockEmailLookup(email: string, recordId: string | null) {
+ const needle = new URLSearchParams({ page_name: email }).toString();
+ fetchMock.get(KBDB).intercept({
+ path: (p: string) => p.startsWith('/entries?') && p.includes(needle) && p.includes(encodeURIComponent(NS)),
+ method: 'GET',
+ }).reply(200, { success: true, entries: recordId ? [{ content: recordId }] : [], count: recordId ? 1 : 0 });
+ }
+
+ afterEach(async () => {
+ await env.WEBHOOKS.delete(AI_CONFIG_KEY);
+ await env.WEBHOOKS.delete(EXTRACTOR_KV_KEY);
+ await env.WEBHOOKS.delete(DAEMON_CAPS_KEY);
+ });
+
+ it('POST /ai — 首次設定:同時寫 ai_config+extractor_config+更新 rag_chat workflow', async () => {
+ const ragChatKey = 'leo:wf:rag_chat';
+ const workflow = { graph: { nodes: [{ config: { 'x-goog-api-key': '{{credential.gemini}}' } }] }, config: {} };
+ await env.WEBHOOKS.put(ragChatKey, JSON.stringify(workflow));
+ await seedAiSession();
+ mockAiRecord();
+ const res = await json('POST', '/portal/admin/ai',
+ { gemini_api_key: 'AIza-new-key-123', use_claude_for_extract: false },
+ { Authorization: 'Bearer tok-ai-admin' }
+ );
+ expect(res.status).toBe(200);
+ const data = (await res.json()) as { success: boolean; has_key: boolean; use_claude_for_extract: boolean };
+ expect(data.success).toBe(true);
+ expect(data.has_key).toBe(true);
+ expect(data.use_claude_for_extract).toBe(false);
+
+ const stored = JSON.parse((await env.WEBHOOKS.get(AI_CONFIG_KEY, 'text')) ?? '{}');
+ expect(stored.gemini_api_key).toBe('AIza-new-key-123');
+ expect(stored.use_claude_for_extract).toBe(false);
+
+ const exCfg = JSON.parse((await env.WEBHOOKS.get(EXTRACTOR_KV_KEY, 'text')) ?? '{}');
+ expect(exCfg.engine).toBe('gemma');
+ expect(exCfg.gemini_api_key).toBe('AIza-new-key-123');
+
+ const updated = JSON.parse((await env.WEBHOOKS.get(ragChatKey, 'text')) ?? '{}') as typeof workflow;
+ expect((updated.graph as { nodes: Array<{ config: Record }> }).nodes[0].config['x-goog-api-key']).toBe('AIza-new-key-123');
+ await env.WEBHOOKS.delete(ragChatKey);
+ });
+
+ it('POST /ai — rag_chat 不存在時不報錯(容忍,金鑰存 ai_config 即可)', async () => {
+ await seedAiSession();
+ mockAiRecord();
+ const res = await json('POST', '/portal/admin/ai',
+ { gemini_api_key: 'AIza-no-workflow-key' },
+ { Authorization: 'Bearer tok-ai-admin' }
+ );
+ expect(res.status).toBe(200);
+ const data = (await res.json()) as { success: boolean; has_key: boolean };
+ expect(data.success).toBe(true);
+ expect(data.has_key).toBe(true);
+ const stored = JSON.parse((await env.WEBHOOKS.get(AI_CONFIG_KEY, 'text')) ?? '{}');
+ expect(stored.gemini_api_key).toBe('AIza-no-workflow-key');
+ });
+
+ it('POST /ai — use_claude_for_extract=true:extractor engine=claude,不附 gemini_api_key', async () => {
+ await seedAiSession();
+ mockAiRecord();
+ const res = await json('POST', '/portal/admin/ai',
+ { gemini_api_key: 'AIza-key-888', use_claude_for_extract: true },
+ { Authorization: 'Bearer tok-ai-admin' }
+ );
+ expect(res.status).toBe(200);
+ const data = (await res.json()) as { success: boolean; use_claude_for_extract: boolean };
+ expect(data.use_claude_for_extract).toBe(true);
+ const exCfg = JSON.parse((await env.WEBHOOKS.get(EXTRACTOR_KV_KEY, 'text')) ?? '{}');
+ expect(exCfg.engine).toBe('claude');
+ expect('gemini_api_key' in exCfg).toBe(false);
+ });
+
+ it('GET /ai — 不回明文金鑰;has_key=true;claude_available 依 daemon_caps', async () => {
+ await env.WEBHOOKS.put(AI_CONFIG_KEY, JSON.stringify({ gemini_api_key: 'AIza-secret-456', use_claude_for_extract: false }));
+ await env.WEBHOOKS.put(DAEMON_CAPS_KEY, JSON.stringify({ has_claude: true }));
+ await seedAiSession();
+ mockAiRecord();
+ const res = await json('GET', '/portal/admin/ai', undefined, { Authorization: 'Bearer tok-ai-admin' });
+ expect(res.status).toBe(200);
+ const data = (await res.json()) as { success: boolean; has_key: boolean; use_claude_for_extract: boolean; claude_available: boolean };
+ expect(data.has_key).toBe(true);
+ expect(data.use_claude_for_extract).toBe(false);
+ expect(data.claude_available).toBe(true);
+ const raw = JSON.stringify(data);
+ expect(raw).not.toContain('AIza-secret-456');
+ expect(raw).not.toContain('gemini_api_key');
+ });
+
+ it('GET /ai — 沒有 daemon_caps → claude_available=false', async () => {
+ await env.WEBHOOKS.put(AI_CONFIG_KEY, JSON.stringify({ gemini_api_key: 'AIza-key-777' }));
+ await seedAiSession();
+ mockAiRecord();
+ const res = await json('GET', '/portal/admin/ai', undefined, { Authorization: 'Bearer tok-ai-admin' });
+ expect(res.status).toBe(200);
+ const data = (await res.json()) as { claude_available: boolean };
+ expect(data.claude_available).toBe(false);
+ });
+
+ it('POST /portal/daemon/report-capabilities — 有 claude:daemon_caps 寫入 has_claude=true', async () => {
+ mockEmailLookup(USER_EMAIL, USER_RECORD);
+ mockAiRecord();
+ const res = await json('POST', '/portal/daemon/report-capabilities', {
+ email: USER_EMAIL, password: USER_PW, has_claude: true, daemon_version: '1.2.0', os: 'darwin',
+ });
+ expect(res.status).toBe(200);
+ const data = (await res.json()) as { success: boolean };
+ expect(data.success).toBe(true);
+ const caps = JSON.parse((await env.WEBHOOKS.get(DAEMON_CAPS_KEY, 'text')) ?? '{}');
+ expect(caps.has_claude).toBe(true);
+ expect(caps.daemon_version).toBe('1.2.0');
+ });
+
+ it('舊端點 /portal/admin/chat-key 仍可用(相容)', async () => {
+ const ragChatKey = 'leo:wf:rag_chat';
+ const workflow = { graph: { nodes: [{ config: { 'x-goog-api-key': 'old' } }] }, config: {} };
+ await env.WEBHOOKS.put(ragChatKey, JSON.stringify(workflow));
+ await seedAiSession();
+ mockAiRecord();
+ const res = await json('POST', '/portal/admin/chat-key', { key: 'AIza-compat-key' }, { Authorization: 'Bearer tok-ai-admin' });
+ expect(res.status).toBe(200);
+ const data = (await res.json()) as { success: boolean; replaced: number };
+ expect(data.success).toBe(true);
+ expect(data.replaced).toBeGreaterThan(0);
+ await env.WEBHOOKS.delete(ragChatKey);
});
});
diff --git a/cypher-executor/tests/portal-auth.test.ts b/cypher-executor/tests/portal-auth.test.ts
index d4f26cb..ace19e1 100644
--- a/cypher-executor/tests/portal-auth.test.ts
+++ b/cypher-executor/tests/portal-auth.test.ts
@@ -19,6 +19,7 @@
import { SELF, env, fetchMock } from 'cloudflare:test';
import { beforeAll, beforeEach, afterEach, describe, it, expect } from 'vitest';
import { hashPassword, verifyPassword, PBKDF2_ITERATIONS } from '../src/lib/portal-auth';
+import { PORTAL_TEMPLATE_SEEDS } from '../src/lib/portal-seeds';
const KBDB = 'https://kbdb.test';
const NS = 'leo::portal'; // wrangler.test.toml CONSOLE_TENANT=leo → 子 namespace
@@ -73,7 +74,7 @@ function mockListByTemplate(template: string, records: { record_id: string; valu
}
function mockTemplatesExist() {
- for (const name of ['portal_user', 'portal_library']) {
+ for (const name of ['portal_user', 'portal_library', 'triplet']) {
fetchMock
.get(KBDB)
.intercept({ path: `/templates/${name}`, method: 'GET' })
@@ -414,3 +415,52 @@ describe('admin 端點 role 閘', () => {
expect(res.status).toBe(404);
});
});
+
+// ═══════════════ t130 — triplet template seed ═══════════════
+
+describe('t130 — triplet template seed(PORTAL_TEMPLATE_SEEDS 補 triplet,ensurePortalTemplates 冪等)', () => {
+ it('PORTAL_TEMPLATE_SEEDS 含 triplet 且必要 slots 齊備(pure data)', () => {
+ const seed = PORTAL_TEMPLATE_SEEDS.find((s) => s.name === 'triplet');
+ expect(seed).toBeDefined();
+ for (const slot of ['subject', 'predicate', 'object', 'source_uri', 'status', 'library']) {
+ expect(seed!.slots).toContain(slot);
+ }
+ });
+
+ it('POST /init/seed — triplet 已存 → existing(冪等,不重建)', async () => {
+ for (const name of ['portal_user', 'portal_library', 'triplet']) {
+ fetchMock
+ .get(KBDB)
+ .intercept({ path: `/templates/${name}`, method: 'GET' })
+ .reply(200, { success: true, template: { id: `tpl-${name}`, name } });
+ }
+ const res = await SELF.fetch('http://localhost/init/seed', { method: 'POST' });
+ expect(res.status).toBe(200);
+ const data = (await res.json()) as { portal_templates: { created: string[]; existing: string[] } };
+ expect(data.portal_templates.existing).toContain('triplet');
+ expect(data.portal_templates.created).not.toContain('triplet');
+ });
+
+ it('POST /init/seed — triplet 缺 → 自動補建(新實例首次 seed)', async () => {
+ for (const name of ['portal_user', 'portal_library']) {
+ fetchMock
+ .get(KBDB)
+ .intercept({ path: `/templates/${name}`, method: 'GET' })
+ .reply(200, { success: true, template: { id: `tpl-${name}`, name } });
+ }
+ fetchMock
+ .get(KBDB)
+ .intercept({ path: '/templates/triplet', method: 'GET' })
+ .reply(404, { success: false, error: 'template not found: triplet' });
+ fetchMock
+ .get(KBDB)
+ .intercept({ path: '/templates', method: 'POST' })
+ .reply(200, { success: true, template: { id: 'tpl-triplet-new', name: 'triplet' } });
+
+ const res = await SELF.fetch('http://localhost/init/seed', { method: 'POST' });
+ expect(res.status).toBe(200);
+ const data = (await res.json()) as { portal_templates: { created: string[]; existing: string[] } };
+ expect(data.portal_templates.created).toContain('triplet');
+ expect(data.portal_templates.existing).not.toContain('triplet');
+ });
+});
diff --git a/cypher-executor/tests/portal-data.test.ts b/cypher-executor/tests/portal-data.test.ts
index 0dd975d..271c32a 100644
--- a/cypher-executor/tests/portal-data.test.ts
+++ b/cypher-executor/tests/portal-data.test.ts
@@ -19,7 +19,7 @@
import { SELF, env, fetchMock } from 'cloudflare:test';
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
import { workflowsVisible } from '../src/routes/portal';
-import { entryLibrary, sanitizeUploadFilename, filterDeprecatedEntries, mapGraphWorkflowOutput } from '../src/routes/portal-data';
+import { entryLibrary, sanitizeUploadFilename, filterDeprecatedEntries, mapGraphWorkflowOutput, normalizeCjkQuery, findBestNodeMatch, dedupeSourcesByPage } from '../src/routes/portal-data';
import type { Bindings } from '../src/types';
const KBDB = 'https://kbdb.test';
@@ -417,3 +417,295 @@ describe('mapGraphWorkflowOutput(#57 workflow 輸出 → plugin 形狀)', ()
expect(mapGraphWorkflowOutput('oops')).toEqual({ neighbors: [], edges: [], count: 0 });
});
});
+
+// ═══════════════ 8. t95: normalizeCjkQuery 純函式 ═══════════════
+
+describe('normalizeCjkQuery(t95 CJK/ASCII 邊界補空白)', () => {
+ it('純中文 → 不動', () => {
+ expect(normalizeCjkQuery('中文')).toBe('中文');
+ expect(normalizeCjkQuery('AI 協作')).toBe('AI 協作'); // 已有空白不重複
+ });
+ it('純 ASCII/數字 → 不動', () => {
+ expect(normalizeCjkQuery('ABC123')).toBe('ABC123');
+ expect(normalizeCjkQuery('')).toBe('');
+ });
+ it('CJK→ASCII 邊界插空白', () => {
+ expect(normalizeCjkQuery('協作AI')).toBe('協作 AI');
+ expect(normalizeCjkQuery('中文1234')).toBe('中文 1234');
+ });
+ it('ASCII→CJK 邊界插空白', () => {
+ expect(normalizeCjkQuery('AI協作')).toBe('AI 協作');
+ expect(normalizeCjkQuery('1234中文')).toBe('1234 中文');
+ });
+ it('已有空白不重複插', () => {
+ expect(normalizeCjkQuery('AI 協作規範書')).toBe('AI 協作規範書');
+ });
+ it('全形符號(非 ASCII alnum)不觸發插空白', () => {
+ expect(normalizeCjkQuery('全形:中文')).toBe('全形:中文');
+ });
+});
+
+// ═══════════════ 9. t96: findBestNodeMatch 純函式 ═══════════════
+
+describe('findBestNodeMatch(t96 fuzzy 節點比對)', () => {
+ it('空清單 → null', () => {
+ expect(findBestNodeMatch('AI 協作', [])).toBeNull();
+ });
+ it('完全不包含 → null', () => {
+ expect(findBestNodeMatch('量子運算', ['AI 協作規範書', '工作流'])).toBeNull();
+ });
+ it('精確子字串命中 → 返回', () => {
+ expect(findBestNodeMatch('AI 協作', ['AI 協作規範書'])).toBe('AI 協作規範書');
+ });
+ it('多命中 → 取最短(最精確優先)', () => {
+ const result = findBestNodeMatch('AI', ['AI 協作規範書', 'AI 知識管理', 'AI']);
+ expect(result).toBe('AI'); // 最短
+ });
+ it('CJK 未正規化的搜尋詞也能比對(normalizeCjkQuery 先處理)', () => {
+ // 搜「AI協作」→ 正規化成「AI 協作」→ 能命中「AI 協作規範書」
+ expect(findBestNodeMatch('AI協作', ['AI 協作規範書', '工作流'])).toBe('AI 協作規範書');
+ });
+ it('大小寫不敏感', () => {
+ expect(findBestNodeMatch('ai', ['AI 協作規範書'])).toBe('AI 協作規範書');
+ });
+});
+
+// ═══════════════ 10. t95: 搜尋 CJK 正規化整合測試 ═══════════════
+
+describe('GET /portal/data/search(t95 CJK 正規化)', () => {
+ it('無空白中英混搜尋詞「AI協作」→ KBDB 收到「AI 協作」', async () => {
+ await seedSession('tok-cn1', 'rec_3');
+ mockGetRecord('rec_3', userValues({ libraries: '["*"]', role: 'admin' }));
+ const cap = captureSearch();
+ await get('/portal/data/search?q=AI%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-cn1' });
+ const sent = new URLSearchParams(cap.url().split('?')[1]);
+ expect(sent.get('q')).toBe('AI 協作'); // 已補空白
+ });
+ it('已有空白的搜尋詞「AI 協作」→ KBDB 收到同樣不重複補', async () => {
+ await seedSession('tok-cn2', 'rec_3');
+ mockGetRecord('rec_3', userValues({ libraries: '["*"]', role: 'admin' }));
+ const cap = captureSearch();
+ await get('/portal/data/search?q=AI%20%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-cn2' });
+ const sent = new URLSearchParams(cap.url().split('?')[1]);
+ expect(sent.get('q')).toBe('AI 協作'); // 無重複空白
+ });
+});
+
+// ═══════════════ 11. t96: graph neighbors fuzzy fallback 整合測試 ═══════════════
+
+describe('GET /portal/data/graph/neighbors/:name(t96 fuzzy fallback)', () => {
+ it('plugin 精確命中有鄰居 → 直接回,不觸發 fallback', async () => {
+ await seedSession('tok-gf1', 'rec_a');
+ mockGetRecord('rec_a', userValues({ libraries: '["*"]', role: 'admin' }));
+ fetchMock
+ .get(GRAPH)
+ .intercept({ path: (p: string) => p.startsWith('/graph/neighbors/'), method: 'GET' })
+ .reply(200, { neighbors: [{ name: '工作流' }], edges: [{ subject: 'AI 協作規範書', predicate: '涵蓋', object: '工作流' }], count: 1 });
+ const res = await get('/portal/data/graph/neighbors/AI%20%E5%8D%94%E4%BD%9C%E8%A6%8F%E7%AF%84%E6%9B%B8', { Authorization: 'Bearer tok-gf1' });
+ expect(res.status).toBe(200);
+ const data = (await res.json()) as { neighbors: unknown[] };
+ expect(data.neighbors.length).toBe(1); // 有鄰居直接回
+ });
+
+ it('plugin 精確命中 0 鄰居 → fuzzy fallback 找到更長節點名並以它重查', async () => {
+ await seedSession('tok-gf2', 'rec_a');
+ mockGetRecord('rec_a', userValues({ libraries: '["*"]', role: 'admin' }));
+ // 精確命中「AI 協作」→ 0 鄰居
+ fetchMock
+ .get(GRAPH)
+ .intercept({ path: (p: string) => p.includes('AI%20%E5%8D%94%E4%BD%9C') && !p.includes('%E8%A6%8F%E7%AF%84'), method: 'GET' })
+ .reply(200, { neighbors: [], edges: [] });
+ // KBDB triplets → 含「AI 協作規範書」
+ fetchMock
+ .get(KBDB)
+ .intercept({ path: (p: string) => p.startsWith('/records/by-template/triplet'), method: 'GET' })
+ .reply(200, {
+ records: [
+ { values: { subject: 'AI 協作規範書', predicate: '涵蓋', object: '工作流' } },
+ { values: { subject: '工作流', predicate: '使用', object: 'Arcrun' } },
+ ],
+ });
+ // fallback 以「AI 協作規範書」重查 → 有鄰居
+ fetchMock
+ .get(GRAPH)
+ .intercept({ path: (p: string) => p.includes('AI%20%E5%8D%94%E4%BD%9C%E8%A6%8F%E7%AF%84%E6%9B%B8'), method: 'GET' })
+ .reply(200, { neighbors: [{ name: '工作流' }], edges: [{ subject: 'AI 協作規範書', predicate: '涵蓋', object: '工作流' }] });
+ const res = await get('/portal/data/graph/neighbors/AI%20%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-gf2' });
+ expect(res.status).toBe(200);
+ const data = (await res.json()) as { neighbors: unknown[] };
+ expect(data.neighbors.length).toBe(1); // fallback 帶出鄰居
+ });
+
+ it('plugin 精確命中 0 鄰居且 fuzzy 無匹配 → 誠實回 0 鄰居', async () => {
+ await seedSession('tok-gf3', 'rec_a');
+ mockGetRecord('rec_a', userValues({ libraries: '["*"]', role: 'admin' }));
+ fetchMock
+ .get(GRAPH)
+ .intercept({ path: (p: string) => p.startsWith('/graph/neighbors/'), method: 'GET' })
+ .reply(200, { neighbors: [], edges: [] });
+ // KBDB triplets → 完全沒有能比對的節點
+ fetchMock
+ .get(KBDB)
+ .intercept({ path: (p: string) => p.startsWith('/records/by-template/triplet'), method: 'GET' })
+ .reply(200, { records: [{ values: { subject: '量子運算', predicate: '屬於', object: '物理學' } }] });
+ const res = await get('/portal/data/graph/neighbors/%E6%B2%92%E6%9C%89%E9%80%99%E5%80%8B%E7%AF%80%E9%BB%9E', { Authorization: 'Bearer tok-gf3' });
+ expect(res.status).toBe(200);
+ const data = (await res.json()) as { neighbors: unknown[]; edges: unknown[] };
+ expect(data.neighbors.length).toBe(0); // 誠實回 0,不偽造
+ expect(data.edges.length).toBe(0);
+ });
+
+ it('t95+t96: 無空白「AI協作」→ 正規化成「AI 協作」→ fuzzy 命中「AI 協作規範書」', async () => {
+ await seedSession('tok-gf4', 'rec_a');
+ mockGetRecord('rec_a', userValues({ libraries: '["*"]', role: 'admin' }));
+ // plugin 收到的是正規化後的「AI 協作」(%20 分隔)
+ fetchMock
+ .get(GRAPH)
+ .intercept({ path: (p: string) => p.includes('AI%20%E5%8D%94%E4%BD%9C') && !p.includes('%E8%A6%8F%E7%AF%84'), method: 'GET' })
+ .reply(200, { neighbors: [], edges: [] });
+ fetchMock
+ .get(KBDB)
+ .intercept({ path: (p: string) => p.startsWith('/records/by-template/triplet'), method: 'GET' })
+ .reply(200, { records: [{ values: { subject: 'AI 協作規範書', predicate: '涵蓋', object: '工作流' } }] });
+ fetchMock
+ .get(GRAPH)
+ .intercept({ path: (p: string) => p.includes('AI%20%E5%8D%94%E4%BD%9C%E8%A6%8F%E7%AF%84%E6%9B%B8'), method: 'GET' })
+ .reply(200, { neighbors: [{ name: '工作流' }], edges: [{ subject: 'AI 協作規範書', predicate: '涵蓋', object: '工作流' }] });
+ // 前端傳「AI協作」(無空白,URL encoded)
+ const res = await get('/portal/data/graph/neighbors/AI%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-gf4' });
+ expect(res.status).toBe(200);
+ const data = (await res.json()) as { neighbors: unknown[] };
+ expect(data.neighbors.length).toBe(1);
+ });
+});
+
+// ═══════════════ 12. t116: graph_neighbors workflow 補傳 kbdb_base ═══════════════
+
+describe('GET /portal/data/graph/neighbors/:name(t116 kbdb_base 補傳)', () => {
+ it('tenant 有 graph_neighbors workflow → portal 傳入 kbdb_base,workflow 正常執行不崩', async () => {
+ // 設定 session(["*"] 全庫,放行 graph 粗閘)
+ await seedSession('tok-t116', 'rec_t116');
+ mockGetRecord('rec_t116', userValues({ libraries: '["*"]', role: 'admin' }));
+
+ // 在 WEBHOOKS KV 放 graph_neighbors workflow(Input→Output 直通)
+ // 這個 workflow 不用 {{input.kbdb_base}},只驗工作流路徑正常執行(不走 graphBase fallback)
+ // 若沒補傳 kbdb_base 但 workflow 內有 {{input.kbdb_base}} 的節點,URL 解析失敗 → executeWebhookGraph 回 error
+ // 此測試退而求其次:用無外部依賴的直通圖確認整個路徑都通(workflow 取代 plugin fallback)
+ const wfKey = `${TENANT}:wf:graph_neighbors`;
+ await env.WEBHOOKS.put(wfKey, JSON.stringify({
+ graph: {
+ id: 'gn-t116',
+ name: 'graph_neighbors',
+ nodes: [
+ { id: 'input', type: 'Input' },
+ // comp_passthrough 是內建零件,不需外部 fetch,直接回傳 context
+ { id: 'pass', type: 'Component', componentId: 'comp_passthrough' },
+ { id: 'output', type: 'Output' },
+ ],
+ edges: [
+ { from: 'input', to: 'pass', type: 'PIPE' },
+ { from: 'pass', to: 'output', type: 'PIPE' },
+ ],
+ },
+ description: 't116 test',
+ created_at: '2026-07-29T00:00:00.000Z',
+ }));
+
+ const res = await get('/portal/data/graph/neighbors/AI%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-t116' });
+ expect(res.status).toBe(200);
+ const data = (await res.json()) as { neighbors: unknown[]; edges: unknown[]; count: number; kbdb_base?: string };
+ // workflow 走 comp_passthrough,output = 整個 context(含 kbdb_base)
+ // mapGraphWorkflowOutput 只取 neighbors/edges,其他欄位不影響回應
+ expect(Array.isArray(data.neighbors)).toBe(true);
+ expect(Array.isArray(data.edges)).toBe(true);
+ // 確認不是 502(graph_neighbors workflow 執行失敗)
+ expect(res.status).not.toBe(502);
+
+ await env.WEBHOOKS.delete(wfKey);
+ });
+});
+
+// ═══════════════ 13. t128: graph_neighbors workflow 補傳 template ═══════════════
+
+describe('GET /portal/data/graph/neighbors/:name(t128 template 補傳)', () => {
+ it('tenant 有 graph_neighbors workflow → portal 傳入 template=triplet,workflow 不崩', async () => {
+ await seedSession('tok-t128', 'rec_t128');
+ mockGetRecord('rec_t128', userValues({ libraries: '["*"]', role: 'admin' }));
+
+ const wfKey = `${TENANT}:wf:graph_neighbors`;
+ await env.WEBHOOKS.put(wfKey, JSON.stringify({
+ graph: {
+ id: 'gn-t128',
+ name: 'graph_neighbors',
+ nodes: [
+ { id: 'input', type: 'Input' },
+ { id: 'pass', type: 'Component', componentId: 'comp_passthrough' },
+ { id: 'output', type: 'Output' },
+ ],
+ edges: [
+ { from: 'input', to: 'pass', type: 'PIPE' },
+ { from: 'pass', to: 'output', type: 'PIPE' },
+ ],
+ },
+ }));
+
+ const res = await get('/portal/data/graph/neighbors/AI%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-t128' });
+ // template 有進 context → workflow 執行不崩(非 502)
+ expect(res.status).toBe(200);
+ const data = (await res.json()) as { neighbors: unknown[]; edges: unknown[] };
+ expect(Array.isArray(data.neighbors)).toBe(true);
+
+ await env.WEBHOOKS.delete(wfKey);
+ });
+});
+
+// ═══════════════ 14. t129: dedupeSourcesByPage 純函式 ═══════════════
+
+describe('dedupeSourcesByPage(t129 出處去重)', () => {
+ it('同 page_name 合併,hit_count 標計數', () => {
+ const srcs = [
+ { page_name: '企業版功能', mode: 'semantic', source: 'gitea://docs/enterprise.md' },
+ { page_name: '企業版功能', mode: 'semantic', source: 'gitea://docs/enterprise.md' },
+ { page_name: '企業版功能', mode: 'keyword', source: 'gitea://docs/enterprise.md' },
+ ];
+ const out = dedupeSourcesByPage(srcs) as { page_name: string; hit_count?: number }[];
+ expect(out.length).toBe(1); // 3 筆→1 筆
+ expect(out[0].page_name).toBe('企業版功能');
+ expect(out[0].hit_count).toBe(3);
+ });
+
+ it('不同 page_name 各保留一筆;單筆無 hit_count', () => {
+ const srcs = [
+ { page_name: 'A 頁', mode: 'semantic' },
+ { page_name: 'B 頁', mode: 'keyword' },
+ ];
+ const out = dedupeSourcesByPage(srcs) as { page_name: string; hit_count?: number }[];
+ expect(out.length).toBe(2);
+ expect(out.every(s => s.hit_count === undefined)).toBe(true);
+ });
+
+ it('page 欄(備用)也能去重', () => {
+ const srcs = [
+ { page: '備用頁', mode: 'semantic' },
+ { page: '備用頁', mode: 'keyword' },
+ ];
+ const out = dedupeSourcesByPage(srcs) as { page?: string; hit_count?: number }[];
+ expect(out.length).toBe(1);
+ expect(out[0].hit_count).toBe(2);
+ });
+
+ it('空陣列 → 空陣列;非物件條目跳過', () => {
+ expect(dedupeSourcesByPage([])).toEqual([]);
+ const out = dedupeSourcesByPage([null, 'oops', { page_name: 'X' }]);
+ expect(out.length).toBe(1);
+ });
+
+ it('page_name 優先於 page', () => {
+ const srcs = [
+ { page_name: '優先頁', page: '備用頁' },
+ { page_name: '優先頁', page: '備用頁' },
+ ];
+ const out = dedupeSourcesByPage(srcs) as { hit_count?: number }[];
+ expect(out.length).toBe(1); // 同 page_name → 合為一筆
+ });
+});
diff --git a/kbdb/node_modules b/kbdb/node_modules
new file mode 120000
index 0000000..f6f53f2
--- /dev/null
+++ b/kbdb/node_modules
@@ -0,0 +1 @@
+/Users/youlinhsieh/Documents/tech_projects/InkStoneCo/matrix/arcrun/kbdb/node_modules
\ No newline at end of file
diff --git a/kbdb/src/actions/entry-crud.ts b/kbdb/src/actions/entry-crud.ts
index 473b6ef..d2802cb 100644
--- a/kbdb/src/actions/entry-crud.ts
+++ b/kbdb/src/actions/entry-crud.ts
@@ -126,6 +126,27 @@ export async function deleteEntry(db: D1Database, id: string): Promise {
await db.prepare('DELETE FROM entries WHERE id = ?').bind(id).run();
}
+/**
+ * 把某 owner 下某庫的所有 entries 標 deprecated(t135 by-name 移除語意)。
+ * 沿用既有 deprecated 機制:metadata_json.status='deprecated' → 搜尋端過濾、庫列表排除。
+ * 回 deprecated 的筆數(0 = 庫名不存在或早已全部 deprecated)。
+ */
+export async function deprecateEntriesByLibrary(db: D1Database, ownerId: string, library: string): Promise {
+ const result = await db
+ .prepare(
+ `UPDATE entries
+ SET metadata_json = json_set(COALESCE(metadata_json, '{}'), '$.status', 'deprecated'),
+ updated_at = unixepoch()
+ WHERE owner_id = ?
+ AND COALESCE(json_extract(metadata_json, '$.library'), 'general') = ?
+ AND (json_extract(metadata_json, '$.status') IS NULL
+ OR json_extract(metadata_json, '$.status') != 'deprecated')`,
+ )
+ .bind(ownerId, library)
+ .run();
+ return (result.meta?.changes as number | undefined) ?? 0;
+}
+
// 「庫」filter 的 SQL 謂詞(portal-auth P1,design §3.2/§3.3;零建表,同 #5.1 source 的 json_extract 先例)。
// COALESCE(x,'general') IN (…) ≡ SDD §3.3 寫的 (x IN (…) OR (x IS NULL AND 'general' IN (…)))——
// 語意完全相同(未標記/無 metadata_json 的舊資料歸 'general'),但單組佔位符、不用重複綁參數。
diff --git a/kbdb/src/actions/record-crud.ts b/kbdb/src/actions/record-crud.ts
index 68f9cd3..83e181c 100644
--- a/kbdb/src/actions/record-crud.ts
+++ b/kbdb/src/actions/record-crud.ts
@@ -209,3 +209,18 @@ export async function searchByTemplate(db: D1Database, template: string, owner_i
}
return ids.map((id) => byId.get(id)).filter((r): r is RecordResult => !!r);
}
+
+/** 刪除一筆 record:先刪 entry_values(FK),再刪底層 entries。回 false 表示 record 不存在。 */
+export async function deleteRecord(db: D1Database, recordId: string): Promise {
+ const evRes = await db
+ .prepare('SELECT entry_id FROM entry_values WHERE record_id = ?')
+ .bind(recordId)
+ .all<{ entry_id: string }>();
+ const rows = evRes.results ?? [];
+ if (rows.length === 0) return false;
+ await db.prepare('DELETE FROM entry_values WHERE record_id = ?').bind(recordId).run();
+ for (const { entry_id } of rows) {
+ await db.prepare('DELETE FROM entries WHERE id = ?').bind(entry_id).run();
+ }
+ return true;
+}
diff --git a/kbdb/src/index.ts b/kbdb/src/index.ts
index a9ad210..de68e99 100644
--- a/kbdb/src/index.ts
+++ b/kbdb/src/index.ts
@@ -14,6 +14,27 @@ import { mapRoutes } from './routes/map';
const app = new Hono<{ Bindings: Bindings }>();
+// t115 global auth guard(三修=總管手改,fail-closed 到底).
+// 為什麼不留讀取的寬容窗口:leo 07-28 實證的洞就是「知道網址即可讀走全部知識」——
+// 讀取放行等於洞沒補。老實例的升級路徑是「重跑安裝器」(會同時注入 token 與新 workflow),
+// 那條路本來就存在(t103 連動提示會叫用戶更新),不需要以繼續外洩為代價換相容。
+// Health(/ 與 /health)永遠豁免:daemon 的雲端版本偵測與監控要打得到。
+app.use('*', async (c, next) => {
+ const path = new URL(c.req.url).pathname;
+ if (path === '/' || path === '/health') return next();
+ const token = c.env.KBDB_INTERNAL_TOKEN;
+ if (!token) {
+ // 沒有 token=這個實例還沒封口。一律拒絕(含讀取),並在訊息裡告訴維運怎麼修。
+ console.warn('[kbdb] KBDB_INTERNAL_TOKEN 未設定——全部請求拒絕,請重跑安裝器以注入金鑰');
+ return c.json({ error: 'Unauthorized', detail: 'kbdb 尚未設定內部金鑰,請重跑安裝器' }, 401);
+ }
+ const auth = c.req.header('Authorization');
+ if (!auth || auth !== `Bearer ${token}`) {
+ return c.json({ error: 'Unauthorized' }, 401);
+ }
+ return next();
+});
+
app.get('/', (c) => c.json({ service: 'arcrun-kbdb', tier: 'base', status: 'ok' }));
app.get('/health', (c) => c.json({ ok: true }));
diff --git a/kbdb/src/routes/entries.ts b/kbdb/src/routes/entries.ts
index 57a3661..4315d65 100644
--- a/kbdb/src/routes/entries.ts
+++ b/kbdb/src/routes/entries.ts
@@ -3,6 +3,7 @@ import { Hono } from 'hono';
import type { Bindings } from '../types';
import {
createEntry,
+ deprecateEntriesByLibrary,
getEntry,
listEntries,
updateEntry,
@@ -32,6 +33,49 @@ entryRoutes.post('/', async (c) => {
return c.json({ success: true, entry });
});
+// GET /entries/libraries?owner_id=... — 這個租戶的資料裡實際出現過哪些庫(distinct)。
+// t52(leo 2026-07-26:地端幾個資料夾=雲端幾個庫):庫由 ingest 蓋章決定,這裡直接從
+// 資料反查,讓「蓋了章的庫」一定看得到,不必依賴任何登記動作。未蓋章的舊資料=general。
+// 註冊在 '/' 之前——Hono 路由先到先比,放後面會被 '/:id' 之類的樣式吃掉。
+entryRoutes.get('/libraries', async (c) => {
+ const owner = c.req.query('owner_id') || '';
+ const rows = await c.env.DB.prepare(
+ `SELECT DISTINCT COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general') AS library
+ FROM entries
+ WHERE (?1 = '' OR owner_id = ?1)
+ AND COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated'
+ ORDER BY library`,
+ )
+ .bind(owner)
+ .all<{ library: string }>();
+ const libraries = (rows.results ?? []).map((r) => r.library).filter(Boolean);
+ return c.json({ success: true, libraries, count: libraries.length });
+});
+
+// GET /entries/library-stats?owner_id=... — 每個庫的知識卡數(distinct page_name,非 block 數)。
+// t142(2026-07-29):政府驗收用——一眼看出每個庫有幾張卡(page 粒度,不是 block 粒度,
+// 一張卡通常對應 3-5 個 block;不含 deprecated entries)。
+// 只計 entry_type='block' 的條目,因為 block 才對應知識卡的一個段落(page_name 標記所屬頁面)。
+entryRoutes.get('/library-stats', async (c) => {
+ const owner = c.req.query('owner_id') || '';
+ const rows = await c.env.DB.prepare(
+ `SELECT
+ COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general') AS library,
+ COUNT(DISTINCT page_name) AS card_count
+ FROM entries
+ WHERE (?1 = '' OR owner_id = ?1)
+ AND entry_type = 'block'
+ AND page_name IS NOT NULL
+ AND COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated'
+ GROUP BY library
+ ORDER BY library`,
+ )
+ .bind(owner)
+ .all<{ library: string; card_count: number }>();
+ const stats = (rows.results ?? []).map((r) => ({ library: r.library, card_count: r.card_count }));
+ return c.json({ success: true, stats });
+});
+
// GET /entries — list with filters (entry_type, owner_id, parent_id, page_name, source, q/search)
// e.g. list workflows under a project: ?parent_id=PROJECT&entry_type=workflow
// e.g. get one by idempotency key: ?page_name=skill-rag_with_arcrun
@@ -142,6 +186,18 @@ entryRoutes.get('/:id', async (c) => {
return c.json({ success: true, entry });
});
+// PATCH /entries/deprecate-by-library — body {owner_id, library}。
+// t135:把某租戶某庫的所有 entries 標 deprecated,讓庫從 auto 清單消失。
+// 此路由必須在 '/:id' 之前,否則 'deprecate-by-library' 會被當成 id 參數。
+entryRoutes.patch('/deprecate-by-library', async (c) => {
+ const body = (await c.req.json().catch(() => null)) as { owner_id?: string; library?: string } | null;
+ const ownerId = String(body?.owner_id ?? '').trim();
+ const library = String(body?.library ?? '').trim();
+ if (!ownerId || !library) return c.json({ success: false, error: 'owner_id 與 library 必填' }, 400);
+ const count = await deprecateEntriesByLibrary(c.env.DB, ownerId, library);
+ return c.json({ success: true, deprecated_count: count });
+});
+
// PATCH /entries/:id
entryRoutes.patch('/:id', async (c) => {
const body = await c.req.json().catch(() => ({}));
diff --git a/kbdb/src/routes/records.ts b/kbdb/src/routes/records.ts
index 7a2d891..1758200 100644
--- a/kbdb/src/routes/records.ts
+++ b/kbdb/src/routes/records.ts
@@ -1,7 +1,7 @@
// Records route — structured records (entry_values composed by a template).
import { Hono } from 'hono';
import type { Bindings } from '../types';
-import { createRecord, getRecord, searchByTemplate, updateRecord } from '../actions/record-crud';
+import { createRecord, deleteRecord, getRecord, searchByTemplate, updateRecord } from '../actions/record-crud';
export const recordRoutes = new Hono<{ Bindings: Bindings }>();
@@ -19,6 +19,38 @@ recordRoutes.post('/', async (c) => {
}
});
+// GET /records/triplet-stats?owner_id=... — 每個庫的三元組(關聯)數。
+// t142(2026-07-29):政府驗收——顯示每個庫整理出幾條知識關聯。
+// 計法:依 triplet 型 record 的 'library' slot 值分組計數。無 library slot 的舊三元組歸 general。
+// 使用子查詢先取 distinct triplet record IDs(針對 owner),再 LEFT JOIN library slot,
+// 避免 N+1(全部一次 SQL 完成,不逐筆 getRecord)。
+recordRoutes.get('/triplet-stats', async (c) => {
+ const owner = c.req.query('owner_id') || '';
+ // 子查詢:找到屬於這個 owner 的所有 triplet records;LEFT JOIN library slot 取庫名
+ const rows = await c.env.DB.prepare(
+ `SELECT
+ COALESCE(NULLIF(lib_e.content, ''), 'general') AS library,
+ COUNT(*) AS triplet_count
+ FROM (
+ SELECT DISTINCT ev.record_id
+ FROM entry_values ev
+ JOIN templates t ON ev.template_id = t.id
+ JOIN entries e ON ev.entry_id = e.id
+ WHERE t.name = 'triplet'
+ AND (?1 = '' OR e.owner_id = ?1)
+ ) AS tr
+ LEFT JOIN entry_values lev
+ ON lev.record_id = tr.record_id AND lev.slot_name = 'library'
+ LEFT JOIN entries lib_e ON lib_e.id = lev.entry_id
+ GROUP BY COALESCE(NULLIF(lib_e.content, ''), 'general')
+ ORDER BY library`,
+ )
+ .bind(owner)
+ .all<{ library: string; triplet_count: number }>();
+ const stats = (rows.results ?? []).map((r) => ({ library: r.library, triplet_count: r.triplet_count }));
+ return c.json({ success: true, stats });
+});
+
// GET /records/by-template/:template — list records of a template
recordRoutes.get('/by-template/:template', async (c) => {
const records = await searchByTemplate(c.env.DB, c.req.param('template'), c.req.query('owner_id') || undefined);
@@ -47,3 +79,10 @@ recordRoutes.patch('/:recordId', async (c) => {
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 400);
}
});
+
+// DELETE /records/:recordId — 刪除一筆 record 及其底層 entries。
+recordRoutes.delete('/:recordId', async (c) => {
+ const found = await deleteRecord(c.env.DB, c.req.param('recordId'));
+ if (!found) return c.json({ success: false, error: 'not found' }, 404);
+ return c.json({ success: true });
+});
diff --git a/kbdb/src/types.ts b/kbdb/src/types.ts
index 36c87de..c80c55b 100644
--- a/kbdb/src/types.ts
+++ b/kbdb/src/types.ts
@@ -4,6 +4,13 @@
export type Bindings = {
DB: D1Database;
ENVIRONMENT: string;
+ // Auth guard (t115 二修, fail-closed): provisioned by the installer automatically.
+ // NOT set → writes (POST/PATCH/DELETE/PUT) rejected 401; reads pass with a warning
+ // (upgrade-window grace so read-only workflows don't break before both workers are
+ // updated together).
+ // SET → all non-health routes require `Authorization: Bearer `.
+ // cypher-executor sends this via kbdbBase(); portal/webhooks/recipes send it inline.
+ KBDB_INTERNAL_TOKEN?: string;
// Optional embed module (issue #7 / SDD T2.4). Present ONLY when the self-host opened
// semantic search (kbdb_embed:true → deploy injects [[vectorize]] + [ai]). Base never
// requires them; code checks `if (env.VECTORIZE && env.AI)` before touching embed.
diff --git a/kbdb/tests/auth.test.ts b/kbdb/tests/auth.test.ts
new file mode 100644
index 0000000..c5727ab
--- /dev/null
+++ b/kbdb/tests/auth.test.ts
@@ -0,0 +1,160 @@
+// t115 二修 — kbdb auth guard tests (fail-closed behaviour).
+//
+// Token NOT set:
+// - GET / and GET /health → 200 (health exempt)
+// - GET /entries → 200 + console.warn (reads pass during upgrade window)
+// - POST/PATCH/DELETE /entries → 401 (fail-closed for writes)
+//
+// Token SET:
+// - / and /health → 200 (health always exempt)
+// - missing / wrong / no-Bearer prefix → 401
+// - correct Bearer → 200
+import { describe, it, expect } from 'vitest';
+import { Hono } from 'hono';
+import type { Bindings } from '../src/types';
+
+// ⚠️ 這裡曾經「複製一份 index.ts 的 middleware」來測——複本會與真實作漂移,
+// 測綠了也不代表線上安全(總管 07-28 三修時發現:真 app 已改 fail-closed,複本還放行讀取)。
+// 現在改成:把真 middleware 從 src/index.ts 匯入無法做到(app 已組裝好路由),
+// 故改為「複本必須與 src/index.ts 的行為斷言一致」+一條結構測試(見最下方 test)。
+function makeApp(token?: string) {
+ const app = new Hono<{ Bindings: Bindings }>();
+
+ app.use('*', async (c, next) => {
+ const path = new URL(c.req.url).pathname;
+ if (path === '/' || path === '/health') return next();
+ const envToken = c.env.KBDB_INTERNAL_TOKEN;
+ if (!envToken) return c.json({ error: 'Unauthorized', detail: 'kbdb 尚未設定內部金鑰,請重跑安裝器' }, 401);
+ const auth = c.req.header('Authorization');
+ if (!auth || auth !== `Bearer ${envToken}`) return c.json({ error: 'Unauthorized' }, 401);
+ return next();
+ });
+
+ app.get('/', (c) => c.json({ status: 'ok' }));
+ app.get('/health', (c) => c.json({ ok: true }));
+ app.get('/entries', (c) => c.json({ success: true, entries: [] }));
+ app.post('/entries', async (c) => c.json({ success: true }));
+ app.patch('/entries/:id', async (c) => c.json({ success: true }));
+ app.delete('/entries/:id', async (c) => c.json({ success: true }));
+
+ // Bind the token into the env for every request.
+ const original = app.fetch.bind(app);
+ return (req: Request) =>
+ original(req, { DB: {} as D1Database, ENVIRONMENT: 'test', KBDB_INTERNAL_TOKEN: token } as Bindings, {});
+}
+
+describe('kbdb auth guard — token NOT set', () => {
+ const fetch = makeApp(undefined);
+
+ it('GET / passes (health exempt)', async () => {
+ const res = await fetch(new Request('http://kbdb/'));
+ expect(res.status).toBe(200);
+ });
+
+ it('GET /health passes (health exempt)', async () => {
+ const res = await fetch(new Request('http://kbdb/health'));
+ expect(res.status).toBe(200);
+ });
+
+ it('GET /entries 也被拒(fail-closed:讀取放行=洞沒補,t115 三修)', async () => {
+ const res = await fetch(new Request('http://kbdb/entries'));
+ expect(res.status).toBe(401);
+ });
+
+ it('POST /entries without token → 401 (fail-closed for writes)', async () => {
+ const res = await fetch(new Request('http://kbdb/entries', { method: 'POST' }));
+ expect(res.status).toBe(401);
+ const body = await res.json() as { error: string };
+ expect(body.error).toBe('Unauthorized');
+ });
+
+ it('PATCH /entries/x without token → 401 (fail-closed for writes)', async () => {
+ const res = await fetch(new Request('http://kbdb/entries/x', { method: 'PATCH' }));
+ expect(res.status).toBe(401);
+ });
+
+ it('DELETE /entries/x without token → 401 (fail-closed for writes)', async () => {
+ const res = await fetch(new Request('http://kbdb/entries/x', { method: 'DELETE' }));
+ expect(res.status).toBe(401);
+ });
+});
+
+describe('kbdb auth guard — token SET', () => {
+ const SECRET = 'test-secret-abc123';
+ const fetch = makeApp(SECRET);
+
+ it('GET / always passes (health exempt)', async () => {
+ const res = await fetch(new Request('http://kbdb/'));
+ expect(res.status).toBe(200);
+ });
+
+ it('GET /health always passes (health exempt)', async () => {
+ const res = await fetch(new Request('http://kbdb/health'));
+ expect(res.status).toBe(200);
+ });
+
+ it('GET /entries without Authorization → 401', async () => {
+ const res = await fetch(new Request('http://kbdb/entries'));
+ expect(res.status).toBe(401);
+ const body = await res.json() as { error: string };
+ expect(body.error).toBe('Unauthorized');
+ });
+
+ it('GET /entries with wrong token → 401', async () => {
+ const res = await fetch(
+ new Request('http://kbdb/entries', {
+ headers: { Authorization: 'Bearer wrong-token' },
+ }),
+ );
+ expect(res.status).toBe(401);
+ });
+
+ it('GET /entries with Bearer prefix missing → 401', async () => {
+ const res = await fetch(
+ new Request('http://kbdb/entries', {
+ headers: { Authorization: SECRET },
+ }),
+ );
+ expect(res.status).toBe(401);
+ });
+
+ it('GET /entries with correct Bearer token → 200', async () => {
+ const res = await fetch(
+ new Request('http://kbdb/entries', {
+ headers: { Authorization: `Bearer ${SECRET}` },
+ }),
+ );
+ expect(res.status).toBe(200);
+ });
+
+ it('POST /entries with correct Bearer token → 200', async () => {
+ const res = await fetch(
+ new Request('http://kbdb/entries', {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${SECRET}` },
+ }),
+ );
+ expect(res.status).toBe(200);
+ });
+
+ it('POST /entries without token → 401', async () => {
+ const res = await fetch(
+ new Request('http://kbdb/entries', { method: 'POST' }),
+ );
+ expect(res.status).toBe(401);
+ });
+});
+
+// 結構閘(總管 07-28 加):src/index.ts 的 guard 必須是 fail-closed——
+// 無 token 時不得有任何「return next()」的放行分支(health 豁免除外)。
+// 這條擋的是「測試複本與真實作漂移」那類假綠。
+import { readFileSync } from 'node:fs';
+describe('t115 結構閘:真實作必須 fail-closed', () => {
+ it('src/index.ts 無 token 分支不放行', () => {
+ const src = readFileSync(new URL('../src/index.ts', import.meta.url), 'utf8');
+ const guard = src.slice(src.indexOf("app.use('*'"), src.indexOf("app.get('/', "));
+ const noTokenBlock = guard.slice(guard.indexOf('if (!token)'), guard.indexOf('const auth'));
+ expect(noTokenBlock).toContain('401');
+ expect(noTokenBlock).not.toContain('return next()');
+ });
+});
diff --git a/kbdb/wrangler.toml b/kbdb/wrangler.toml
index b92f840..0e413be 100644
--- a/kbdb/wrangler.toml
+++ b/kbdb/wrangler.toml
@@ -15,6 +15,20 @@ database_id = "0c580910-e00b-4f8e-9c57-ac54ea52242f" # 官方 prod D1(arcrun-
[vars]
ENVIRONMENT = "production"
+# ── Auth guard (t115 二修, fail-closed) ────────────────────────────────────────
+# The installer generates a random token at deploy time and secrets it into BOTH workers:
+# wrangler secret put KBDB_INTERNAL_TOKEN (arcrun-kbdb)
+# wrangler secret put KBDB_INTERNAL_TOKEN (arcrun-cypher-executor)
+# cypher sends the token as `Authorization: Bearer ` via kbdbBase().
+# Workflow http_request nodes that hit KBDB directly must include
+# `Authorization: Bearer __KBDB_TOKEN__` (installer substitutes the value).
+#
+# Secret NOT set → writes (POST/PATCH/DELETE) are rejected 401 immediately (fail-closed).
+# Reads (GET) pass with a server-side warning — old instances survive the upgrade
+# window until both workers receive the secret at the same time.
+# Secret SET → all non-health routes require correct Bearer; / and /health exempt.
+# ──────────────────────────────────────────────────────────────────────────────
+
# ── Optional embed module (issue #7 / SDD T2.4) ────────────────────────────────
# Base 預設不開(free-tier 友善)。self-host 開語義查詢時,deploy.ts 偵測 config kbdb_embed:true
# → 取消下面兩段註解(注入 active binding)並 `wrangler vectorize create arcrun-kbdb-embed
diff --git a/registry/components/auth_oauth2/component.contract.yaml b/registry/components/auth_oauth2/component.contract.yaml
index d0b726a..0723647 100644
--- a/registry/components/auth_oauth2/component.contract.yaml
+++ b/registry/components/auth_oauth2/component.contract.yaml
@@ -27,7 +27,11 @@ input_schema:
refresh — 強制重新 refresh,更新 CREDENTIALS_KV 中的 access_token/expires_at
api_key:
type: string
- description: 租戶識別(ak_ 前綴),用來組 {api_key}:cred:{name} KV key
+ description: >-
+ 租戶識別=Arcrun namespace,用來組 {api_key}:cred:{name} KV key。
+ ⚠️ 2026-07-29 更正:舊敘述寫「ak_ 前綴」,但現行沒有發 API key 的機制
+ (leo 07-29 指正)——namespace 即身分即憑證。下方 examples 的 ak_test/ak_nonexistent
+ 是測試用假值,不代表真實格式。
service:
type: string
description: auth recipe 名稱,對應 auth_recipe:{service} 的 KV 記錄
diff --git a/registry/components/auth_service_account/component.contract.yaml b/registry/components/auth_service_account/component.contract.yaml
index b778ed2..92145eb 100644
--- a/registry/components/auth_service_account/component.contract.yaml
+++ b/registry/components/auth_service_account/component.contract.yaml
@@ -24,7 +24,11 @@ input_schema:
description: 目前僅支援 authenticate
api_key:
type: string
- description: 租戶識別(ak_ 前綴),用來組 {api_key}:cred:{name} KV key
+ description: >-
+ 租戶識別=Arcrun namespace,用來組 {api_key}:cred:{name} KV key。
+ ⚠️ 2026-07-29 更正:舊敘述寫「ak_ 前綴」,但現行沒有發 API key 的機制
+ (leo 07-29 指正)——namespace 即身分即憑證。下方 examples 的 ak_test/ak_nonexistent
+ 是測試用假值,不代表真實格式。
service:
type: string
description: auth recipe 名稱,對應 auth_recipe:{service} 的 KV 記錄
diff --git a/registry/components/auth_static_key/component.contract.yaml b/registry/components/auth_static_key/component.contract.yaml
index de2a028..a23ec2c 100644
--- a/registry/components/auth_static_key/component.contract.yaml
+++ b/registry/components/auth_static_key/component.contract.yaml
@@ -24,7 +24,11 @@ input_schema:
description: 目前僅支援 authenticate;static_key 無 refresh 概念
api_key:
type: string
- description: 租戶識別(ak_ 前綴),用來組 {api_key}:cred:{name} KV key
+ description: >-
+ 租戶識別=Arcrun namespace,用來組 {api_key}:cred:{name} KV key。
+ ⚠️ 2026-07-29 更正:舊敘述寫「ak_ 前綴」,但現行沒有發 API key 的機制
+ (leo 07-29 指正)——namespace 即身分即憑證。下方 examples 的 ak_test/ak_nonexistent
+ 是測試用假值,不代表真實格式。
service:
type: string
description: auth recipe 名稱,對應 auth_recipe:{service} 的 KV 記錄
diff --git a/registry/examples/cron-watcher/workflow.yaml b/registry/examples/cron-watcher/workflow.yaml
index c2fcadd..25ce9b0 100644
--- a/registry/examples/cron-watcher/workflow.yaml
+++ b/registry/examples/cron-watcher/workflow.yaml
@@ -14,7 +14,7 @@ config:
list_unprocessed:
component: kbdb_get
- api_key: "{{api_key}}"
+ api_key: "{{credential.arcrun_namespace}}"
type: "note"
source: "user-input"
limit: 20
@@ -32,7 +32,7 @@ config:
trigger_processor:
component: trigger_workflow
workflow_name: "your_processor_workflow" # ← 改成你的處理 workflow 名
- api_key: "{{api_key}}"
+ api_key: "{{credential.arcrun_namespace}}"
input:
- api_key: "{{api_key}}"
+ api_key: "{{credential.arcrun_namespace}}"
block_id: "{{item.id}}"
diff --git a/registry/examples/daily-digest/workflow.yaml b/registry/examples/daily-digest/workflow.yaml
index 1a69f91..504bcf2 100644
--- a/registry/examples/daily-digest/workflow.yaml
+++ b/registry/examples/daily-digest/workflow.yaml
@@ -17,7 +17,7 @@ config:
fetch_kbdb_yesterday:
component: kbdb_get
- api_key: "{{api_key}}"
+ api_key: "{{credential.arcrun_namespace}}"
type: "note"
source: "km-writer-direct"
limit: 50
diff --git a/registry/examples/github-issue-bot/workflow.yaml b/registry/examples/github-issue-bot/workflow.yaml
index 4a93567..9ac0231 100644
--- a/registry/examples/github-issue-bot/workflow.yaml
+++ b/registry/examples/github-issue-bot/workflow.yaml
@@ -33,7 +33,7 @@ config:
url: "https://api.github.com/repos/{{input.repository.full_name}}/issues/{{input.issue.number}}/comments"
method: POST
headers:
- Authorization: "Bearer {{secret.GITHUB_BOT_TOKEN}}"
+ Authorization: "Bearer {{credential.github_bot_token}}"
Accept: "application/vnd.github+json"
body_json:
body: "{{analyze.first_response}}"
@@ -43,7 +43,7 @@ config:
url: "https://api.github.com/repos/{{input.repository.full_name}}/issues/{{input.issue.number}}/labels"
method: POST
headers:
- Authorization: "Bearer {{secret.GITHUB_BOT_TOKEN}}"
+ Authorization: "Bearer {{credential.github_bot_token}}"
body_json:
labels:
- "auto-triaged"
diff --git a/registry/examples/llm-classify/workflow.yaml b/registry/examples/llm-classify/workflow.yaml
index 7825c6e..939873f 100644
--- a/registry/examples/llm-classify/workflow.yaml
+++ b/registry/examples/llm-classify/workflow.yaml
@@ -24,7 +24,7 @@ config:
save_with_tag:
component: kbdb_create_block
- api_key: "{{api_key}}"
+ api_key: "{{credential.arcrun_namespace}}"
type: "note"
source: "llm-classified"
user_id: "ai_classifier"
diff --git a/registry/examples/parallel-fanout/workflow.yaml b/registry/examples/parallel-fanout/workflow.yaml
index 369fc9a..e548917 100644
--- a/registry/examples/parallel-fanout/workflow.yaml
+++ b/registry/examples/parallel-fanout/workflow.yaml
@@ -12,24 +12,24 @@ config:
dispatch_to_summary:
component: trigger_workflow
workflow_name: "llm_classify_example" # 改成你的 summary workflow
- api_key: "{{api_key}}"
+ api_key: "{{credential.arcrun_namespace}}"
input:
- api_key: "{{api_key}}"
+ api_key: "{{credential.arcrun_namespace}}"
text: "{{input.text}}"
dispatch_to_translate:
component: trigger_workflow
workflow_name: "your_translate_workflow"
- api_key: "{{api_key}}"
+ api_key: "{{credential.arcrun_namespace}}"
input:
- api_key: "{{api_key}}"
+ api_key: "{{credential.arcrun_namespace}}"
text: "{{input.text}}"
target_lang: "{{input.target_lang}}"
dispatch_to_classify:
component: trigger_workflow
workflow_name: "llm_classify_example"
- api_key: "{{api_key}}"
+ api_key: "{{credential.arcrun_namespace}}"
input:
- api_key: "{{api_key}}"
+ api_key: "{{credential.arcrun_namespace}}"
text: "{{input.text}}"
diff --git a/registry/examples/pdf-to-blocks/workflow.yaml b/registry/examples/pdf-to-blocks/workflow.yaml
index 93036da..46779a4 100644
--- a/registry/examples/pdf-to-blocks/workflow.yaml
+++ b/registry/examples/pdf-to-blocks/workflow.yaml
@@ -18,7 +18,7 @@ config:
# source 用 file_url 當去重 key(同 PDF 重 ingest 不會重複建)
ingest_to_kbdb:
component: kbdb_ingest
- api_key: "{{api_key}}"
+ api_key: "{{credential.arcrun_namespace}}"
page_name: "pdf-{{input.title}}"
text: "{{convert_pdf.data.text}}"
source: "pdf:{{input.pdf_url}}"
diff --git a/registry/examples/rag-search-answer/workflow.yaml b/registry/examples/rag-search-answer/workflow.yaml
index 6e6425e..3f6b339 100644
--- a/registry/examples/rag-search-answer/workflow.yaml
+++ b/registry/examples/rag-search-answer/workflow.yaml
@@ -8,7 +8,7 @@ flow:
config:
search_kbdb:
component: kbdb_search
- api_key: "{{api_key}}"
+ api_key: "{{credential.arcrun_namespace}}"
query: "{{input.question}}"
topK: 5
user_id: "{{input.user_id}}" # 可選,限定某用戶 namespace
diff --git a/system-dev/docs/3-specs/portal-auth/tasks.md b/system-dev/docs/3-specs/portal-auth/tasks.md
index ad4a0cd..8a68536 100644
--- a/system-dev/docs/3-specs/portal-auth/tasks.md
+++ b/system-dev/docs/3-specs/portal-auth/tasks.md
@@ -161,6 +161,170 @@
頁尾連 00-MAP.md=#39「人機共用同一份地圖」的文字版)。
③ #39 本體(library_map Template/ingest 重算/MCP instructions+get_map)不在本次範圍,仍歸 #39 SDD。
+- [x] **t95 搜尋 CJK 正規化(2026-07-28,任務層小改)**:
+ 來源=leo 實測 geek6688 實例「搜『AI協作』(無空白)0 結果,搜『AI 協作』有結果,中文習慣不是每個人都會加空白」。
+ 修:`portal-data.ts` 新增 `normalizeCjkQuery()`(CJK/ASCII 邊界自動插空白,純函式可 export 單測);
+ `/portal/data/search` 路由的 `q` 參數先過正規化再查 KBDB;只動查詢端不動索引端。
+ 驗證:pure function 6 案(純 CJK/ASCII 不動、邊界插空白、已有空白不重複、全形符號不觸發)+
+ integration 2 案(`AI%E5%8D%94%E4%BD%9C` → KBDB 收到 `AI 協作`)。
+
+- [x] **t96 圖譜節點模糊比對 fuzzy fallback(2026-07-28,任務層小改)**:
+ 來源=leo 實測「graph 模式搜『AI 協作』回鄰居 0 關聯 0,但總圖上明明有節點『AI 協作規範書』—精確比對太嚴」。
+ 修:`portal-data.ts` 新增 `findBestNodeMatch()`(contains 比對+最短名優先,純函式)+
+ `fuzzyFindNode()`(查 KBDB triplet records 找最佳節點名);
+ graph neighbors 路由 plugin fallback 路徑(②):精確命中 0 鄰居+0 邊 → 以 fuzzyFindNode 找最佳節點名重查;
+ 工作流路徑(①)套 CJK 正規化但不加 fuzzy fallback(workflow 自管節點解析)。
+ B5 分支(work/b5-graph-library-filter-0726)只動同一行的 `libraries` 欄位,衝突面最小。
+ 驗證:findBestNodeMatch 6 案(空清單/無命中/精確子字串/多命中取最短/CJK 未正規化/大小寫)+
+ integration 4 案(有鄰居直接回、0 鄰居 fuzzy 命中、0 鄰居 fuzzy 無命中誠實回 0、t95+t96 連動)。
+
+- [x] **t97 庫目錄只顯示用戶同步進來的(2026-07-28,任務層小改)**:
+ 來源=leo 裁定「用戶沒加上的庫,不要自作主張給它加上」。
+ t97a:bootstrap 後不再預埋 `kb` 庫(index.html firstsetup 移除 POST /portal/admin/libraries 種子 call)。
+ t97b:GET /portal/admin/libraries auto 段新增 `n === 'general'` 過濾——general 是系統「未標庫」
+ fallback 桶,不是用戶加的,不在目錄露臉;資料照舊、B5 權限語意不動。
+ 驗證:portal-admin.test.ts 新增「auto 過濾 general」1 案(mock KBDB /entries/libraries → ['kb','general','notes'],
+ 回應 names 含 kb/notes,不含 general)。
+
+- [x] **t114 拿掉「登記到目錄」兩段式(2026-07-28,任務層小改)**:
+ 來源=leo 裁定「掃進來的就是要進目錄…加入目錄這件小事還要分兩段做?是在攻打用戶嗎」。
+ 修:`renderAdminLibs()` 拿掉 auto/已登記視覺分岔,auto 庫直接以完整卡片顯示(保留「同步自動出現」tag,
+ 去掉「登記到目錄後可以改顯示名」描述文字);移除「登記到目錄」按鈕與 lib-adopt event handler;
+ 空狀態文字改「同步小幫手還沒送來任何庫…庫會自動出現在這裡」。
+ 顯示名:此輪 auto 庫唯讀(與已登記庫現行行為一致,實作成本最低;後續若需可補 auto-adopt on PATCH)。
+ 搜尋不依賴登記(搜尋走 /portal/data/search → server 注入 library filter,與庫目錄登記簿無關)。
+ 驗證:HTML 殼測試補「無 lib-adopt、無登記到目錄」斷言;既有 HTML 斷言(零租戶字串等)不回退。
+
+- [x] **t115 kbdb 全域認證中介層(2026-07-28,安全洞熱修;二修 2026-07-28)**:
+ 實證:不帶任何憑證直打 `arcrun-kbdb..workers.dev/entries` → 200 回真實知識;POST 直寫
+ 成功。B5 權限做在 cypher/portal 層,繞過 portal 直打 kbdb 全破,隱私賣點(原文不出機)形同虛設。
+ 一修(已在樹上)兩個缺陷:① `if (env.KBDB_INTERNAL_TOKEN)` 才擋 = 沒設 secret 洞照開(fail-open);
+ ② workflows/rag-ingest-card.local.yaml(arcrun-rag repo)有 3 處 `__KBDB_BASE__`
+ (post_block/post_triplet 走 http_request 直打 kbdb),rag-chat/graph-neighbors/takedown 同樣;
+ token 生效後收卡與查詢全 401。
+ 二修(本次):
+ ① `kbdb/src/index.ts` middleware 改 fail-closed:
+ - 未設 token → POST/PATCH/DELETE/PUT 直接 401(fail-closed for writes);
+ GET 記 console.warn 後放行(讀取升級窗口,老實例不整個炸)。
+ - 已設 token → 非 health 路由全部要求 Bearer(行為同一修)。
+ ② `kbdb/tests/auth.test.ts` 改 12 項(含 PATCH/DELETE 無 token→401;原「無 token POST 過」改為 401)。
+ ③ `kbdb/src/types.ts`+`kbdb/wrangler.toml` 說明改為 fail-closed 語意。
+ ④ workflow http_request 節點(arcrun-rag 側):規格見本項末「給安裝器的規格」段,由總管派 arcrun-rag。
+ cypher 不需改(五處已有 `if (KBDB_INTERNAL_TOKEN) headers[Authorization]=Bearer`)。
+ 老實例升級路徑:安裝器部署時自動生成同一把 token → `wrangler secret put KBDB_INTERNAL_TOKEN` 注入
+ kbdb 與 cypher 兩個 worker;workflow yaml 同批替換 `__KBDB_TOKEN__`(見規格)即封口。
+ kbdb_upsert_block WASM 指向死路由 `/blocks`(設 token 前後都壞,不新增破壞)。
+
+- [x] **t116 圖搜尋靜默失敗——portal 補傳 kbdb_base(2026-07-29,任務層小改)**:
+ 診斷:graph_neighbors workflow 的 fetch_triplets url 含 `{{input.kbdb_base}}/records/...`,
+ 但 portal 呼叫 executeWebhookGraph 時未傳 kbdb_base → URL 解析失敗 → `HTTP request failed`。
+ 修法(b 案 雙保險):portal-data.ts 補傳 `kbdb_base: env.KBDB_BASE_URL ?? ''`;
+ yaml 端(`__KBDB_BASE__` 安裝期替換)由總管派 arcrun-rag,詳見本項末規格段。
+ 執行範圍:`cypher-executor/src/routes/portal-data.ts`(補傳 kbdb_base)。
+
+- [x] **t117 三元組寫入靜默失敗——FOREACH 全失敗浮出水面(2026-07-29,任務層小改)**:
+ 診斷:youlin 實例 triplet=0,懷疑 post_triplet http_request 401 被靜默吞掉;
+ FOREACH 收集到全 `success:false` 的 iterResults 後回 `{success:true, data:{results:[...]}}` —— 呼叫端不知失敗。
+ 兩層修法:
+ ① wasi-shim.ts http_request catch:改寫錯誤 envelope(含 fetch 原始 message)到 WASM 輸出,
+ 取代只 return 1(WASM 寫 "HTTP request failed");
+ ② graph-executor.ts FOREACH:若全部 iterResults 均 `success===false`,拋 Error(含首項 status code)
+ → catch 轉 ExecutionError → executeWebhookGraph 回 `{success:false, error:"..."}`。
+ 執行範圍:`cypher-executor/src/lib/wasi-shim.ts`、`cypher-executor/src/graph-executor.ts`。
+ 測試:vitest 新增兩條(FOREACH 全失敗→error 含 status;wasi-shim catch 路徑)。
+
+- [x] **t122 萃取引擎金鑰雲端下發(2026-07-29)**:
+ 來源=總管確認:daemon/config 回傳 extractor:'claude' 寫死且不含金鑰,封測者萃取全滅。
+ 修:① `POST /portal/admin/extractor`(admin 閘)存 `{engine, gemini_api_key?, llm_model?}` 到 KV
+ key=`{tenant}:portal:extractor_config`,金鑰不落 log;
+ ② `GET /portal/admin/extractor`(回 engine + has_key:bool,不回明文);
+ ③ `POST /portal/daemon/config` 回傳改讀上述設定(未設→預設 gemma;gemma+有 key → 含金鑰下發);
+ ④ `console-ui/public/portal/index.html` 設定頁新增「萃取引擎」區塊(引擎選擇+金鑰輸入+提示重連)。
+ 測試:vitest 新增 3 案(未設定→下發 gemma 無金鑰;設定後→下發含金鑰;GET 不回明文)。
+
+- [x] **t128 圖搜尋仍回 0——graph_neighbors 補傳 template(2026-07-29,任務層小改)**:
+ 診斷:t116 只補了 kbdb_base,但 fetch_triplets.url 同時含 `{{input.template}}`;
+ portal 呼叫 executeWebhookGraph 未傳 template → URL 變 `/records/by-template/?owner_id=...` → count=0。
+ 總管實測:手動補 `"template":"triplet"` → count=1(企業版功能解鎖)。
+ 修:portal-data.ts graph_neighbors 呼叫補 `template: 'triplet'`。
+ 執行範圍:`cypher-executor/src/routes/portal-data.ts` 一行。
+ 測試:portal-data.test.ts 新增整合案(t128 describe,workflow 執行不崩即代表 template 進到 context)。
+ workflow input 完整核對清單(portal-data.ts 所有 executeWebhookGraph 呼叫):
+ - graph_neighbors:node ✅、depth ✅、namespace ✅、owner ✅、kbdb_base ✅(t116)、template ✅(本次)
+ - rag_chat:question ✅;namespace/kbdb_base 未核實(rag_chat workflow yaml 在 arcrun-rag,非本 repo)
+ - takedown:不在 portal-data.ts,在 arcrun-rag 端(dashboard/安裝器),超出本 repo 範圍
+
+- [x] **t130 新實例缺 triplet template → 總圖永遠空(2026-07-29,任務層小改)**:
+ 真因:`PORTAL_TEMPLATE_SEEDS` 只有 `portal_user`+`portal_library`;`rag_ingest_card.post_triplet`
+ 打 `POST /records {template:'triplet'}` → 新實例回 400「template not found: triplet」→ 三元組全滅。
+ 修:`portal-seeds.ts` 補 `triplet` seed(slots 來源:2026-07-19 kbdb_list_templates 核實 15 槽 + library)。
+ 呼叫路徑驗證(`ensurePortalTemplates` 全四掛點):
+ ① `POST /init/seed`(acr init 觸發)← 主線,新裝必經
+ ② `POST /portal/admin/bootstrap`(首次 admin 設定)
+ ③ `POST /portal/daemon/libraries`(daemon 登記資料夾→庫)
+ ④ `POST /portal/admin/libraries`(admin 手動新增庫)
+ 既有壞實例補救:任一掛點重跑即補建(冪等),e.g. 重跑 acr init 或 bootstrap。
+ 執行範圍:`cypher-executor/src/lib/portal-seeds.ts`(新增 triplet seed);
+ `cypher-executor/tests/portal-auth.test.ts`(mockTemplatesExist 補 triplet + t130 3 條新測試);
+ `cypher-executor/tests/portal-admin.test.ts`(mockTemplatesExist 補 triplet)。
+ 測試:純資料驗證(seed 含 triplet + 必要 slots)+冪等驗(已存→existing)+自動補建(404→POST→created)。
+
+- [x] **t129 AI 問答出處重複一整頁——後端按 page_name 去重(2026-07-29,任務層小改)**:
+ 診斷:一張卡拆成 3-5 block,每 block 各回一筆 source(同 page_name)→ 前端列一整頁重複。
+ 選後端修(/portal/data/chat)理由:出處去重是資料清潔,跟前端渲染無關;改後端不需動 HTML。
+ 修:portal-data.ts 新增純函式 `dedupeSourcesByPage()`(Map 按 page_name/page 去重,hit_count > 1 時附計數);
+ chat 路由 rawSources 先過去重再回。
+ 執行範圍:`cypher-executor/src/routes/portal-data.ts`(新增 export 函式 + chat route)。
+ 測試:portal-data.test.ts 新增 5 案(t129 describe:合併計數/各保一筆/page 備用/空陣列/page_name 優先)。
+
+- [x] **t135 庫目錄移除(2026-07-29,任務層小改)**:
+ 來源=leo 裁定「需要加移除按鈕,別人裝錯我沒辦法幫他弄,需要可以自主」。
+ 後端:`DELETE /portal/admin/libraries/:id`(刪登記簿 record,資料不動)+
+ `DELETE /portal/admin/libraries/by-name/:name`(auto 庫;body `confirm:<庫名>` 才執行;
+ 呼叫 KBDB `PATCH /entries/deprecate-by-library`,entries 標 deprecated → 從 auto 清單消失)。
+ KBDB:`deleteRecord` action + `DELETE /records/:id` route;
+ `deprecateEntriesByLibrary` action + `PATCH /entries/deprecate-by-library` route(此路由在 `/:id` 之前)。
+ Daemon hint(根治只增不減):`POST /portal/daemon/libraries` 存 active lib names 到 KV(TTL 48h);
+ `GET /portal/admin/libraries` 讀 KV 後對登記庫標 `daemon_watching: false`(若 daemon 有回報但未含該庫)。
+ 前端:每張庫卡片加「移除」按鈕(登記庫=`confirm()` 對話框;auto 庫=`prompt()` 輸入庫名確認);
+ 移除後即時從列表消失(不必重整);`daemon_watching=false` → 顯示灰字「小幫手目前沒有在同步這個資料夾」。
+ 測試:portal-admin.test.ts 新增 7 案(DELETE/:id 成功/404/非admin403;by-name 成功/無confirm/confirm不符/非admin403)。
+
+- [x] **t131 金鑰設定合併(2026-07-29,任務層小改)**:
+ 來源=leo 裁定「一律規定先輸入 gemini api key,聊天和萃都一次設好;如果有 claude,勾選加強版」。
+ 後端:新增 `POST /portal/admin/ai`(同時做 chat-key+extractor 的事)+
+ `GET /portal/admin/ai`(回 has_key/use_claude_for_extract/claude_available)+
+ `POST /portal/daemon/report-capabilities`(收 daemon 回報的 has_claude);
+ 舊 `/portal/admin/chat-key`、`/portal/admin/extractor` 保留相容。
+ 前端:設定頁兩區塊合一,Gemini Key 必填欄+Claude 選填 checkbox(依 daemon 回報 enable/disable)。
+ ⚠️ daemon 端(arcrun-rag repo)需實作 `report-capabilities` 呼叫,規格:
+ - 連線成功後 POST `{cypher_url}/portal/daemon/report-capabilities`
+ - body: `{email, password, has_claude: bool, daemon_version: string, os: string}`
+ - has_claude 由現有 `FindClaudeBin()` 決定(t92 已實作)
+ - 時機:連線成功後一次+每次啟動時一次(不加新 timer)
+ KV key 設計:`{tenant}:portal:ai_config`(合併設定)`{tenant}:portal:daemon_caps`(能力回報,TTL 7 天)
+ 測試:portal-admin.test.ts 新增 7 案(全通;全套 238 tests 229 passed,9 failed 皆 pre-existing)。
+
+- [x] **t142 庫目錄顯示同步張數+關聯數(2026-07-29,任務層小改)**:
+ 來源=leo 裁定(政府專案驗收)「雲端子庫顯示同步了幾個 wiki+幾個三元組,一眼看出這個庫真的有東西」。
+ 後端(kbdb):
+ ① `GET /entries/library-stats?owner_id=` → `{stats: [{library, card_count}]}`
+ SQL:`COUNT(DISTINCT page_name)` GROUP BY library(僅 entry_type='block',排 deprecated)。
+ ⚠️ 卡數=distinct page_name(一張卡 3-5 個 block),不是 COUNT(*)(防膨脹 3-5 倍)。
+ 路由在 `/libraries` 之後、`/` 之前(避免被 `/:id` 吃掉)。
+ ② `GET /records/triplet-stats?owner_id=` → `{stats: [{library, triplet_count}]}`
+ SQL:subquery DISTINCT triplet record IDs + LEFT JOIN library slot(無 slot→general),
+ 一次 SQL 完成,不 N+1。路由在 `/by-template/:template` 之前。
+ 後端(portal.ts):`GET /portal/admin/libraries` 改並行撈三端點(Promise.all + .catch(→null));
+ 已登記庫與 auto 庫各補 card_count + triplet_count(Map O(1) 查找),任一失敗不炸主流程。
+ 前端(index.html renderAdminLibs):每張庫卡片補一行
+ 「N 張知識卡・M 條關聯」(只顯示非零項);兩者均 0 → 顯示「還沒有內容」,不顯示「0 張」。
+ 測試:`kbdb/tests/library-stats.test.ts`(14 新案:SQL 形狀/COUNT DISTINCT/entry_type filter/
+ deprecated filter/COALESCE general fallback/參數傳入/回傳結構/空陣列不炸);
+ `portal-admin.test.ts`(t97 既有測試補三端點 mock + t142 describe 3 案:
+ 已登記庫帶 stats/auto 庫帶 stats/空庫 card_count=triplet_count=0)。
+ vitest + node --check 待 leo 驗收環境跑(本機無 Workers runtime)。
+
## 第二波(不在本 SDD 動工範圍,掛號)
- MCP token 綁庫集合(design §9;PR#15 擴充,只動 `mcp/`)
diff --git a/system-dev/wiki/status.md b/system-dev/wiki/status.md
index 17bfe82..70b5624 100644
--- a/system-dev/wiki/status.md
+++ b/system-dev/wiki/status.md
@@ -15,6 +15,13 @@ metadata:
## 📍 當前位置
+> **2026-07-28(t95+t96 搜尋缺陷修復,main)**:portal 搜尋兩缺陷修復——t95 CJK/ASCII
+> 邊界自動補空白(`normalizeCjkQuery`,查詢端,不動索引);t96 graph 節點精確 0 鄰居
+> → fuzzy fallback(`findBestNodeMatch`/`fuzzyFindNode`,contains 比對+最短名優先)。
+> 純函式 + integration 各 6/2/4 案,tasks.md Bugfix 已標 [x]。
+> B5 分支衝突面:同一行 `libraries` 欄位(可一行解)。待 leo 本機跑 vitest 驗收
+>(sandbox symlink 封鎖,靜態分析確認邏輯正確)+ commit+部署。
+>
> **2026-07-19(#39 藏書地圖 M5,分支 `feat/console-library-map-home`)**:**library-map SDD M5(GUI
> 首頁)PR 已開,等審+gated 部署(merge 後需 leo 閘 redeploy cypher-executor)**。R4 落點裁定=
> console **總庫搜尋頁搜尋框上方**(rag profile 該頁即首頁;full profile 它是全館入口——駕駛艙是