4b6cc159b8
實作 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>
100 lines
5.1 KiB
TypeScript
100 lines
5.1 KiB
TypeScript
/**
|
||
* console-auth.ts —— 舊實例相容(帳密還在 D61 的認證儲存,尚未搬回 SESSIONS_KV)
|
||
*
|
||
* 2026-08-14 起(D61 補充,leo confirm「走C」):console 帳密的家改回 SESSIONS_KV(binding)。
|
||
* 已經在跑 D61(帳密住 CF Workers Secrets)的實例不能因為這次改動而登不進去——讀取認證儲存
|
||
* 零成本、零外部憑證需求(只有寫入才需要 CF_SECRETS_API_TOKEN),所以永遠讀得到;讀到就
|
||
* 順手搬回 SESSIONS_KV(best-effort,binding 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';
|
||
|
||
beforeAll(() => {
|
||
fetchMock.activate();
|
||
fetchMock.disableNetConnect();
|
||
});
|
||
afterEach(() => fetchMock.assertNoPendingInterceptors());
|
||
|
||
function json(method: string, path: string, body?: unknown) {
|
||
return SELF.fetch(`http://localhost${path}`, {
|
||
method,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: body === undefined ? undefined : JSON.stringify(body),
|
||
});
|
||
}
|
||
|
||
/** 種一筆進 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, { success: true });
|
||
}
|
||
|
||
/** 複刻 console-auth.ts 內未 export 的私有迭代雜湊(sha256(salt+password) 迭代 3 次),
|
||
* 單純為了在測試端準備一筆能通過驗證的 legacy fixture,不是重新實作生產邏輯。 */
|
||
async function legacyHash(password: string, salt: string): Promise<string> {
|
||
async function sha256Hex(input: string): Promise<string> {
|
||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
|
||
return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||
}
|
||
let h = `${salt}:${password}`;
|
||
for (let i = 0; i < 3; i++) h = await sha256Hex(h);
|
||
return h;
|
||
}
|
||
|
||
const EMAIL = 'legacy-owner@example.com';
|
||
const PASSWORD = 'legacy-owner-pw-1';
|
||
const SALT = 'deadbeef00112233';
|
||
|
||
// 🔴 全部收在**同一個 `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);
|
||
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: { legacy_secrets_present: boolean };
|
||
};
|
||
expect(data.configured).toBe(true);
|
||
expect(data.credentials_source).toBe('legacy-secrets'); // 這次是靠回退讀到的
|
||
// loadCredentials 內的 best-effort 搬遷在回應組出來之前就已 await 完成
|
||
expect(data.auth_store.legacy_secrets_present).toBe(true);
|
||
|
||
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); // 原樣搬過去,不重新雜湊
|
||
|
||
// 搬回後再打一次: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');
|
||
|
||
// 用搬回去的帳密登入 → 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);
|
||
});
|
||
});
|