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
This commit is contained in:
@@ -143,7 +143,18 @@ export function authStoreWritable(env: Bindings): boolean {
|
||||
*/
|
||||
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)) {
|
||||
@@ -268,13 +279,50 @@ export async function hydrateFromAccelerator(env: Bindings): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 讀出來 → 改 → 寫回去(同一支,避免各處自己拼 read/modify/write)。 */
|
||||
/**
|
||||
* 這台實例「剛剛才寫過認證儲存」嗎——亦即現在是不是**傳播空窗期**。
|
||||
*
|
||||
* 🔴 #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> {
|
||||
const data = readAuthStore(env);
|
||||
const next: AuthStoreData = { version: 1, console: data.console, users: [...data.users] };
|
||||
await hydrateFromAccelerator(env);
|
||||
const next = unionStores(readAuthStore(env), readAuthStoreFromEnv(env));
|
||||
await fn(next);
|
||||
await writeAuthStore(env, next);
|
||||
return next;
|
||||
|
||||
Reference in New Issue
Block a user