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
@@ -1,17 +1,21 @@
/**
* console-auth.ts —— D61 舊實例相容(帳密只在舊 SESSIONS_KV,尚未搬遷過
* console-auth.ts —— 舊實例相容(帳密還在 D61 的認證儲存,尚未搬回 SESSIONS_KV
*
* 拆成獨立檔案的理由:portal-auth-store.ts 的 per-isolate overlay 是模組級全域變數,
* 一旦某個測試讓 console 帳密的認證儲存寫入成功,overlay.console 就會在**同一支測試檔案**
* 剩下的測試裡持續存在(不同檔案=不同 worker 執行個體,互不污染,已用小型探針驗證過)。
* tests/console-auth.test.ts 一開始就會走一次「首次設定成功」,之後整支檔案都是「已設定」
* 的世界;「認證儲存還是空的、帳密只活在舊 KV」這個起始狀態只有在全新檔案才測得出來。
* 2026-08-14 起(D61 補充,leo confirm「走C」):console 帳密的家改回 SESSIONS_KVbinding)。
* 已經在跑 D61(帳密住 CF Workers Secrets)的實例不能因為這次改動而登不進去——讀取認證儲存
* 零成本、零外部憑證需求(只有寫入才需要 CF_SECRETS_API_TOKEN),所以永遠讀得到;讀到就
* 順手搬回 SESSIONS_KVbest-effortbinding put 幾乎不會失敗)。
*
* 拆成獨立檔案的理由:portal-auth-store.ts 內部沒有跨測試檔案共享的可變狀態問題(每個測試
* 檔案是獨立 worker 執行個體),但為了跟 console-auth.test.ts(一開始就會把帳密設進
* SESSIONS_KV)的「全新、尚未設定」起始狀態互不干擾,仍分開一支檔案,語意更清楚。
*/
import { SELF, env, fetchMock } from 'cloudflare:test';
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
import { mutateAuthStore } from '../src/lib/portal-auth-store';
import type { Bindings } from '../src/types';
const CF_API = 'https://api.cloudflare.com';
const CREDS_KEY = 'console:credentials';
beforeAll(() => {
fetchMock.activate();
@@ -27,18 +31,12 @@ function json(method: string, path: string, body?: unknown) {
});
}
function mockAuthStoreWrite(times = 1): { puts: () => Array<{ name: string; text: string }> } {
const captured: Array<{ name: string; text: string }> = [];
/** 種一筆進 D61 認證儲存(CF Workers Secrets)——直接呼叫 lib,不經任何 HTTP route。 */
function mockLegacySecretsWrite(): void {
fetchMock
.get(CF_API)
.intercept({ path: (p: string) => p.includes('/secrets'), method: 'PUT' })
.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 };
.reply(200, { success: true });
}
/** 複刻 console-auth.ts 內未 export 的私有迭代雜湊(sha256(salt+password) 迭代 3 次),
@@ -57,45 +55,45 @@ const EMAIL = 'legacy-owner@example.com';
const PASSWORD = 'legacy-owner-pw-1';
const SALT = 'deadbeef00112233';
describe('D61 舊實例相容:console 帳密只在舊 KV(尚未搬遷)', () => {
it('GET /console/auth-status:讀到舊 KV 這筆、順手搬進認證儲存', async () => {
// 🔴 全部收在**同一個 `it()`** 裡(不拆成多則):`isolatedStorage`vitest-pool-workers 預設開)
// 在每一個 `it()` 前重置 SESSIONS_KV,但 D61 認證儲存的模組級記憶體變數 `overlay` 不受影響
// (見 lib/portal-auth-store.ts 檔頭)。若拆成多則,「搬回 SESSIONS_KV 後再打一次直接命中
// 新家」這一步在下一個 `it()` 會因為 KV 被重置而又落回 legacy-secrets 分支,驗不出「新家優先」
// 這件事——所以要在同一次測試、同一份 KV 狀態內連續打兩次才驗得出來。
describe('舊實例相容:console 帳密只在認證儲存(尚未搬回 SESSIONS_KV', () => {
it('GET /console/auth-status 讀到認證儲存這筆、順手搬回 SESSIONS_KV;再打一次直接命中新家;帳密登得進去', async () => {
const hash = await legacyHash(PASSWORD, SALT);
await env.SESSIONS_KV.put(
CREDS_KEY,
JSON.stringify({ email: EMAIL, salt: SALT, hash, created_at: '2026-01-01T00:00:00.000Z' }),
);
const { puts } = mockAuthStoreWrite();
mockLegacySecretsWrite();
await mutateAuthStore(env as unknown as Bindings, (data) => {
data.console = { email: EMAIL, salt: SALT, hash, created_at: '2026-01-01T00:00:00.000Z' };
});
const res = await json('GET', '/console/auth-status');
expect(res.status).toBe(200);
const data = (await res.json()) as {
configured: boolean;
credentials_source: string;
auth_store: { console_configured: boolean };
auth_store: { legacy_secrets_present: boolean };
};
expect(data.configured).toBe(true);
expect(data.credentials_source).toBe('legacy-kv'); // 這次是靠回退讀到的
// loadCredentials 內的 best-effort 搬遷在回應組出來之前就已 await 完成
// 故 authStoreStatus 已經反映搬遷後的狀態
expect(data.auth_store.console_configured).toBe(true);
expect(data.credentials_source).toBe('legacy-secrets'); // 這次是靠回退讀到的
// loadCredentials 內的 best-effort 搬遷在回應組出來之前就已 await 完成
expect(data.auth_store.legacy_secrets_present).toBe(true);
const shards = puts();
expect(shards.length).toBe(1);
const shard = JSON.parse(shards[0].text) as { console: { email: string; hash: string } };
expect(shard.console.email).toBe(EMAIL);
expect(shard.console.hash).toBe(hash); // 原樣搬過去,不重新雜湊
});
const kvRaw = await env.SESSIONS_KV.get('console:credentials');
expect(kvRaw).toBeTruthy();
const stored = JSON.parse(kvRaw!) as { email: string; hash: string };
expect(stored.email).toBe(EMAIL);
expect(stored.hash).toBe(hash); // 原樣搬過去,不重新雜湊
it('搬遷後再打一次:新家已經有了,直接命中新家(不用再查舊 KV)', async () => {
const res = await json('GET', '/console/auth-status');
const data = (await res.json()) as { credentials_source: string };
expect(data.credentials_source).toBe('secrets');
});
// 搬回後再打一次:SESSIONS_KV 已經有了,直接命中新家(不用再查認證儲存)
const res2 = await json('GET', '/console/auth-status');
const data2 = (await res2.json()) as { credentials_source: string };
expect(data2.credentials_source).toBe('kv');
it('用搬遷過去的帳密登入 → 200(搬遷沒有讓帳密變得登不進去)', async () => {
const res = await json('POST', '/console/login', { email: EMAIL, password: PASSWORD });
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean };
expect(data.success).toBe(true);
// 用搬回去的帳密登入 → 200(搬遷沒有讓帳密變得登不進去)
const login = await json('POST', '/console/login', { email: EMAIL, password: PASSWORD });
expect(login.status).toBe(200);
expect((await login.json() as { success: boolean }).success).toBe(true);
});
});