From 417d69ceb3714ae6729957437f7486a7b59184c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 13:22:34 +0000 Subject: [PATCH] =?UTF-8?q?fix(portal):=20=E5=82=B3=E6=92=AD=E7=A9=BA?= =?UTF-8?q?=E7=AA=97=E6=9C=9F=E4=B8=8D=E5=86=8D=E9=8A=B7=E6=AF=80=20sessio?= =?UTF-8?q?n=E2=80=94=E2=80=94=E8=AE=80=E4=B8=8D=E5=88=B0=20=E2=89=A0=20?= =?UTF-8?q?=E9=80=99=E5=80=8B=E4=BA=BA=E4=B8=8D=E5=AD=98=E5=9C=A8=EF=BC=88?= =?UTF-8?q?arcrun-rag#66=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01R8vF2zS2XpaZjzkC75Fjss --- console-ui/public/portal/index.html | 21 +++++++- cypher-executor/src/lib/portal-auth-store.ts | 54 ++++++++++++++++++-- cypher-executor/src/routes/portal.ts | 40 ++++++++++++++- 3 files changed, 109 insertions(+), 6 deletions(-) diff --git a/console-ui/public/portal/index.html b/console-ui/public/portal/index.html index 6f92768..b3571c5 100644 --- a/console-ui/public/portal/index.html +++ b/console-ui/public/portal/index.html @@ -821,7 +821,20 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { fetch(API_BASE + '/portal/session', { headers: authHeaders() }) .then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) .then(function (x) { - if (!x.ok) { dropSession(); return; } + // 🔴 arcrun-rag#66:**只有 401 才算「你被登出了」**。 + // 舊版是 `if (!x.ok) dropSession()` = 任何非 2xx 都清掉 token—— + // 包含改完密碼後那幾十秒的 503(認證 secret 正在鋪開)與 502(KBDB 暫時不可達)。 + // 那正是 leo 08-10「改完密碼、重新整理就回不去」的最後一哩: + // 後端就算不刪 KV,前端自己把鑰匙丟了,結果一樣。 + if (x.status === 401) { dropSession(); return; } + if (!x.ok) { + // 暫時性故障:留著 session,告訴他這是暫時的、下一步做什麼。 + showAuth(); + $('login-status').textContent = (x.d && x.d.code === 'auth_store_propagating') + ? '認證資料正在更新中(通常幾十秒),請稍候重新整理——你並沒有被登出。' + : '服務暫時不可用,請稍後重新整理(你的登入沒有失效)。'; + return; + } S.profile = x.d; showApp(); }) @@ -1185,6 +1198,12 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { } // 任何 data 請求收到 401 → session 失效 → 回登入殼 + // + // 🔴 arcrun-rag#66 的前端那一半:後端在「認證資料正在鋪開」時改回 503 + // `auth_store_propagating`(不再回 401、也不再刪 KV 那筆 session)。 + // 這裡**必須跟著只認 401**——若前端把任何錯誤都當登出,後端不刪也沒用: + // 使用者手上的 token 會被自己的瀏覽器丟掉,症狀跟被踢出去一模一樣。 + // (503 由各呼叫點自己顯示錯誤訊息,session 原封不動。) function guard401(status) { if (status === 401) { dropSession(); return true; } return false; diff --git a/cypher-executor/src/lib/portal-auth-store.ts b/cypher-executor/src/lib/portal-auth-store.ts index 2ead92a..dec36cd 100644 --- a/cypher-executor/src/lib/portal-auth-store.ts +++ b/cypher-executor/src/lib/portal-auth-store.ts @@ -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; const out = emptyStore(); for (const name of shardNames(env)) { @@ -268,13 +279,50 @@ export async function hydrateFromAccelerator(env: Bindings): Promise { } } -/** 讀出來 → 改 → 寫回去(同一支,避免各處自己拼 read/modify/write)。 */ +/** + * 這台實例「剛剛才寫過認證儲存」嗎——亦即現在是不是**傳播空窗期**。 + * + * 🔴 #66 用它分辨兩件長得一樣、後果完全相反的事: + * - 「查不到這個帳號」= 帳號真的被刪了 → 該擋(401) + * - 「查不到這個帳號」= secret 新版本還沒鋪到這顆 isolate → **不該擋,更不該刪 session** + * 加速器的 key 只在寫入後存活 `ACCEL_TTL_SECONDS`,它存在就代表「最近有人動過認證儲存」。 + * 讀不到(KV 掛了/沒設)⇒ 回 false,退回舊行為,不會比現在更糟。 + */ +export async function authStoreRecentlyWritten(env: Bindings): Promise { + 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(); + 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, ): Promise { - 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; diff --git a/cypher-executor/src/routes/portal.ts b/cypher-executor/src/routes/portal.ts index ddbfbd8..b19332b 100644 --- a/cypher-executor/src/routes/portal.ts +++ b/cypher-executor/src/routes/portal.ts @@ -33,6 +33,7 @@ import { storeCredential, hasCredential } from './credentials'; // KBDB 只保留為「舊實例的既有帳號」回退讀路徑,且讀到就順手搬進新家(見 promoteLegacyUser)。 import { AuthStoreWriteError, + authStoreRecentlyWritten, authStoreStatus, findAuthUserByEmail, findAuthUserById, @@ -413,6 +414,23 @@ export type AuthResult = { ok: true; user: AuthedUser } | { ok: false; res: Resp /** * portal session 閘:token → KV → record_id → **回讀 record**(唯一真相源)→ status=active。 * 停用即時生效(design §4.3);停用/孤兒 session 順手刪 KV(best-effort,正確性不依賴它)。 + * + * 🔴 `Leo/arcrun-rag#66`(2026-08-10,leo 本人被鎖在 stage 外面的那條): + * **「這一瞬間讀不到」不可以觸發不可逆的動作。** + * 認證的家是 CF Workers Secret,改它(改密碼/建帳號/停用)會產生 worker 新版本, + * **既有 isolate 讀到的還是舊 env**(`#55` 實測 ≥15 秒)。舊版在那個空窗裡: + * ① `getRecordById` 讀不到 → ② **直接把 session 從 KV 刪掉** → ③ 回 401 + * ⇒ 帶著一個**完全有效的 token**,登入狀態被當場銷毀,等 secret 鋪開也回不來。 + * `#55` 補的「讀不到就再問一次加速器」只加在登入路徑(`findAndVerifyUser`),這道門沒有。 + * + * 三段修法(缺一不可): + * 1. **先問一次加速器再判定**——與登入路徑同一招,同一支 `hydrateFromAccelerator`。 + * 2. **永不因「讀不到」刪 session**。刪是 best-effort 清潔工,而它清掉的是使用者唯一的 + * 憑據;KV 的 TTL 本來就會回收,這件事沒有非做不可的理由。 + * 3. 仍然讀不到且**正在傳播空窗**(加速器 key 還在)→ 回 **503 `auth_store_propagating`**, + * 不是 401。理由在前端:portal 的 `guard401()` 一看到 401 就清 localStorage 踢回登入頁 + * ⇒ 就算 KV 那筆還在,使用者手上的 token 也被自己的瀏覽器丟掉了。 + * **後端不刪、前端不丟,這件事才算真的修好。** */ export async function requirePortalUser(c: Context<{ Bindings: Bindings }>): Promise { const token = bearerToken(c); @@ -426,15 +444,33 @@ export async function requirePortalUser(c: Context<{ Bindings: Bindings }>): Pro /* fallthrough */ } if (!recordId) { + // 這一筆 session 的內容本身壞掉=確定的事實(不是讀不到),刪它是對的。 await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`); return { ok: false, res: c.json({ error: 'session 無效或已過期' }, 401) }; } - const rec = await getRecordById(c.env, recordId); + let rec = await getRecordById(c.env, recordId); + if (!rec && (await hydrateFromAccelerator(c.env))) { + rec = await getRecordById(c.env, recordId); // ①:與登入路徑同一招,再問一次加速器 + } if (!rec) { - await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`); + // ③:分辨「傳播空窗」與「帳號真的沒了」——前者不可以把人踢出去。 + if (await authStoreRecentlyWritten(c.env)) { + return { + ok: false, + res: c.json( + { + error: '認證資料正在更新中(Cloudflare 正在鋪開新版本),請稍候幾秒再試——你並沒有被登出。', + code: 'auth_store_propagating', + }, + 503, + ), + }; + } + // ②:查無此帳號(可能真的被刪了)→ 擋下即可,**不刪 session**(KV TTL 自己會回收)。 return { ok: false, res: c.json({ error: 'session 無效或已過期' }, 401) }; } if ((rec.values.status ?? '') !== 'active') { + // 讀得到 record = 確定的事實,停用要即時生效,刪 session 是對的。 await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`); return { ok: false, res: c.json({ error: '帳號已停用' }, 403) }; }