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:
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user