/** * 認證儲存(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; 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; 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 { 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 { 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 { 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 { 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, }; }