417d69ceb3
leo 2026-08-10 在 youlin stage 改完密碼被登出,之後怎麼登都回不去。 病灶是「這一瞬間讀不到」被當成「這個人不存在」,而且做的是**不可逆**的動作。 認證的家是 CF Workers Secret,改它會產生 worker 新版本,既有 isolate 讀到的 還是舊 env(#55 實測 ≥15 秒)。舊的 requirePortalUser 在那個空窗裡直接把 session 從 KV 刪掉——不是擋下讓你重試,是當場銷毀,等 secret 鋪開也回不來。 #55 補的「讀不到就再問一次加速器」只加在登入路徑(findAndVerifyUser),這道門沒有。 三處修法(缺一不可): 1. requirePortalUser 先問一次加速器再判定(與登入路徑同一招、同一支函式) 2. **永不因「讀不到」刪 session**;仍讀不到且正在傳播空窗 → 回 503 `auth_store_propagating` 而不是 401(讀得到 record 的「已停用」照舊刪,那是確定的事實) 3. 前端 boot() 從「任何非 2xx 都 dropSession」收斂成**只有 401 才算被登出** ——後端不刪、前端卻自己丟掉 localStorage 的 token,症狀一模一樣 順帶修掉同一族的一個資料遺失路徑:mutateAuthStore 舊版拿「可能是舊版 env」當底稿做 read-modify-write,而 writeAuthStore 會重切分片並刪掉多出來的舊分片 ⇒ 底稿若是 「某帳號被建立之前」的版本,那個帳號會在這次寫入中被抹掉且無法還原(secret 是唯一真相源)。 改成先問加速器、再把 env 版與 overlay 版取聯集當底稿;刪除仍有效(fn() 在聯集之後才跑)。 驗證:tsc 與 baseline 同為 23 個既有錯誤(零新增);tests/portal-auth.test.ts 27/27 綠。 stage 實測見交付回報。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R8vF2zS2XpaZjzkC75Fjss
348 lines
15 KiB
TypeScript
348 lines
15 KiB
TypeScript
/**
|
||
* 認證儲存(D61:認證與資料分離)— 門鎖不住在知識資料庫裡
|
||
*
|
||
* leo 2026-08-10 下令(ADR D61 / Leo/arcrun-rag#55):
|
||
* 「登入認證資料要分離⋯⋯**就算只有我一個人存在單獨的 json 檔也好**,
|
||
* 它不能被改資料庫的連結導致無法登入。」
|
||
*
|
||
* 不變量(整份檔案只為這一句存在):
|
||
* **登入所需要的一切,不得存放在任何「會被安裝/遷移重新指向」的地方。**
|
||
*
|
||
* 為什麼家選在 CF Workers per-script Secrets(判斷過程留著,方便日後推翻):
|
||
* - D1 / KV / R2 / Vectorize 全靠 **binding** 指過去,安裝器每次都會重新指一次
|
||
* ⇒ 換家=換鎖。所以「搬到另一顆資料庫」根本不解問題。
|
||
* - Workers Secret **掛在 script 本身**,與 bindings 是兩套資源:
|
||
* `wrangler deploy` 帶新 bindings 重部不會洗掉它(journeys/gemini-key-lost-on-reinstall.md
|
||
* 在 stage 完整重裝 24/24 顆 worker 後 secret 仍在;installer worker.js:1148 亦有同款實證)。
|
||
* - 它是**自足**的:讀出來就是完整的一份 JSON,裡面沒有任何「再去某顆 D1/KV 查一次」的指標。
|
||
* 自足是重點——只要還要回頭查一次,就又被綁回去了。
|
||
* - 不開新 D1(P9:leo 2026-08-07「你建一顆新的 D1,以後就會偷偷溜去那裡建表」)。
|
||
* - 不牴觸 D38「KBDB 三張核心表永不加新的」:本檔是把東西**搬出去**,KBDB 表數不增不減。
|
||
*
|
||
* 容量(2026-08-10 查官方 developers.cloudflare.com/workers/platform/limits/,不是憑記憶):
|
||
* - 每個變數(secret + text 合計)上限 **5 KB**
|
||
* - 每顆 worker 變數數量上限 **64(Free)/ 128(Paid)**,與 CRED_* 共用同一份額度
|
||
* ⇒ 故採「單一 store + 溢位分片」:`ARCRUN_AUTH_STORE`、`ARCRUN_AUTH_STORE_1`、`_2`…
|
||
* 一份 ~4.5 KB 大約裝得下 12–15 個帳號;超過就自動長出下一片。
|
||
* 這是刻意的取捨:**不**做「一個帳號一顆 secret」,因為那會用同一份 64 格的額度去跟
|
||
* workflow credential 搶位子,且沒有任何實例接近這個量級。
|
||
*
|
||
* 寫入路徑:CF Workers Scripts secrets 管理 API(唯寫,讀不回值)。
|
||
* 與 routes/credentials.ts 走**同一支** putWorkerSecret/deleteWorkerSecret,不另造第二套
|
||
* (D36 教訓:AI 天生偏向新增一種做法而非沿用既有的,兩套並存必然漂移)。
|
||
*
|
||
* 讀取路徑:`env` 直接讀——**零網路呼叫**。這正是它比 KBDB 可靠的原因:
|
||
* 登入不再依賴任何外部系統活著。
|
||
*
|
||
* ⚠️ 傳播延遲(誠實限制,mindset §7):更新 secret 會產生 worker 的新版本,
|
||
* **既有 isolate 讀到的仍是舊 env**,要等新版本鋪開。故本檔帶一層 per-isolate 的
|
||
* write-through overlay(AUTH_OVERLAY_TTL_MS),讓「剛改完密碼立刻登入」在同一顆 isolate 上
|
||
* 立即生效;跨 isolate 仍可能有數十秒的落差,這是平台特性,不假裝沒有。
|
||
*/
|
||
import type { Bindings } from '../types';
|
||
import { putWorkerSecret, deleteWorkerSecret } from '../routes/credentials';
|
||
|
||
/** 主分片名;溢位分片為 `${AUTH_STORE_PREFIX}_1`、`_2`… */
|
||
export const AUTH_STORE_PREFIX = 'ARCRUN_AUTH_STORE';
|
||
/** 單片安全上限(官方 5 KB,留 ~10% 給 JSON 結構與 UTF-8 膨脹)。 */
|
||
const SHARD_MAX_BYTES = 4600;
|
||
/** 剛寫完的資料在本 isolate 內優先採信多久(跨 isolate 傳播用)。 */
|
||
const AUTH_OVERLAY_TTL_MS = 180_000;
|
||
/**
|
||
* 「剛寫完」加速器的 KV key 與存活時間。
|
||
*
|
||
* 🔴 為什麼需要它(2026-08-10 stage 演練**實測撞到**,不是預防性設計):
|
||
* 更新 secret 會產生 worker 新版本,**既有 isolate 讀到的還是舊 env**。實測「建好帳號 →
|
||
* 立刻登入」有 **15 秒以上**登不進去,而且那幾次失敗**會被算進 5 次鎖定**
|
||
* ⇒ 安裝精靈「建立帳號 → 馬上登入」會把人鎖在門外 15 分鐘。**這正是本案要根治的病的變種。**
|
||
*
|
||
* 🔑 它**不是**認證的家,只是「新版本還沒鋪開時的臨時快遞」:
|
||
* - 讀取順序永遠是 **secret 優先**;secret 裡查不到/密碼對不上,才回頭問加速器一次
|
||
* - KV 被重裝指到新的空的 → 加速器空 → 退回 secret ⇒ **D61 的不變量不受影響**
|
||
* - 短 TTL:密碼雜湊不長期躺在 KV 裡(舊設計是永久躺著,這比舊的嚴格)
|
||
*/
|
||
const ACCEL_KEY = 'auth_store_recent';
|
||
const ACCEL_TTL_SECONDS = 600;
|
||
/** store 內 user id 前綴——呼叫端據此分辨「這筆住新家還是舊家(KBDB)」。 */
|
||
export const AUTH_ID_PREFIX = 'auth:';
|
||
|
||
export interface AuthUserRecord {
|
||
id: string;
|
||
email: string;
|
||
display_name: string;
|
||
status: string;
|
||
role: string;
|
||
libraries: string[];
|
||
password_hash: string;
|
||
created_at: string;
|
||
updated_at: string;
|
||
}
|
||
|
||
/** console 管理員那一組(原本住 SESSIONS_KV `console:credentials`,重裝就跟著蒸發)。 */
|
||
export interface AuthConsoleRecord {
|
||
email: string;
|
||
salt: string;
|
||
hash: string;
|
||
created_at: string;
|
||
}
|
||
|
||
export interface AuthStoreData {
|
||
version: number;
|
||
console: AuthConsoleRecord | null;
|
||
users: AuthUserRecord[];
|
||
}
|
||
|
||
interface ShardPayload {
|
||
v: number;
|
||
console?: AuthConsoleRecord | null;
|
||
users?: AuthUserRecord[];
|
||
}
|
||
|
||
/** 寫入路徑未就緒(缺 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID,或 CF API 回錯)。 */
|
||
export class AuthStoreWriteError extends Error {}
|
||
|
||
// ── per-isolate overlay(見檔頭「傳播延遲」)─────────────────────────────────────
|
||
let overlay: AuthStoreData | null = null;
|
||
let overlayAt = 0;
|
||
|
||
function emptyStore(): AuthStoreData {
|
||
return { version: 1, console: null, users: [] };
|
||
}
|
||
|
||
function shardNames(env: Bindings): string[] {
|
||
const bag = env as unknown as Record<string, unknown>;
|
||
return Object.keys(bag)
|
||
.filter((k) => k === AUTH_STORE_PREFIX || /^ARCRUN_AUTH_STORE_\d+$/.test(k))
|
||
.filter((k) => typeof bag[k] === 'string' && (bag[k] as string).length > 0)
|
||
.sort((a, b) => shardIndex(a) - shardIndex(b));
|
||
}
|
||
|
||
function shardIndex(name: string): number {
|
||
if (name === AUTH_STORE_PREFIX) return 0;
|
||
return Number.parseInt(name.slice(AUTH_STORE_PREFIX.length + 1), 10) || 0;
|
||
}
|
||
|
||
function shardNameOf(index: number): string {
|
||
return index === 0 ? AUTH_STORE_PREFIX : `${AUTH_STORE_PREFIX}_${index}`;
|
||
}
|
||
|
||
/** 這台實例的 env 裡有沒有認證儲存(不論裡面有沒有帳號)。 */
|
||
export function authStorePresent(env: Bindings): boolean {
|
||
return shardNames(env).length > 0 || (overlay !== null && Date.now() - overlayAt < AUTH_OVERLAY_TTL_MS);
|
||
}
|
||
|
||
/** 寫入路徑是否就緒——缺就誠實回報「不能改密碼」,不假綠。 */
|
||
export function authStoreWritable(env: Bindings): boolean {
|
||
return Boolean(env.CF_SECRETS_API_TOKEN && env.CF_ACCOUNT_ID);
|
||
}
|
||
|
||
/**
|
||
* 讀出完整認證資料。**同步、零網路呼叫**——這就是分離的意義:
|
||
* 登入不依賴 KBDB / D1 / KV 任何一個活著。
|
||
* 壞掉的分片(JSON parse 失敗)誠實跳過,不讓一片損毀鎖死整台實例。
|
||
*/
|
||
export function readAuthStore(env: Bindings): AuthStoreData {
|
||
if (overlay && Date.now() - overlayAt < AUTH_OVERLAY_TTL_MS) return overlay;
|
||
return readAuthStoreFromEnv(env);
|
||
}
|
||
|
||
/**
|
||
* 只讀 `env` 那一版(**跳過 overlay**)。
|
||
*
|
||
* 為什麼要分出這一支(#66 修補的一半):read-modify-write 時,overlay 與 env 兩份都可能
|
||
* 各自「有對方沒有的帳號」——overlay 可能來自加速器(別台 isolate 剛寫的),
|
||
* env 可能是**比加速器更新**的一版(加速器過期、或這顆 isolate 已經吃到新版本)。
|
||
* 只採信其中一份就會把另一份獨有的帳號寫掉,而 secret 是唯一真相源 ⇒ **永久消失**。
|
||
*/
|
||
function readAuthStoreFromEnv(env: Bindings): AuthStoreData {
|
||
const bag = env as unknown as Record<string, unknown>;
|
||
const out = emptyStore();
|
||
for (const name of shardNames(env)) {
|
||
let parsed: ShardPayload | null = null;
|
||
try {
|
||
parsed = JSON.parse(bag[name] as string) as ShardPayload;
|
||
} catch {
|
||
continue; // 損毀的分片跳過(其餘帳號仍登得進去)
|
||
}
|
||
if (!parsed || typeof parsed !== 'object') continue;
|
||
if (parsed.console && !out.console) out.console = parsed.console;
|
||
if (Array.isArray(parsed.users)) {
|
||
for (const u of parsed.users) {
|
||
if (u && typeof u.email === 'string' && typeof u.id === 'string') out.users.push(u);
|
||
}
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** 找一筆帳號(email 比對,大小寫不敏感)。 */
|
||
export function findAuthUserByEmail(env: Bindings, email: string): AuthUserRecord | null {
|
||
const needle = email.trim().toLowerCase();
|
||
return readAuthStore(env).users.find((u) => u.email.toLowerCase() === needle) ?? null;
|
||
}
|
||
|
||
export function findAuthUserById(env: Bindings, id: string): AuthUserRecord | null {
|
||
return readAuthStore(env).users.find((u) => u.id === id) ?? null;
|
||
}
|
||
|
||
/** 判斷一個 record_id 是不是住新家(呼叫端據此決定打 store 還是打 KBDB)。 */
|
||
export function isAuthStoreId(recordId: string): boolean {
|
||
return recordId.startsWith(AUTH_ID_PREFIX);
|
||
}
|
||
|
||
export function newAuthUserId(): string {
|
||
const arr = new Uint8Array(12);
|
||
crypto.getRandomValues(arr);
|
||
return AUTH_ID_PREFIX + Array.from(arr).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||
}
|
||
|
||
/**
|
||
* 把整份認證資料切片後寫回 Workers Secrets。
|
||
* 分片規則:console 一定放第 0 片;users 依序塞,塞不下就開下一片。
|
||
* 多出來的舊分片會被刪掉(避免「刪了帳號卻還留在舊分片裡復活」)。
|
||
*/
|
||
export async function writeAuthStore(env: Bindings, data: AuthStoreData): Promise<void> {
|
||
if (!authStoreWritable(env)) {
|
||
throw new AuthStoreWriteError(
|
||
'這台實例還不能寫入認證儲存(缺 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID)。' +
|
||
'認證分離需要這兩項才寫得進 Workers Secrets——請重新執行安裝/更新讓它就緒。',
|
||
);
|
||
}
|
||
|
||
const shards: string[] = [];
|
||
let current: ShardPayload = { v: 1, console: data.console ?? null, users: [] };
|
||
for (const u of data.users) {
|
||
const trial: ShardPayload = { ...current, users: [...(current.users ?? []), u] };
|
||
const size = new TextEncoder().encode(JSON.stringify(trial)).length;
|
||
if (size > SHARD_MAX_BYTES && (current.users ?? []).length > 0) {
|
||
shards.push(JSON.stringify(current));
|
||
current = { v: 1, users: [u] };
|
||
} else {
|
||
current = trial;
|
||
}
|
||
}
|
||
shards.push(JSON.stringify(current));
|
||
|
||
// 單筆帳號本身就超過一片=真的塞不下,誠實擋下(不靜默丟資料)
|
||
for (const s of shards) {
|
||
if (new TextEncoder().encode(s).length > 5000) {
|
||
throw new AuthStoreWriteError('單筆認證資料超過 Cloudflare 變數 5 KB 上限,無法寫入。');
|
||
}
|
||
}
|
||
|
||
const existing = shardNames(env);
|
||
for (let i = 0; i < shards.length; i++) {
|
||
await putWorkerSecret(env, shardNameOf(i), shards[i]);
|
||
}
|
||
for (const name of existing) {
|
||
if (shardIndex(name) >= shards.length) await deleteWorkerSecret(env, name);
|
||
}
|
||
|
||
overlay = { version: 1, console: data.console ?? null, users: [...data.users] };
|
||
overlayAt = Date.now();
|
||
|
||
// 加速器(非真相源,見 ACCEL_KEY 註解):讓別的 isolate 在新版本鋪開前也讀得到剛寫的東西。
|
||
// 寫失敗完全不影響正確性——最多就是回到「等 secret 傳播」的狀態,故吞掉例外。
|
||
try {
|
||
await env.SESSIONS_KV.put(
|
||
ACCEL_KEY,
|
||
JSON.stringify({ written_at: Date.now(), data: overlay }),
|
||
{ expirationTtl: ACCEL_TTL_SECONDS },
|
||
);
|
||
} catch {
|
||
/* 加速器是加分項,不是必要條件 */
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 「secret 裡查不到/密碼對不上」時再問一次加速器(見 ACCEL_KEY)。
|
||
* 命中就把它放進本 isolate 的 overlay,呼叫端重跑一次同樣的查找即可。
|
||
* 回傳是否真的拿到比較新的資料(沒有就不必重跑)。
|
||
*/
|
||
export async function hydrateFromAccelerator(env: Bindings): Promise<boolean> {
|
||
let raw: string | null = null;
|
||
try {
|
||
raw = await env.SESSIONS_KV.get(ACCEL_KEY);
|
||
} catch {
|
||
return false;
|
||
}
|
||
if (!raw) return false;
|
||
try {
|
||
const parsed = JSON.parse(raw) as { written_at?: number; data?: AuthStoreData };
|
||
if (!parsed?.data || !Array.isArray(parsed.data.users)) return false;
|
||
if (overlay && overlayAt >= (parsed.written_at ?? 0)) return false; // 本地的更新
|
||
overlay = { version: 1, console: parsed.data.console ?? null, users: parsed.data.users };
|
||
overlayAt = parsed.written_at ?? Date.now();
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 這台實例「剛剛才寫過認證儲存」嗎——亦即現在是不是**傳播空窗期**。
|
||
*
|
||
* 🔴 #66 用它分辨兩件長得一樣、後果完全相反的事:
|
||
* - 「查不到這個帳號」= 帳號真的被刪了 → 該擋(401)
|
||
* - 「查不到這個帳號」= secret 新版本還沒鋪到這顆 isolate → **不該擋,更不該刪 session**
|
||
* 加速器的 key 只在寫入後存活 `ACCEL_TTL_SECONDS`,它存在就代表「最近有人動過認證儲存」。
|
||
* 讀不到(KV 掛了/沒設)⇒ 回 false,退回舊行為,不會比現在更糟。
|
||
*/
|
||
export async function authStoreRecentlyWritten(env: Bindings): Promise<boolean> {
|
||
try {
|
||
return Boolean(await env.SESSIONS_KV.get(ACCEL_KEY));
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/** 兩份 store 取聯集:同一個 id 以 `updated_at` 新者為準;只在一邊出現的一律保留。 */
|
||
function unionStores(a: AuthStoreData, b: AuthStoreData): AuthStoreData {
|
||
const byId = new Map<string, AuthUserRecord>();
|
||
for (const u of [...a.users, ...b.users]) {
|
||
const prev = byId.get(u.id);
|
||
if (!prev || (u.updated_at ?? '') >= (prev.updated_at ?? '')) byId.set(u.id, u);
|
||
}
|
||
return { version: 1, console: a.console ?? b.console ?? null, users: [...byId.values()] };
|
||
}
|
||
|
||
/**
|
||
* 讀出來 → 改 → 寫回去(同一支,避免各處自己拼 read/modify/write)。
|
||
*
|
||
* 🔴 #66:**改之前先把手上這份補齊**。舊版直接 `readAuthStore(env)` 當底稿,而 `writeAuthStore`
|
||
* 會把整份重切分片並刪掉多出來的舊分片 ⇒ 若底稿是「某個帳號被建立之前」的版本,
|
||
* 那個帳號會在這次寫入中**被抹掉,且再也回不來**(secret 是唯一真相源,沒有第二份可還原)。
|
||
* 這正是「改一次密碼=有人被鎖在門外」的另一半病因。
|
||
*
|
||
* 補法:先問一次加速器,再把 env 版與 overlay 版**取聯集**當底稿——
|
||
* 兩邊獨有的帳號都留下來;刪除仍然有效,因為 `fn()` 是在聯集**之後**才跑。
|
||
*/
|
||
export async function mutateAuthStore(
|
||
env: Bindings,
|
||
fn: (data: AuthStoreData) => void | Promise<void>,
|
||
): Promise<AuthStoreData> {
|
||
await hydrateFromAccelerator(env);
|
||
const next = unionStores(readAuthStore(env), readAuthStoreFromEnv(env));
|
||
await fn(next);
|
||
await writeAuthStore(env, next);
|
||
return next;
|
||
}
|
||
|
||
/** 診斷用(/health、/console/auth-status、daemon diagnostics 共用同一份判讀)。 */
|
||
export function authStoreStatus(env: Bindings): {
|
||
present: boolean;
|
||
writable: boolean;
|
||
users: number;
|
||
console_configured: boolean;
|
||
shards: number;
|
||
} {
|
||
const data = readAuthStore(env);
|
||
return {
|
||
present: authStorePresent(env),
|
||
writable: authStoreWritable(env),
|
||
users: data.users.length,
|
||
console_configured: Boolean(data.console),
|
||
shards: shardNames(env).length,
|
||
};
|
||
}
|