Merge branch 'main' into fix/merge-main-into-batch-t173
# Conflicts: # console-ui/public/portal/index.html # cypher-executor/src/routes/health.ts # cypher-executor/src/routes/portal.ts # registry/components/kbdb_upsert_block/component.contract.yaml # registry/examples/km-wiki-ingest/workflow.yaml
This commit is contained in:
Symlink
+1
@@ -0,0 +1 @@
|
||||
/Users/youlinhsieh/Documents/tech_projects/InkStoneCo/matrix/arcrun/kbdb/node_modules
|
||||
@@ -126,6 +126,27 @@ export async function deleteEntry(db: D1Database, id: string): Promise<void> {
|
||||
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<number> {
|
||||
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'),但單組佔位符、不用重複綁參數。
|
||||
|
||||
@@ -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<boolean> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
|
||||
@@ -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(() => ({}));
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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 <token>`.
|
||||
// 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.
|
||||
|
||||
@@ -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()');
|
||||
});
|
||||
});
|
||||
@@ -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 <token>` 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
|
||||
|
||||
Reference in New Issue
Block a user