Files
Arcrun/cypher-executor/src/lib/portal-auth-store.ts
T
Claude 417d69ceb3 fix(portal): 傳播空窗期不再銷毀 session——讀不到 ≠ 這個人不存在(arcrun-rag#66)
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
2026-08-10 13:22:34 +00:00

348 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 認證儲存(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 查一次」的指標。
* 自足是重點——只要還要回頭查一次,就又被綁回去了。
* - 不開新 D1P9leo 2026-08-07「你建一顆新的 D1,以後就會偷偷溜去那裡建表」)。
* - 不牴觸 D38「KBDB 三張核心表永不加新的」:本檔是把東西**搬出去**,KBDB 表數不增不減。
*
* 容量(2026-08-10 查官方 developers.cloudflare.com/workers/platform/limits/,不是憑記憶):
* - 每個變數(secret + text 合計)上限 **5 KB**
* - 每顆 worker 變數數量上限 **64Free/ 128Paid**,與 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 overlayAUTH_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,
};
}