From 60f481938d67ff5a625ec6e377c933741accdfc5 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Tue, 4 Aug 2026 14:37:16 +0800 Subject: [PATCH] =?UTF-8?q?t181=20=E4=BF=AE=E6=AD=A3=EF=BC=9A/portal/daemo?= =?UTF-8?q?n/extract=20=E8=AA=8D=E8=AD=89=E6=94=B9=E7=94=A8=20X-Arcrun-API?= =?UTF-8?q?-Key=EF=BC=88=E5=B8=B3=E5=AF=86=E8=A1=8C=E4=B8=8D=E9=80=9A?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 【我自己的設計錯誤】前一版用帳密認證,但查證 daemon 實際行為後發現行不通: **密碼只在連線精靈當下用過就丟、不落地**(config.json 沒有密碼欄,刻意的安全設計, wiki 記為「密碼零落地」),而背景萃取是每輪自動跑的 ⇒ 根本拿不到密碼。 【改法】沿用 daemon 送卡片上雲時本來就帶的 `X-Arcrun-API-Key`(=namespace, collector/direct.go:355)⇒ 同一把憑證、同一個身份模型,不必為此新增任何儲存。 不符即 401(租戶隔離)。 測試四則全過(沒帶 key 401/key 錯 401/缺參數 400/ **回歸守衛:錯誤訊息不得出現 gemini_api_key 或 credential**)。 全檔 38 passed,唯一 failed 與 tsc 的 1190 行 'auto' 皆為基準線既有、與本次無關。 Co-Authored-By: Claude Opus 5 (1M context) --- cypher-executor/src/routes/portal.ts | 23 ++++------- cypher-executor/tests/portal-admin.test.ts | 48 ++++++++-------------- 2 files changed, 26 insertions(+), 45 deletions(-) diff --git a/cypher-executor/src/routes/portal.ts b/cypher-executor/src/routes/portal.ts index b3f1e1d..fd2c6bc 100644 --- a/cypher-executor/src/routes/portal.ts +++ b/cypher-executor/src/routes/portal.ts @@ -731,28 +731,23 @@ function toPublicLibrary(rec: PortalRecord) { //(binding 是 Worker 專屬),且模型選型集中在雲端才能統一換。 // ⚠️ 隱私邊界不變:daemon 送的是**已在本機轉成文字的原稿**,回傳的是知識卡; // 原始檔案(docx/pdf)仍然不出用戶的電腦。 +// 🔑 認證用 `X-Arcrun-API-Key`(=namespace),**不是帳密**: +// daemon 的密碼**只在連線精靈當下用過就丟、不落地**(config.json 沒有密碼欄,刻意的安全設計), +// 但背景萃取是每輪自動跑的 ⇒ 根本拿不到密碼。 +// 而 daemon 送卡片上雲時本來就帶這個 header(collector/direct.go:355),沿用同一把最自然。 portalRouter.post('/portal/daemon/extract', (c) => run(c, async () => { + const apiKey = (c.req.header('X-Arcrun-API-Key') ?? '').trim(); + if (!apiKey) return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401); + if (apiKey !== portalTenant(c.env)) return c.json({ error: 'X-Arcrun-API-Key 不正確' }, 401); + const body = (await c.req.json().catch(() => null)) as - | { email?: string; password?: string; page_name?: string; text?: string } + | { page_name?: string; text?: string } | null; - const email = String(body?.email ?? '').trim().toLowerCase(); - const password = String(body?.password ?? ''); - if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400); const pageName = String(body?.page_name ?? '').trim(); const srcText = String(body?.text ?? ''); if (!pageName || !srcText.trim()) return c.json({ error: 'page_name 與 text 必填' }, 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); - if (!c.env.AI) { // 誠實失敗:不假裝成功,並指名這個部署缺什麼(禁假綠) return c.json({ error: '這個部署沒有綁定 Workers AI(wrangler.toml 需有 [ai] binding),請更新知識庫版本' }, 501); diff --git a/cypher-executor/tests/portal-admin.test.ts b/cypher-executor/tests/portal-admin.test.ts index 0f9a169..6b25625 100644 --- a/cypher-executor/tests/portal-admin.test.ts +++ b/cypher-executor/tests/portal-admin.test.ts @@ -820,46 +820,32 @@ describe('/portal/admin/ai + /portal/daemon/report-capabilities(t131)', () = // 「52 檔全滅還要把金鑰傳給別人才查得出原因」三種災難。 describe('POST /portal/daemon/extract(t181:Workers AI 萃卡,免金鑰)', () => { - const USER_EMAIL = 'daemon@example.com'; - const USER_PW = 'unit-test-pw-1'; - const USER_RECORD = 'rec_daemon_extract'; + // 認證=X-Arcrun-API-Key(=namespace,wrangler.test.toml CONSOLE_TENANT=leo), + // **不是帳密**:daemon 密碼不落地(連線精靈用完即丟),背景萃取拿不到密碼。 + const KEY = { 'X-Arcrun-API-Key': 'leo' }; - 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('沒帶 API Key → 401', async () => { + const res = await json('POST', '/portal/daemon/extract', { page_name: 'x', text: 'y' }); + expect(res.status).toBe(401); + }); + + it('API Key 錯 → 401(租戶隔離)', async () => { + const res = await json('POST', '/portal/daemon/extract', + { page_name: 'x', text: 'y' }, { 'X-Arcrun-API-Key': 'someone-else' }); + expect(res.status).toBe(401); + }); it('缺 page_name 或 text → 400(不打 AI、不假裝成功)', async () => { - const res = await json('POST', '/portal/daemon/extract', { email: USER_EMAIL, password: USER_PW }); + const res = await json('POST', '/portal/daemon/extract', {}, KEY); expect(res.status).toBe(400); const d = (await res.json()) as { error?: string }; expect(String(d.error)).toContain('page_name'); }); - it('缺帳密 → 400', async () => { - const res = await json('POST', '/portal/daemon/extract', { page_name: 'x', text: 'y' }); - expect(res.status).toBe(400); - }); - - it('帳密錯 → 401(認證與 daemon/config 同一把)', async () => { - mockEmailLookup(USER_EMAIL, USER_RECORD); - mockGetRecord(USER_RECORD, adminValues({ email: USER_EMAIL, password_hash: storedHash })); - const res = await json('POST', '/portal/daemon/extract', { - email: USER_EMAIL, password: 'wrong-password', page_name: 'x', text: 'y', - }); - expect(res.status).toBe(401); - }); - - // 🔴 回歸守衛:這條路**不得**要求任何 Gemini/API 金鑰。 - // 若哪天有人把它改回打 Google,這則會因為錯誤訊息提到 credential/gemini 而紅。 + // 🔴 回歸守衛:這條路**不得**要求任何 Gemini/API 金鑰——免金鑰正是它存在的理由。 + // 若哪天有人把它改回打 Google,錯誤訊息會出現 credential/gemini_api_key ⇒ 這則會紅。 it('錯誤訊息不得要求任何金鑰(免金鑰是本端點存在的理由)', async () => { - const res = await json('POST', '/portal/daemon/extract', { email: USER_EMAIL, password: USER_PW }); + const res = await json('POST', '/portal/daemon/extract', {}, KEY); const raw = await res.text(); expect(raw).not.toContain('gemini_api_key'); expect(raw).not.toContain('credential');