t181 修正:/portal/daemon/extract 認證改用 X-Arcrun-API-Key(帳密行不通)

【我自己的設計錯誤】前一版用帳密認證,但查證 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) <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-08-04 14:37:16 +08:00
parent d728071a6a
commit 60f481938d
2 changed files with 26 additions and 45 deletions
+9 -14
View File
@@ -731,28 +731,23 @@ function toPublicLibrary(rec: PortalRecord) {
//binding 是 Worker 專屬),且模型選型集中在雲端才能統一換。
// ⚠️ 隱私邊界不變:daemon 送的是**已在本機轉成文字的原稿**,回傳的是知識卡;
// 原始檔案(docx/pdf)仍然不出用戶的電腦。
// 🔑 認證用 `X-Arcrun-API-Key`(=namespace),**不是帳密**
// daemon 的密碼**只在連線精靈當下用過就丟、不落地**(config.json 沒有密碼欄,刻意的安全設計),
// 但背景萃取是每輪自動跑的 ⇒ 根本拿不到密碼。
// 而 daemon 送卡片上雲時本來就帶這個 headercollector/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 AIwrangler.toml 需有 [ai] binding),請更新知識庫版本' }, 501);
+17 -31
View File
@@ -820,46 +820,32 @@ describe('/portal/admin/ai + /portal/daemon/report-capabilitiest131', () =
// 「52 檔全滅還要把金鑰傳給別人才查得出原因」三種災難。
describe('POST /portal/daemon/extractt181Workers AI 萃卡,免金鑰)', () => {
const USER_EMAIL = 'daemon@example.com';
const USER_PW = 'unit-test-pw-1';
const USER_RECORD = 'rec_daemon_extract';
// 認證=X-Arcrun-API-Key(=namespacewrangler.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);
});
// 🔴 回歸守衛:這條路**不得**要求任何 GeminiAPI 金鑰。
// 若哪天有人把它改回打 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');