From 9ba6ba75fc3ba8c3f88ce8a435bde9a89bbbdb98 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Sat, 15 Aug 2026 14:48:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(cypher):=20POST=20/kbdb/records=20?= =?UTF-8?q?=E9=96=8B=E9=80=9A=20entry=5Fids=20=E9=80=9A=E9=81=93=EF=BC=88A?= =?UTF-8?q?rcrun#128=20=E7=9A=84=E5=B0=8D=E5=A4=96=E9=96=80=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 基本盤補好而通道不開=能力在、沒人打得到——同 b6ef0f0 那次 PATCH 的形狀。 #129(wiki template)與 #130(三元組正規化)的寫入端走的就是這扇門。 改了兩件事(維持純轉發,判斷全在基本盤,薄殼鐵律見檔頭): · 舊版寫死 `!body.values → 400`:只給 entry_ids 的合法請求會被自己的 proxy 擋掉 ⇒ 改成「values 與 entry_ids 至少要有一個」 · 轉發 body 帶上 entry_ids;沒給的那個 key 就不塞(不憑空給基本盤一個空物件) 租戶隔離沿用本檔既有做法:**忽略 caller 自帶的 owner_id,一律注入 header 身份**。 配合基本盤這次加的檢查(被參照 entry 的 owner_id 必須與注入的租戶相符), 「呼叫端可以自己指定 entry_id」這條新路徑不會變成跨租戶的門。 驗(tests/kbdb-records-entry-ids-proxy.test.ts,6 條,fetchMock 假 host+斷網) · 無 X-Arcrun-API-Key → 401 不碰 KBDB · 只給 entry_ids → 轉發成功,body 為 {template, entry_ids, owner_id:'leo'}(無 values key), 且 caller 自帶的 owner_id:'alice' 被忽略 · values 與 entry_ids 混用 → 兩個都轉過去 · 舊呼叫端只給 values → 轉發 body 與過去逐字相同,不夾帶 entry_ids · 兩個都沒給 → 400 不轉發;base 擋跨租戶(400)→ 原樣透傳不假裝成功 cypher-executor 全套 445 綠 / 14 紅——那 14 條是**既有**紅燈 (auth-dispatcher/console-library-map-page/executor/portal-admin/portal-data), 已用 `git stash` 拿掉本次改動複跑同五個檔複驗:同樣 14 紅,與本次無關。 Co-Authored-By: Claude Opus 5 --- cypher-executor/src/routes/kbdb-proxy.ts | 21 ++- .../kbdb-records-entry-ids-proxy.test.ts | 126 ++++++++++++++++++ 2 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 cypher-executor/tests/kbdb-records-entry-ids-proxy.test.ts diff --git a/cypher-executor/src/routes/kbdb-proxy.ts b/cypher-executor/src/routes/kbdb-proxy.ts index 0074369..5c555e4 100644 --- a/cypher-executor/src/routes/kbdb-proxy.ts +++ b/cypher-executor/src/routes/kbdb-proxy.ts @@ -81,20 +81,33 @@ kbdbProxyRouter.get('/kbdb/templates/:idOrName', async (c) => { // ── records(以租戶 namespace 為 owner_id 隔離)──────────────────────────────── -// POST /kbdb/records — 填一筆 record(template + values)。owner_id 自動注入。 +// POST /kbdb/records — 填一筆 record(template + values/entry_ids)。owner_id 自動注入。 +// +// `entry_ids`(Arcrun#128)= slot 指向**既有** entry 的 id,不新建、不複製;與 values 並存 +// (給字串照舊新建)。這裡維持純轉發,判斷與擋人全在基本盤 kbdb(薄殼鐵律,見檔頭): +// · 兩者都沒給/型別不對 → base 回 400 +// · 指到別人的 entry → base 擋(它比對被參照 entry 的 owner_id 與這裡注入的租戶身份, +// 所以「呼叫端自己指定 entry_id」這條新路徑不會變成跨租戶的門) +// 🔴 為什麼通道要一起開:#129(wiki template)與 #130(三元組正規化)的寫入端走這扇門。 +// 基本盤補好而通道不開=能力在、沒人打得到——同 PATCH 那次(b6ef0f0)的教訓。 kbdbProxyRouter.post('/kbdb/records', async (c) => { const owner = tenant(c); if (!owner) return c.json(NEED_KEY, 401); const body = await c.req.json().catch(() => null); - if (!body || !body.template || !body.values) { - return c.json({ error: 'template 與 values 必填' }, 400); + if (!body || !body.template || (!body.values && !body.entry_ids)) { + return c.json({ error: 'template 必填,values 與 entry_ids 至少要有一個' }, 400); } const { base, headers } = kbdbBase(c.env); const res = await fetch(`${base}/records`, { method: 'POST', headers, // 強制以租戶身份隔離:忽略 caller 自帶 owner_id,一律用 header 身份(防跨租戶寫入) - body: JSON.stringify({ template: body.template, values: body.values, owner_id: owner }), + body: JSON.stringify({ + template: body.template, + ...(body.values ? { values: body.values } : {}), + ...(body.entry_ids ? { entry_ids: body.entry_ids } : {}), + owner_id: owner, + }), }); return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); }); diff --git a/cypher-executor/tests/kbdb-records-entry-ids-proxy.test.ts b/cypher-executor/tests/kbdb-records-entry-ids-proxy.test.ts new file mode 100644 index 0000000..6e4c03b --- /dev/null +++ b/cypher-executor/tests/kbdb-records-entry-ids-proxy.test.ts @@ -0,0 +1,126 @@ +/** + * POST /kbdb/records — `entry_ids` 通道(Arcrun#128) + * + * 背景:基本盤 kbdb 的 createRecord 現在接受 `entry_ids`(slot 指向**既有** entry 的 id, + * 不新建、不複製)。這條 proxy 之前寫死只轉發 `values`,且沒有 values 就 400 + * ⇒ 走 X-Arcrun-API-Key 的呼叫者(#129 的 wiki 寫入端、#130 的三元組正規化)打不到新能力, + * 等於基本盤補好了、通道沒開(同 b6ef0f0 那次 PATCH 的形狀)。 + * + * 驗的是 IO 接線(判斷真身在基本盤,這裡只測轉發,比照 kbdb-records-patch-proxy.test.ts): + * 1. 租戶閘:無 X-Arcrun-API-Key → 401 不碰 KBDB + * 2. 只給 entry_ids(沒有 values)→ 轉發成功(舊版這裡是 400) + * 3. 轉發的 body:帶 entry_ids + **注入租戶當 owner_id**(caller 自帶的 owner_id 被忽略) + * 4. values 與 entry_ids 混用 → 兩個都轉過去 + * 5. 兩個都沒給 → 400,不轉發 + * 6. base 擋跨租戶(400)→ 原樣透傳,不假裝成功 + * + * KBDB 打 fetchMock 假 host(wrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+ + * disableNetConnect——測試絕不外連。 + */ +import { SELF, fetchMock } from 'cloudflare:test'; +import { beforeAll, afterEach, describe, it, expect } from 'vitest'; + +const KEY = { 'X-Arcrun-API-Key': 'leo', 'Content-Type': 'application/json' }; + +beforeAll(() => { + fetchMock.activate(); + fetchMock.disableNetConnect(); +}); +afterEach(() => fetchMock.assertNoPendingInterceptors()); + +describe('POST /kbdb/records(entry_ids)— 租戶閘與參數', () => { + it('無 X-Arcrun-API-Key → 401,不碰 KBDB', async () => { + const res = await SELF.fetch('http://localhost/kbdb/records', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ template: 'wiki', entry_ids: { gloss: 'e_1' } }), + }); + expect(res.status).toBe(401); + }); + + it('values 與 entry_ids 都沒給 → 400,不轉發', async () => { + const res = await SELF.fetch('http://localhost/kbdb/records', { + method: 'POST', + headers: KEY, + body: JSON.stringify({ template: 'wiki' }), + }); + expect(res.status).toBe(400); + }); +}); + +describe('POST /kbdb/records(entry_ids)— 轉發', () => { + it('只給 entry_ids(沒有 values)→ 轉發,且 owner_id 由租戶身份注入', async () => { + fetchMock + .get('https://kbdb.test') + .intercept({ + path: '/records', + method: 'POST', + // 沒有 values 這個 key(不要憑空塞一個空物件給基本盤) + body: JSON.stringify({ template: 'wiki', entry_ids: { gloss: 'e_1', points: 'e_2' }, owner_id: 'leo' }), + }) + .reply(200, { + success: true, + record: { record_id: 'rec_1', template_id: 'tpl-wiki', values: { gloss: '既有', points: '既有2' }, owner_id: 'leo' }, + }); + const res = await SELF.fetch('http://localhost/kbdb/records', { + method: 'POST', + headers: KEY, + // caller 自帶 owner_id:必須被忽略(防跨租戶寫入,本檔既有慣例) + body: JSON.stringify({ template: 'wiki', entry_ids: { gloss: 'e_1', points: 'e_2' }, owner_id: 'alice' }), + }); + expect(res.status).toBe(200); + const data = (await res.json()) as { success: boolean; record: { values: Record } }; + expect(data.success).toBe(true); + expect(data.record.values.gloss).toBe('既有'); + }); + + it('values 與 entry_ids 混用 → 兩個都轉過去', async () => { + fetchMock + .get('https://kbdb.test') + .intercept({ + path: '/records', + method: 'POST', + body: JSON.stringify({ template: 'wiki', values: { title: '新建' }, entry_ids: { gloss: 'e_1' }, owner_id: 'leo' }), + }) + .reply(200, { success: true, record: { record_id: 'rec_2', template_id: 'tpl-wiki', values: {}, owner_id: 'leo' } }); + const res = await SELF.fetch('http://localhost/kbdb/records', { + method: 'POST', + headers: KEY, + body: JSON.stringify({ template: 'wiki', values: { title: '新建' }, entry_ids: { gloss: 'e_1' } }), + }); + expect(res.status).toBe(200); + }); + + it('舊呼叫端(只給 values)→ 轉發的 body 不夾帶 entry_ids,行為與過去相同', async () => { + fetchMock + .get('https://kbdb.test') + .intercept({ + path: '/records', + method: 'POST', + body: JSON.stringify({ template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B' }, owner_id: 'leo' }), + }) + .reply(200, { success: true, record: { record_id: 'rec_3', template_id: 'tpl-triplet', values: {}, owner_id: 'leo' } }); + const res = await SELF.fetch('http://localhost/kbdb/records', { + method: 'POST', + headers: KEY, + body: JSON.stringify({ template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B' } }), + }); + expect(res.status).toBe(200); + }); + + it('base 擋下跨租戶參照(400)→ 原樣透傳,不假裝成功', async () => { + fetchMock + .get('https://kbdb.test') + .intercept({ path: '/records', method: 'POST' }) + .reply(400, { success: false, error: 'entry owner mismatch: e_x(alice) != leo' }); + const res = await SELF.fetch('http://localhost/kbdb/records', { + method: 'POST', + headers: KEY, + body: JSON.stringify({ template: 'wiki', entry_ids: { gloss: 'e_x' } }), + }); + expect(res.status).toBe(400); + const data = (await res.json()) as { success: boolean; error: string }; + expect(data.success).toBe(false); + expect(data.error).toContain('owner mismatch'); + }); +});