feat(auth): 認證儲存搬回 D1/KV——落實方案C(leo confirm「走C」,arcrun-rag#99)

實作 pending-changes.md「認證儲存要不要搬回 D1/KV」提案(commit 8286c8a):
D61 的病根「重裝時 binding 被安裝器照名字重新指到新建空資源」已被更通用的
shared/resource-rule(Arcrun#97,2026-08-13)解掉,故不再需要繞開 binding
去躲這個病——而繞開的代價正是這次要收的債:Workers Secrets 寫入需要外部
CF_SECRETS_API_TOKEN,這把 token 從安裝那天起就沒被種過,止血版只解掉
「建第一個帳號」這一格,之後的每一次寫入(換密碼/加帳號/改權限)仍卡死。

改動:
- console 管理員帳密:家改回 SESSIONS_KV(binding,console-auth.ts)
- portal 多人帳號:家改回 KBDB(binding,走 base HTTP API,D38 零 SQL,portal.ts)
- D61 認證儲存(CF Workers Secrets)留為舊實例的唯讀回退路徑:讀取零成本、
  零外部憑證需求(只有寫入才要 token);登入成功即 best-effort 自動搬進新家,
  且**這次登入發出的 session 就直接指向新 record_id**(不必等下一次登入)
- D61 的三項「明顯失敗」語意全部保留:auth_store_empty(讀不到不算密碼錯、
  不計入鎖定)、/console/setup 遇既有帳號說清楚密碼沒被採用、/health 與
  /console/auth-status 吐儲存狀態
- 移除止血版的 x-arcrun-install-token 表頭傳遞機制(installToken 參數)——
  帳號寫入從此不需要任何外部 CF token,這個結構性缺口已從根拔除

測試:cypher-executor 全套 vitest 439/453(14 個既存失敗與本改動無關,已用
git stash 對照 clean checkout 逐一比對檔名確認完全相同);tsc --noEmit
無新增錯誤(3 個既存錯誤同上核實無關)。已跑 build-worker-artifacts.mjs
重打 tier2 bundle,grep 複驗 createKbdbUserRecord/promoteToKbdb 進了成品、
promoteLegacyUser/x-arcrun-install-token 完全從成品消失。

未覆蓋:POST /credentials(一般 workflow API 金鑰儲存)仍依賴
CF_SECRETS_API_TOKEN——這是 01-tech-stack.md 既有的、獨立於 D61 之外的
credential 儲存架構(D19「擁有目錄不擁有內容物」),本提案範圍只涵蓋「認證」
(登入帳密),不涵蓋一般 credential 儲存;07-29 已知缺口仍待另案處理。

不准 merge 進 main(SDD 鐵律③,等總管審過再併);不准部署(D20 出貨閘)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-08-14 12:52:07 +08:00
parent 8286c8afec
commit 4b6cc159b8
9 changed files with 591 additions and 570 deletions
+33 -32
View File
@@ -16,20 +16,20 @@
* KBDB 打 fetchMock 假 hostwrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+
* disableNetConnect——絕不外連。UI 全流程由本機隔離雙 worker 端到端 curl 驗證(PR 證據表)。
*
* D61ADR D61 / Leo/arcrun-rag#55):本檔測試裡的帳號 fixturerec_admin/rec_u1/rec_admin2…)
* 全部沿用「record_id 不是 auth: 開頭」這個既有慣例——這正是 portal.ts 的相容分流點
* isAuthStoreId(recordId)),非 auth: 開頭的 id 一律走原本的 KBDB 路徑,行為與 D61 之前
* 完全一致,故本檔絕大多數測試不需要改。**只有「新建帳號」這個動作**POST /portal/admin/users、
* POST /portal/admin/bootstrap 走同一支 createPortalUser)改成寫進認證儲存(CF Workers
* Secrets),需要額外攔截 `https://api.cloudflare.com/.../secrets`PUT)——見 mockAuthStoreWrite。
* 2026-08-14 起(D61 補充,leo confirm「走C」):帳號改回住 KBDBbinding,見
* pending-changes.md「認證儲存要不要搬回 D1/KV」)。本檔測試裡的帳號 fixture
* rec_admin/rec_u1/rec_admin2…)全部沿用「record_id 不是 auth: 開頭」這個既有慣例——
* 這正是 portal.ts 的相容分流點(isAuthStoreId(recordId)),非 auth: 開頭的 id 一律走
* KBDB 路徑,行為與 D61 之前完全一致,故本檔絕大多數測試不需要改。
* **只有「新建帳號」這個動作**POST /portal/admin/users、POST /portal/admin/bootstrap
* 走同一支 createPortalUser)改回直接寫 KBDBPOST /records + POST /entries head),
* 不再需要 `https://api.cloudflare.com/.../secrets`。
*/
import { SELF, env, fetchMock } from 'cloudflare:test';
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
import { hashPassword, PBKDF2_ITERATIONS } from '../src/lib/portal-auth';
import { AUTH_ID_PREFIX } from '../src/lib/portal-auth-store';
const KBDB = 'https://kbdb.test';
const CF_API = 'https://api.cloudflare.com';
const NS = 'leo::portal'; // wrangler.test.toml CONSOLE_TENANT=leo → 子 namespace
let storedHash: string;
@@ -49,19 +49,21 @@ function json(method: string, path: string, body?: unknown, headers: Record<stri
});
}
/** D61:認證儲存寫入路徑(同 portal-auth.test.ts 的同名 helper,見那邊檔頭的完整說明)。 */
function mockAuthStoreWrite(times = 1): { puts: () => Array<{ name: string; text: string }> } {
const captured: Array<{ name: string; text: string }> = [];
/** 建帳號的 KBDB 寫入路徑(POST /records + POST /entries head entry)。 */
function mockCreateUser(recordId: string): { recordBody: () => string } {
let recordBody = '';
fetchMock
.get(CF_API)
.intercept({ path: (p: string) => p.includes('/secrets'), method: 'PUT' })
.get(KBDB)
.intercept({ path: '/records', method: 'POST' })
.reply(200, (opts) => {
const body = JSON.parse(String(opts.body)) as { name: string; text: string };
captured.push(body);
return { success: true };
})
.times(times);
return { puts: () => captured };
recordBody = String(opts.body);
return { success: true, record: { record_id: recordId, template_id: 'tpl_pu', values: {} } };
});
fetchMock
.get(KBDB)
.intercept({ path: '/entries', method: 'POST' })
.reply(200, { success: true, entry: { id: `${recordId}_head` } });
return { recordBody: () => recordBody };
}
function mockHeadLookup(email: string, recordId: string | null) {
@@ -202,11 +204,12 @@ describe('last-admin 鎖死保護(PATCH /portal/admin/users/:id', () => {
// ═══════════════ 2. 一次性密碼(新增帳號)═══════════════
describe('POST /portal/admin/users(一次性密碼)', () => {
it('未帶 password → generated_password 回一次(16 碼);認證儲存落的是 hash 非明碼D61', async () => {
it('未帶 password → generated_password 回一次(16 碼);KBDB 落的是 hash 非明碼', async () => {
await seedAdminSession();
mockGetRecord('rec_admin', adminValues());
mockHeadLookup('new@example.com', null); // email 未占用(新家找不到 → 回退查舊家)
const { puts } = mockAuthStoreWrite();
mockHeadLookup('new@example.com', null); // email 未占用
const { recordBody } = mockCreateUser('rec_new');
mockGetRecord('rec_new', userValues({ email: 'new@example.com' })); // 回應用的回讀
const res = await json(
'POST',
'/portal/admin/users',
@@ -218,23 +221,21 @@ describe('POST /portal/admin/users(一次性密碼)', () => {
expect(typeof data.generated_password).toBe('string');
expect(data.generated_password!.length).toBe(16);
expect('password_hash' in data.user).toBe(false);
expect((data.user as { record_id: string }).record_id.startsWith(AUTH_ID_PREFIX)).toBe(true); // 住新家
expect((data.user as { record_id: string }).record_id).toBe('rec_new'); // 住 KBDB
// 一次性密碼不落地:認證儲存收到的 shard 只有 hash、無明碼
const shards = puts();
expect(shards.length).toBe(1);
expect(shards[0].text).not.toContain(data.generated_password!);
const shard = JSON.parse(shards[0].text) as { users: Array<{ email: string; password_hash: string }> };
const stored = shard.users.find((u) => u.email === 'new@example.com');
expect(stored).toBeDefined();
expect(stored!.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
// 一次性密碼不落地:KBDB 收到的 record body 只有 hash、無明碼
expect(recordBody()).not.toContain(data.generated_password!);
const rec = JSON.parse(recordBody()) as { owner_id: string; values: Record<string, string> };
expect(rec.owner_id).toBe(NS);
expect(rec.values.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
});
it('自帶 password → 回應**無** generated_password', async () => {
await seedAdminSession();
mockGetRecord('rec_admin', adminValues());
mockHeadLookup('own@example.com', null);
mockAuthStoreWrite();
mockCreateUser('rec_own');
mockGetRecord('rec_own', userValues({ email: 'own@example.com' }));
const res = await json(
'POST',
'/portal/admin/users',