feat(auth): 認證與資料分離——搬動知識資料時登入不再跟著壞掉(D61 / arcrun-rag#55)
leo 2026-08-10 下令:「登入認證資料要分離⋯⋯就算只有我一個人存在單獨的 json 檔也好,
它不能被改資料庫的連結導致無法登入。」「昨天不能登入 portal,今天不能登入 mcp,
這根本就是一個問題。」
病:portal 帳號住 KBDB(owner_id = {CONSOLE_TENANT}::portal),console 管理員帳密住
SESSIONS_KV。兩者都靠 binding 指過去,重裝/遷移一定會被重新指一次 ⇒ 保險箱的鑰匙
放在保險箱裡。2026-08-09 leo 資料一個位元組都沒動,卻被鎖在門外。
修:認證搬到 CF Workers per-script Secrets(掛在 script 上,與 bindings 兩套資源,
重部不會洗掉;journeys/gemini-key-lost-on-reinstall.md 與 installer worker.js:1148 皆有實證)。
- 新增 lib/portal-auth-store.ts:自足的 JSON,讀取零網路呼叫,>4.6KB 自動溢位分片
- portal.ts 的帳號讀寫全部改走它;KBDB 只留為舊實例的回退讀路徑,登入成功順手搬過去
- console-auth.ts 的第二份認證資料同樣搬離 KV
- 不牴觸 D38:KBDB 三張核心表不增不減,本案是把東西搬出去
- 沿用 credentials.ts 既有的 putWorkerSecret/deleteWorkerSecret,不另造第二套寫入路徑(D36)
明顯失敗(把 #10「寧可明顯失敗,不要靜默錯置」套到門鎖上):
- 「這台實例讀不到任何登入資料」回 503 + code=auth_store_empty,且**不計入 5 次鎖定**
(08-09 leo 就是被系統自己的誤判鎖了 15 分鐘)
- /console/setup 遇既有帳號改說「你剛才輸入的密碼沒有被採用」,不再只說「已設定過」
- /health 與 /console/auth-status 吐 auth_store 狀態(住哪、寫不寫得進去)
stage 實測撞到並修掉的坑:改 secret 會產生 worker 新版本,既有 isolate 讀到的還是舊 env
⇒ 「建好帳號立刻登入」有 15 秒以上 401,還被算進鎖定。加一層短 TTL 的 KV 加速器
(非真相源,只在 secret 查不到/密碼對不上時問一次),換 KV 不影響不變量。
驗收:stage(youlin)把知識資料庫換成另一顆空的 + 換租戶代號 + SESSIONS_KV 換成空的,
三樣一起換之後 portal / MCP /authorize / console 三條登入路徑仍全綠(複跑 3 次)。
對照組(舊版程式碼同樣換庫):登入回「email 或密碼錯誤」,5 次後鎖 15 分鐘。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* 認證儲存(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;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/** 讀出來 → 改 → 寫回去(同一支,避免各處自己拼 read/modify/write)。 */
|
||||
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 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,
|
||||
};
|
||||
}
|
||||
@@ -22,6 +22,19 @@
|
||||
*/
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
// D61(ADR D61 / Leo/arcrun-rag#55):這組管理員帳密原本住 SESSIONS_KV(`console:credentials`,
|
||||
// 而且沒有 TTL)——KV 是靠 binding 指過去的,重裝會被指到**新建的空 KV** ⇒ 帳密憑空消失。
|
||||
// 這是「KV=暫存、非長期真相源」第三次被違反,而這一次違反的是大門的鎖。
|
||||
// 現改存進認證儲存(Workers Secrets,不靠 binding);舊 KV 只保留為回退讀路徑,
|
||||
// 讀到就順手搬過去(見 loadCredentials)。
|
||||
import {
|
||||
AuthStoreWriteError,
|
||||
authStoreStatus,
|
||||
hydrateFromAccelerator,
|
||||
mutateAuthStore,
|
||||
readAuthStore,
|
||||
type AuthConsoleRecord,
|
||||
} from '../lib/portal-auth-store';
|
||||
|
||||
export const consoleAuthRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -70,16 +83,72 @@ function tenantOf(c: { env: Bindings }): string {
|
||||
return c.env.CONSOLE_TENANT || 'leo';
|
||||
}
|
||||
|
||||
// ── D61:帳密的家 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 讀出 console 管理員帳密。**新家(Workers Secrets)優先**;沒有才回退舊家(KV),
|
||||
* 且一旦從舊家讀到就順手搬過去(best-effort,搬不動不影響本次登入)。
|
||||
*/
|
||||
async function loadCredentials(env: Bindings): Promise<{ creds: StoredCredentials | null; source: 'secrets' | 'legacy-kv' | 'none' }> {
|
||||
let fromStore = readAuthStore(env).console;
|
||||
if (!fromStore && (await hydrateFromAccelerator(env))) {
|
||||
// 剛設定完帳密、secret 的新版本還沒鋪到這顆 isolate(實測有 15 秒以上的窗口)
|
||||
// → 先問一次加速器,免得「剛設好就說你沒設過」。細節見 lib 的 ACCEL_KEY 註解。
|
||||
fromStore = readAuthStore(env).console;
|
||||
}
|
||||
if (fromStore) return { creds: fromStore, source: 'secrets' };
|
||||
|
||||
const raw = await env.SESSIONS_KV.get(CREDS_KEY);
|
||||
if (!raw) return { creds: null, source: 'none' };
|
||||
let legacy: StoredCredentials | null = null;
|
||||
try {
|
||||
legacy = JSON.parse(raw) as StoredCredentials;
|
||||
} catch {
|
||||
return { creds: null, source: 'none' };
|
||||
}
|
||||
try {
|
||||
await mutateAuthStore(env, (data) => {
|
||||
if (!data.console) data.console = legacy as AuthConsoleRecord;
|
||||
});
|
||||
} catch {
|
||||
/* 搬不動就照舊用 KV 這份(狀態看 /health 的 auth_store) */
|
||||
}
|
||||
return { creds: legacy, source: 'legacy-kv' };
|
||||
}
|
||||
|
||||
/** 寫入 console 管理員帳密——**只寫新家**,不再寫 KV(寫回去等於把病種回土裡)。 */
|
||||
async function saveCredentials(env: Bindings, record: StoredCredentials): Promise<void> {
|
||||
await mutateAuthStore(env, (data) => {
|
||||
data.console = record;
|
||||
});
|
||||
}
|
||||
|
||||
// GET /console/auth-status — 前端用來決定顯示「首次設定」還是「登入」表單。不洩漏 email。
|
||||
consoleAuthRouter.get('/console/auth-status', async (c) => {
|
||||
const existing = await c.env.SESSIONS_KV.get(CREDS_KEY);
|
||||
return c.json({ configured: !!existing });
|
||||
const { creds, source } = await loadCredentials(c.env);
|
||||
// D61:多回一個 auth_store 區塊——「認證住在哪、寫不寫得進去」要在實例自己這一側看得出來,
|
||||
// 不是等用戶登不進去才發現(#10「寧可明顯失敗,不要靜默錯置」)。
|
||||
return c.json({ configured: !!creds, credentials_source: source, auth_store: authStoreStatus(c.env) });
|
||||
});
|
||||
|
||||
// POST /console/setup — 首次設定帳密(body: {email, password})。已設定過 → 409(不可覆蓋,防外人搶注)。
|
||||
consoleAuthRouter.post('/console/setup', async (c) => {
|
||||
const existing = await c.env.SESSIONS_KV.get(CREDS_KEY);
|
||||
if (existing) return c.json({ error: '已設定過帳密,請改用登入;要換帳密請用 /console/setup/reset(需舊密碼)' }, 409);
|
||||
const { creds: existing } = await loadCredentials(c.env);
|
||||
if (existing) {
|
||||
// D61 明顯失敗:舊版只說「已設定過」,**沒說剛才填的那組密碼被整個丟掉了**——
|
||||
// 用戶(含安裝精靈裡的 leo)以為自己剛設好了新密碼,其實從頭到尾沒有被採用過。
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
'這台實例已經有管理員帳密了,**你剛才輸入的密碼沒有被採用**,目前的密碼仍是當初設定的那一組。' +
|
||||
'要用舊密碼登入,或用 /console/setup/reset(需要舊密碼)換一組。',
|
||||
code: 'already_configured',
|
||||
password_applied: false,
|
||||
reset_path: '/console/setup/reset',
|
||||
},
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const email = (body?.email ?? '').trim();
|
||||
@@ -90,7 +159,13 @@ consoleAuthRouter.post('/console/setup', async (c) => {
|
||||
const salt = randomHex(16);
|
||||
const hash = await hashPassword(password, salt);
|
||||
const record: StoredCredentials = { email: email.toLowerCase(), salt, hash, created_at: new Date().toISOString() };
|
||||
await c.env.SESSIONS_KV.put(CREDS_KEY, JSON.stringify(record));
|
||||
try {
|
||||
await saveCredentials(c.env, record);
|
||||
} catch (e) {
|
||||
// 寫不進去就誠實回報(不假綠:舊版寫 KV 幾乎不會失敗,於是沒人處理過這條路)
|
||||
const msg = e instanceof AuthStoreWriteError ? e.message : String(e);
|
||||
return c.json({ error: `帳密沒有存起來:${msg}`, code: 'auth_store_not_writable' }, 502);
|
||||
}
|
||||
|
||||
const token = randomHex(32);
|
||||
await c.env.SESSIONS_KV.put(`${SESSION_PREFIX}${token}`, JSON.stringify({ created_at: Date.now() }), {
|
||||
@@ -101,9 +176,8 @@ consoleAuthRouter.post('/console/setup', async (c) => {
|
||||
|
||||
// POST /console/setup/reset — 換帳密(body: {current_password, email, password})。需驗舊密碼,防外人重設。
|
||||
consoleAuthRouter.post('/console/setup/reset', async (c) => {
|
||||
const raw = await c.env.SESSIONS_KV.get(CREDS_KEY);
|
||||
if (!raw) return c.json({ error: '尚未設定過,請用 /console/setup' }, 400);
|
||||
const existing = JSON.parse(raw) as StoredCredentials;
|
||||
const { creds: existing } = await loadCredentials(c.env);
|
||||
if (!existing) return c.json({ error: '尚未設定過,請用 /console/setup' }, 400);
|
||||
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const currentPassword = body?.current_password ?? '';
|
||||
@@ -118,23 +192,48 @@ consoleAuthRouter.post('/console/setup/reset', async (c) => {
|
||||
const salt = randomHex(16);
|
||||
const hash = await hashPassword(password, salt);
|
||||
const record: StoredCredentials = { email: email.toLowerCase(), salt, hash, created_at: existing.created_at };
|
||||
await c.env.SESSIONS_KV.put(CREDS_KEY, JSON.stringify(record));
|
||||
try {
|
||||
await saveCredentials(c.env, record);
|
||||
} catch (e) {
|
||||
const msg = e instanceof AuthStoreWriteError ? e.message : String(e);
|
||||
return c.json({ error: `新帳密沒有存起來:${msg}`, code: 'auth_store_not_writable' }, 502);
|
||||
}
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
// POST /console/login — body: {email, password}。成功 → session token(localStorage 存這個,不存密碼)。
|
||||
consoleAuthRouter.post('/console/login', async (c) => {
|
||||
const raw = await c.env.SESSIONS_KV.get(CREDS_KEY);
|
||||
if (!raw) return c.json({ error: '尚未設定帳密,請先完成首次設定' }, 400);
|
||||
const existing = JSON.parse(raw) as StoredCredentials;
|
||||
const { creds: existing } = await loadCredentials(c.env);
|
||||
if (!existing) {
|
||||
// D61 明顯失敗:這是「這台實例讀不到認證資料」,不是「你帳密打錯」
|
||||
return c.json(
|
||||
{
|
||||
error: '這台實例還沒有管理員帳密(或讀不到)——不是密碼錯。請先完成首次設定。',
|
||||
code: 'auth_store_empty',
|
||||
auth_store: authStoreStatus(c.env),
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const email = (body?.email ?? '').trim().toLowerCase();
|
||||
const password = body?.password ?? '';
|
||||
if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400);
|
||||
|
||||
const hash = await hashPassword(password, existing.salt);
|
||||
if (email !== existing.email || hash !== existing.hash) {
|
||||
let creds = existing;
|
||||
let hash = await hashPassword(password, creds.salt);
|
||||
if (email !== creds.email || hash !== creds.hash) {
|
||||
// D61:剛改完帳密、secret 新版本還沒鋪開的窗口 → 問一次加速器再判失敗
|
||||
if (await hydrateFromAccelerator(c.env)) {
|
||||
const again = (await loadCredentials(c.env)).creds;
|
||||
if (again) {
|
||||
creds = again;
|
||||
hash = await hashPassword(password, creds.salt);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (email !== creds.email || hash !== creds.hash) {
|
||||
return c.json({ error: 'email 或密碼錯誤' }, 401);
|
||||
}
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ function validSensitivity(s: unknown): s is 'standard' | 'high' {
|
||||
* 呼叫 CF Workers Scripts secrets 管理 API,把明文值存進本 worker 的 per-script secret。
|
||||
* 唯寫:這支 API 不回傳任何既有 secret 的值,只能 create/update/delete/list 名字(D19 對齊)。
|
||||
*/
|
||||
async function putWorkerSecret(env: Bindings, secretRef: string, value: string): Promise<void> {
|
||||
export async function putWorkerSecret(env: Bindings, secretRef: string, value: string): Promise<void> {
|
||||
if (!env.CF_SECRETS_API_TOKEN || !env.CF_ACCOUNT_ID) {
|
||||
throw new Error(
|
||||
'此 worker 缺 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID 設定,寫入路徑未就緒(見 ' +
|
||||
@@ -122,7 +122,7 @@ async function putWorkerSecret(env: Bindings, secretRef: string, value: string):
|
||||
* 呼叫 CF Workers Scripts secrets 管理 API 刪除一個 per-script secret(T9 治理端點用)。
|
||||
* 404(本來就不存在)視為成功(冪等刪除,呼叫端可能已被清過)。
|
||||
*/
|
||||
async function deleteWorkerSecret(env: Bindings, secretRef: string): Promise<void> {
|
||||
export async function deleteWorkerSecret(env: Bindings, secretRef: string): Promise<void> {
|
||||
if (!env.CF_SECRETS_API_TOKEN || !env.CF_ACCOUNT_ID) {
|
||||
throw new Error('此 worker 缺 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID 設定,刪除路徑未就緒');
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { authStoreStatus } from '../lib/portal-auth-store';
|
||||
|
||||
export const healthRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -10,11 +11,17 @@ export const healthRouter = new Hono<{ Bindings: Bindings }>();
|
||||
// 只是沒有人把它吐出來)。修=誠實回報本實例的 bundle 版本。
|
||||
// 未注入(本地 dev/很舊的實例)就省略該欄——daemon 對空字串仍判 stale,
|
||||
// 那是**正確的**(真的是老實例,該更新)。
|
||||
// D61(ADR D61 / Leo/arcrun-rag#55):多吐一個 `auth_store`——「認證住哪、寫不寫得進去」
|
||||
// 要在實例自己這一側就看得出來,不是等用戶登不進去才發現(#10「寧可明顯失敗」)。
|
||||
// 只回統計不回內容(帳號數/有沒有 console 帳密/分片數),不洩漏任何 email 或雜湊。
|
||||
// bundle_version 的既有行為不動(未注入就省略該欄——daemon 對空字串判 stale 是正確的)。
|
||||
healthRouter.get('/health', (c) => {
|
||||
const bundleVersion = c.env.ARCRUN_BUNDLE_VERSION;
|
||||
return c.json(
|
||||
bundleVersion ? { ok: true, bundle_version: bundleVersion } : { ok: true },
|
||||
);
|
||||
return c.json({
|
||||
ok: true,
|
||||
...(bundleVersion ? { bundle_version: bundleVersion } : {}),
|
||||
auth_store: authStoreStatus(c.env),
|
||||
});
|
||||
});
|
||||
|
||||
healthRouter.get('/', (c) =>
|
||||
|
||||
@@ -28,6 +28,21 @@ import { PORTAL_TEMPLATE_SEEDS } from '../lib/portal-seeds';
|
||||
// arcrun-rag#10:/portal/admin/ai 存 Gemini key 走 credentials.ts 的**唯一**寫入路徑,
|
||||
// 不在 portal 這層另造第二套儲存(D36:值進 Workers Secret,D1 只留 ref)。
|
||||
import { storeCredential, hasCredential } from './credentials';
|
||||
// D61(Leo/arcrun-rag#55,ADR D61):**帳號不再住知識資料庫**。
|
||||
// 讀寫一律先走 lib/portal-auth-store(CF Workers Secrets,不靠任何 binding),
|
||||
// KBDB 只保留為「舊實例的既有帳號」回退讀路徑,且讀到就順手搬進新家(見 promoteLegacyUser)。
|
||||
import {
|
||||
AuthStoreWriteError,
|
||||
authStoreStatus,
|
||||
findAuthUserByEmail,
|
||||
findAuthUserById,
|
||||
hydrateFromAccelerator,
|
||||
isAuthStoreId,
|
||||
mutateAuthStore,
|
||||
newAuthUserId,
|
||||
readAuthStore,
|
||||
type AuthUserRecord,
|
||||
} from '../lib/portal-auth-store';
|
||||
|
||||
export const portalRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -82,6 +97,10 @@ export async function run(c: Context<{ Bindings: Bindings }>, fn: () => Promise<
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
// D61:認證儲存寫不進去要**看得出來是這件事**(不是 KBDB 的錯,也不是密碼的錯)
|
||||
if (e instanceof AuthStoreWriteError) {
|
||||
return c.json({ error: `認證儲存寫入失敗:${e.message}`, code: 'auth_store_not_writable' }, 502);
|
||||
}
|
||||
if (e instanceof KbdbError) return c.json({ error: `KBDB 不可達或回錯:${e.message}` }, 502);
|
||||
throw e;
|
||||
}
|
||||
@@ -152,8 +171,67 @@ export async function ensurePortalTemplates(
|
||||
return { created, existing, errors };
|
||||
}
|
||||
|
||||
/** email → user record_id(design §2.3 head entry O(1) 查找:page_name=email 走 index)。 */
|
||||
// ── D61 認證儲存 ⇄ PortalRecord 轉換(呼叫端一律只認 PortalRecord,不必分辨住哪)─────
|
||||
|
||||
function authUserToRecord(u: AuthUserRecord): PortalRecord {
|
||||
return {
|
||||
record_id: u.id,
|
||||
template_id: USER_TEMPLATE,
|
||||
values: {
|
||||
email: u.email,
|
||||
display_name: u.display_name,
|
||||
status: u.status,
|
||||
role: u.role,
|
||||
password_hash: u.password_hash,
|
||||
libraries: JSON.stringify(u.libraries ?? []),
|
||||
created_at: u.created_at,
|
||||
updated_at: u.updated_at,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function recordValuesToAuthUser(id: string, v: Record<string, string>): AuthUserRecord {
|
||||
return {
|
||||
id,
|
||||
email: (v.email ?? '').toLowerCase(),
|
||||
display_name: v.display_name ?? '',
|
||||
status: v.status ?? 'active',
|
||||
role: v.role ?? 'user',
|
||||
libraries: parseLibraries(v.libraries),
|
||||
password_hash: v.password_hash ?? '',
|
||||
created_at: v.created_at ?? new Date().toISOString(),
|
||||
updated_at: v.updated_at ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 舊實例自癒:在 KBDB 找到的既有帳號,原樣搬進認證儲存。
|
||||
* best-effort——搬不動(寫入路徑未就緒)不影響這次登入,只是下次還會再走一次舊路。
|
||||
* 這就是 #55「第一版不做跨版本遷移機制」的落地方式:**用一次成功的登入把自己搬過去**。
|
||||
*/
|
||||
async function promoteLegacyUser(env: Bindings, rec: PortalRecord): Promise<void> {
|
||||
try {
|
||||
const email = (rec.values.email ?? '').toLowerCase();
|
||||
if (!email) return;
|
||||
if (findAuthUserByEmail(env, email)) return;
|
||||
await mutateAuthStore(env, (data) => {
|
||||
if (data.users.some((u) => u.email === email)) return;
|
||||
data.users.push(recordValuesToAuthUser(newAuthUserId(), rec.values));
|
||||
});
|
||||
} catch {
|
||||
/* 搬遷失敗不擋登入(誠實:狀態可從 /health 的 auth_store 看出來) */
|
||||
}
|
||||
}
|
||||
|
||||
/** email → user record_id。**新家優先**;找不到才回退舊家(KBDB),並順手搬過去。 */
|
||||
async function findUserRecordId(env: Bindings, email: string): Promise<string | null> {
|
||||
const inStore = findAuthUserByEmail(env, email);
|
||||
if (inStore) return inStore.id;
|
||||
return findLegacyUserRecordId(env, email);
|
||||
}
|
||||
|
||||
/** 舊家(KBDB)的 email → record_id(design §2.3 head entry O(1) 查找)。 */
|
||||
async function findLegacyUserRecordId(env: Bindings, email: string): Promise<string | null> {
|
||||
const ns = portalNamespace(env);
|
||||
const params = new URLSearchParams({
|
||||
page_name: email,
|
||||
@@ -169,6 +247,11 @@ async function findUserRecordId(env: Bindings, email: string): Promise<string |
|
||||
}
|
||||
|
||||
async function getRecordById(env: Bindings, recordId: string): Promise<PortalRecord | null> {
|
||||
// D61:住新家的帳號零網路呼叫直接讀 env(換 D1/換租戶代號都影響不到)
|
||||
if (isAuthStoreId(recordId)) {
|
||||
const u = findAuthUserById(env, recordId);
|
||||
return u ? authUserToRecord(u) : null;
|
||||
}
|
||||
const res = await kbdbFetch(env, `/records/${encodeURIComponent(recordId)}`);
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) throw new KbdbError(`GET /records/${recordId} → ${res.status}`);
|
||||
@@ -177,6 +260,19 @@ async function getRecordById(env: Bindings, recordId: string): Promise<PortalRec
|
||||
}
|
||||
|
||||
async function patchRecordValues(env: Bindings, recordId: string, values: Record<string, string>): Promise<PortalRecord> {
|
||||
// D61:住新家的帳號改寫進 Workers Secrets(改密碼/停用/改權限都在這條路上)
|
||||
if (isAuthStoreId(recordId)) {
|
||||
let updated: AuthUserRecord | null = null;
|
||||
await mutateAuthStore(env, (data) => {
|
||||
const idx = data.users.findIndex((u) => u.id === recordId);
|
||||
if (idx < 0) throw new KbdbError(`認證儲存找不到帳號 ${recordId}`);
|
||||
const merged = { ...authUserToRecord(data.users[idx]).values, ...values };
|
||||
updated = recordValuesToAuthUser(recordId, merged);
|
||||
data.users[idx] = updated;
|
||||
});
|
||||
if (!updated) throw new KbdbError(`認證儲存更新失敗 ${recordId}`);
|
||||
return authUserToRecord(updated);
|
||||
}
|
||||
const res = await kbdbFetch(env, `/records/${encodeURIComponent(recordId)}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ values }),
|
||||
@@ -188,6 +284,17 @@ async function patchRecordValues(env: Bindings, recordId: string, values: Record
|
||||
}
|
||||
|
||||
async function deleteKbdbRecord(env: Bindings, recordId: string): Promise<boolean> {
|
||||
if (isAuthStoreId(recordId)) {
|
||||
let found = false;
|
||||
await mutateAuthStore(env, (data) => {
|
||||
const idx = data.users.findIndex((u) => u.id === recordId);
|
||||
if (idx >= 0) {
|
||||
data.users.splice(idx, 1);
|
||||
found = true;
|
||||
}
|
||||
});
|
||||
return found;
|
||||
}
|
||||
const res = await kbdbFetch(env, `/records/${encodeURIComponent(recordId)}`, { method: 'DELETE' });
|
||||
if (res.status === 404) return false;
|
||||
if (!res.ok) throw new KbdbError(`DELETE /records/${recordId} → ${res.status}`);
|
||||
@@ -200,6 +307,24 @@ function daemonActiveKey(env: Bindings): string {
|
||||
}
|
||||
|
||||
export async function listRecordsByTemplate(env: Bindings, template: string): Promise<PortalRecord[]> {
|
||||
// D61:帳號清單=新家為主,舊家(KBDB)尚未搬走的補在後面(同 email 以新家為準)。
|
||||
// 舊家讀不到不算失敗——認證已經不靠它了,這裡只是把還沒搬完的人也列出來。
|
||||
if (template === USER_TEMPLATE) {
|
||||
const fromStore = readAuthStore(env).users.map(authUserToRecord);
|
||||
const seen = new Set(fromStore.map((r) => (r.values.email ?? '').toLowerCase()));
|
||||
let legacy: PortalRecord[] = [];
|
||||
try {
|
||||
legacy = await listLegacyRecordsByTemplate(env, template);
|
||||
} catch {
|
||||
legacy = [];
|
||||
}
|
||||
return [...fromStore, ...legacy.filter((r) => !seen.has((r.values.email ?? '').toLowerCase()))];
|
||||
}
|
||||
return listLegacyRecordsByTemplate(env, template);
|
||||
}
|
||||
|
||||
/** KBDB 原生的 by-template 查詢(portal_library 等「資料」仍走這條,那些本來就該住知識庫)。 */
|
||||
async function listLegacyRecordsByTemplate(env: Bindings, template: string): Promise<PortalRecord[]> {
|
||||
const ns = portalNamespace(env);
|
||||
const res = await kbdbFetch(env, `/records/by-template/${encodeURIComponent(template)}?owner_id=${encodeURIComponent(ns)}`);
|
||||
if (!res.ok) throw new KbdbError(`GET /records/by-template/${template} → ${res.status}`);
|
||||
@@ -215,44 +340,28 @@ interface CreateUserInput {
|
||||
password_hash: string;
|
||||
}
|
||||
|
||||
/** 建 portal_user record(子 namespace)+ email head entry(§2.3)。 */
|
||||
/**
|
||||
* 建帳號。**D61 起一律建在認證儲存(Workers Secrets),不再寫進 KBDB。**
|
||||
* 寫入路徑未就緒就誠實拋錯(AuthStoreWriteError → 502),不偷偷退回舊家——
|
||||
* 退回去等於這個帳號下次搬資料時又會不見,那正是本案要根治的病。
|
||||
*/
|
||||
async function createPortalUser(env: Bindings, input: CreateUserInput): Promise<string> {
|
||||
const ns = portalNamespace(env);
|
||||
const now = new Date().toISOString();
|
||||
const res = await kbdbFetch(env, '/records', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
template: USER_TEMPLATE,
|
||||
owner_id: ns,
|
||||
values: {
|
||||
email: input.email,
|
||||
display_name: input.display_name,
|
||||
status: 'active',
|
||||
role: input.role,
|
||||
password_hash: input.password_hash,
|
||||
libraries: JSON.stringify(input.libraries),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
}),
|
||||
const id = newAuthUserId();
|
||||
await mutateAuthStore(env, (data) => {
|
||||
data.users.push({
|
||||
id,
|
||||
email: input.email.toLowerCase(),
|
||||
display_name: input.display_name,
|
||||
status: 'active',
|
||||
role: input.role,
|
||||
libraries: input.libraries,
|
||||
password_hash: input.password_hash,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
});
|
||||
if (!res.ok) throw new KbdbError(`POST /records(portal_user)→ ${res.status}`);
|
||||
const body = (await res.json()) as { record?: { record_id: string } };
|
||||
const recordId = body.record?.record_id;
|
||||
if (!recordId) throw new KbdbError('POST /records 回應缺 record_id');
|
||||
|
||||
// head entry:page_name=email(indexed)→ content=record_id,O(1) 登入查找
|
||||
const head = await kbdbFetch(env, '/entries', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
entry_type: USER_TEMPLATE,
|
||||
page_name: input.email,
|
||||
content: recordId,
|
||||
owner_id: ns,
|
||||
}),
|
||||
});
|
||||
if (!head.ok) throw new KbdbError(`head entry 建立失敗(record ${recordId} 已建,需人工收拾)→ ${head.status}`);
|
||||
return recordId;
|
||||
return id;
|
||||
}
|
||||
|
||||
// ── user 值域 helpers ──────────────────────────────────────────────────────
|
||||
@@ -429,6 +538,49 @@ async function clearLoginFail(env: Bindings, email: string): Promise<void> {
|
||||
await env.SESSIONS_KV.delete(`${LOCKFAIL_PREFIX}${email}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* D61:這台實例是不是「一個帳號都沒有」(新家空、舊家也空/讀不到)。
|
||||
* 只在「查無此帳號」時才呼叫,不進正常登入熱路徑。
|
||||
*/
|
||||
async function instanceHasNoAuthData(env: Bindings): Promise<boolean> {
|
||||
if (readAuthStore(env).users.length > 0) return false;
|
||||
try {
|
||||
return (await listLegacyRecordsByTemplate(env, USER_TEMPLATE)).length === 0;
|
||||
} catch {
|
||||
return true; // 舊家讀不到 + 新家空 = 這台實例確實沒有可用的登入資料
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* D61:查帳號+驗密碼的**唯一**入口(portal 登入與兩支 daemon 端點共用,避免三份走樣)。
|
||||
*
|
||||
* 🔴 為什麼要「失敗後再問一次加速器」(2026-08-10 stage 演練實測撞到的坑):
|
||||
* 認證的家是 CF Workers Secret,改它會產生 worker 新版本,**既有 isolate 讀到的還是舊 env**。
|
||||
* 實測「建好帳號 → 立刻登入」有 15 秒以上是 401,而且那幾次還被算進 5 次鎖定
|
||||
* ⇒ 安裝精靈「建立帳號 → 馬上登入」會把人鎖在門外 15 分鐘。
|
||||
* ⇒ 所以查不到/密碼對不上時,**先問一次加速器再判定失敗**(見 lib 的 ACCEL_KEY 註解)。
|
||||
* ⇒ 加速器讀不到也沒關係,只是回到「等傳播」;它不是真相源,換 KV 不影響 D61 的不變量。
|
||||
*/
|
||||
async function findAndVerifyUser(
|
||||
env: Bindings,
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<{ recordId: string | null; rec: PortalRecord | null; ok: boolean }> {
|
||||
const attempt = async () => {
|
||||
const recordId = await findUserRecordId(env, email);
|
||||
const rec = recordId ? await getRecordById(env, recordId) : null;
|
||||
const ok = rec ? await verifyPassword(password, rec.values.password_hash ?? '') : false;
|
||||
return { recordId, rec, ok };
|
||||
};
|
||||
const first = await attempt();
|
||||
if (first.ok) return first;
|
||||
if (await hydrateFromAccelerator(env)) {
|
||||
const second = await attempt();
|
||||
if (second.ok || second.rec) return second;
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════ 認證端點 ═══════════════════════════════════
|
||||
|
||||
// POST /portal/login — body {email, password}。成功發 portal session token。
|
||||
@@ -444,25 +596,39 @@ portalRouter.post('/portal/login', (c) =>
|
||||
return c.json({ error: '登入失敗次數過多,已暫時鎖定,請 15 分鐘後再試' }, 429);
|
||||
}
|
||||
|
||||
const recordId = await findUserRecordId(c.env, email);
|
||||
if (!recordId) {
|
||||
await recordLoginFail(c.env, email);
|
||||
return c.json({ error: 'email 或密碼錯誤' }, 401);
|
||||
}
|
||||
const rec = await getRecordById(c.env, recordId);
|
||||
if (!rec) {
|
||||
const { recordId, rec, ok } = await findAndVerifyUser(c.env, email, password);
|
||||
if (!recordId || !rec) {
|
||||
// D61 明顯失敗(arcrun-rag#10「寧可明顯失敗,不要靜默錯置」套到門鎖上):
|
||||
// 「這台實例一個帳號都沒有」跟「你密碼打錯」是兩件事,不准混成同一句話——
|
||||
// 2026-08-09 leo 就是被這個誤判鎖了 15 分鐘,而他的密碼從頭到尾都是對的。
|
||||
// ⇒ 回一個**分得出來**的錯,而且**不計入鎖定**。
|
||||
if (await instanceHasNoAuthData(c.env)) {
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
'這台實例讀不到任何登入資料——不是密碼錯。認證儲存是空的,' +
|
||||
'請重新執行安裝/更新以重新建立管理員帳號。',
|
||||
code: 'auth_store_empty',
|
||||
auth_store: authStoreStatus(c.env),
|
||||
},
|
||||
503,
|
||||
);
|
||||
}
|
||||
await recordLoginFail(c.env, email);
|
||||
return c.json({ error: 'email 或密碼錯誤' }, 401);
|
||||
}
|
||||
if ((rec.values.status ?? '') !== 'active') {
|
||||
return c.json({ error: '帳號已停用' }, 403);
|
||||
}
|
||||
const ok = await verifyPassword(password, rec.values.password_hash ?? '');
|
||||
if (!ok) {
|
||||
await recordLoginFail(c.env, email);
|
||||
return c.json({ error: 'email 或密碼錯誤' }, 401);
|
||||
}
|
||||
|
||||
// D61 自癒:這次是拿舊家(KBDB)的帳號登進來的 → 順手搬進認證儲存,
|
||||
// 下次換庫/換租戶代號就不會再把他鎖在門外。
|
||||
if (!isAuthStoreId(recordId)) await promoteLegacyUser(c.env, rec);
|
||||
|
||||
await clearLoginFail(c.env, email);
|
||||
const token = randomHex(32);
|
||||
// session 值只存 record_id(design §4.3)——權限/狀態每請求回讀 record,不快取進 session
|
||||
@@ -844,10 +1010,9 @@ portalRouter.post('/portal/daemon/libraries', (c) =>
|
||||
const password = String(body?.password ?? '');
|
||||
if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400);
|
||||
if (await isLocked(c.env, email)) return c.json({ error: '登入失敗次數過多,請稍後再試' }, 429);
|
||||
const recordId = await findUserRecordId(c.env, email);
|
||||
const rec = recordId ? await getRecordById(c.env, recordId) : null;
|
||||
if (!rec || (rec.values.status ?? '') !== 'active'
|
||||
|| !(await verifyPassword(password, rec.values.password_hash ?? ''))) {
|
||||
// D61:與 /portal/login 共用同一支查找+驗證(含「剛建好還沒傳播」的加速器重試)
|
||||
const { rec, ok } = await findAndVerifyUser(c.env, email, password);
|
||||
if (!rec || (rec.values.status ?? '') !== 'active' || !ok) {
|
||||
await recordLoginFail(c.env, email);
|
||||
return c.json({ error: 'email 或密碼錯誤' }, 401);
|
||||
}
|
||||
@@ -910,14 +1075,14 @@ portalRouter.post('/portal/daemon/config', (c) =>
|
||||
if (await isLocked(c.env, email)) {
|
||||
return c.json({ error: '登入失敗次數過多,已暫時鎖定,請 15 分鐘後再試' }, 429);
|
||||
}
|
||||
const recordId = await findUserRecordId(c.env, email);
|
||||
const rec = recordId ? await getRecordById(c.env, recordId) : null;
|
||||
// D61:同上,共用 findAndVerifyUser
|
||||
const { rec, ok } = await findAndVerifyUser(c.env, email, password);
|
||||
if (!rec) {
|
||||
await recordLoginFail(c.env, email);
|
||||
return c.json({ error: 'email 或密碼錯誤' }, 401);
|
||||
}
|
||||
if ((rec.values.status ?? '') !== 'active') return c.json({ error: '帳號已停用' }, 403);
|
||||
if (!(await verifyPassword(password, rec.values.password_hash ?? ''))) {
|
||||
if (!ok) {
|
||||
await recordLoginFail(c.env, email);
|
||||
return c.json({ error: 'email 或密碼錯誤' }, 401);
|
||||
}
|
||||
@@ -1105,10 +1270,9 @@ portalRouter.post('/portal/daemon/libraries', (c) =>
|
||||
|
||||
// 帳密驗證(沿用 /portal/session 的鎖定與驗證機制)
|
||||
if (await isLocked(c.env, email)) return c.json({ error: '登入失敗次數過多,請稍後再試' }, 429);
|
||||
const recordId = await findUserRecordId(c.env, email);
|
||||
const rec = recordId ? await getRecordById(c.env, recordId) : null;
|
||||
if (!rec || (rec.values.status ?? '') !== 'active'
|
||||
|| !(await verifyPassword(password, rec.values.password_hash ?? ''))) {
|
||||
// D61:與 /portal/login 共用同一支查找+驗證(含「剛建好還沒傳播」的加速器重試)
|
||||
const { rec, ok } = await findAndVerifyUser(c.env, email, password);
|
||||
if (!rec || (rec.values.status ?? '') !== 'active' || !ok) {
|
||||
await recordLoginFail(c.env, email);
|
||||
return c.json({ error: 'email 或密碼錯誤' }, 401);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* console-auth.ts —— D61 舊實例相容(帳密只在舊 SESSIONS_KV,尚未搬遷過)
|
||||
*
|
||||
* 拆成獨立檔案的理由:portal-auth-store.ts 的 per-isolate overlay 是模組級全域變數,
|
||||
* 一旦某個測試讓 console 帳密的認證儲存寫入成功,overlay.console 就會在**同一支測試檔案**
|
||||
* 剩下的測試裡持續存在(不同檔案=不同 worker 執行個體,互不污染,已用小型探針驗證過)。
|
||||
* tests/console-auth.test.ts 一開始就會走一次「首次設定成功」,之後整支檔案都是「已設定」
|
||||
* 的世界;「認證儲存還是空的、帳密只活在舊 KV」這個起始狀態只有在全新檔案才測得出來。
|
||||
*/
|
||||
import { SELF, env, fetchMock } from 'cloudflare:test';
|
||||
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
||||
|
||||
const CF_API = 'https://api.cloudflare.com';
|
||||
const CREDS_KEY = 'console:credentials';
|
||||
|
||||
beforeAll(() => {
|
||||
fetchMock.activate();
|
||||
fetchMock.disableNetConnect();
|
||||
});
|
||||
afterEach(() => fetchMock.assertNoPendingInterceptors());
|
||||
|
||||
function json(method: string, path: string, body?: unknown) {
|
||||
return SELF.fetch(`http://localhost${path}`, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function mockAuthStoreWrite(times = 1): { puts: () => Array<{ name: string; text: string }> } {
|
||||
const captured: Array<{ name: string; text: string }> = [];
|
||||
fetchMock
|
||||
.get(CF_API)
|
||||
.intercept({ path: (p: string) => p.includes('/secrets'), method: 'PUT' })
|
||||
.reply(200, (opts) => {
|
||||
const body = JSON.parse(String(opts.body)) as { name: string; text: string };
|
||||
captured.push(body);
|
||||
return { success: true };
|
||||
})
|
||||
.times(times);
|
||||
return { puts: () => captured };
|
||||
}
|
||||
|
||||
/** 複刻 console-auth.ts 內未 export 的私有迭代雜湊(sha256(salt+password) 迭代 3 次),
|
||||
* 單純為了在測試端準備一筆能通過驗證的 legacy fixture,不是重新實作生產邏輯。 */
|
||||
async function legacyHash(password: string, salt: string): Promise<string> {
|
||||
async function sha256Hex(input: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
|
||||
return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
let h = `${salt}:${password}`;
|
||||
for (let i = 0; i < 3; i++) h = await sha256Hex(h);
|
||||
return h;
|
||||
}
|
||||
|
||||
const EMAIL = 'legacy-owner@example.com';
|
||||
const PASSWORD = 'legacy-owner-pw-1';
|
||||
const SALT = 'deadbeef00112233';
|
||||
|
||||
describe('D61 舊實例相容:console 帳密只在舊 KV(尚未搬遷)', () => {
|
||||
it('GET /console/auth-status:讀到舊 KV 這筆、順手搬進認證儲存', async () => {
|
||||
const hash = await legacyHash(PASSWORD, SALT);
|
||||
await env.SESSIONS_KV.put(
|
||||
CREDS_KEY,
|
||||
JSON.stringify({ email: EMAIL, salt: SALT, hash, created_at: '2026-01-01T00:00:00.000Z' }),
|
||||
);
|
||||
const { puts } = mockAuthStoreWrite();
|
||||
|
||||
const res = await json('GET', '/console/auth-status');
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as {
|
||||
configured: boolean;
|
||||
credentials_source: string;
|
||||
auth_store: { console_configured: boolean };
|
||||
};
|
||||
expect(data.configured).toBe(true);
|
||||
expect(data.credentials_source).toBe('legacy-kv'); // 這次是靠回退讀到的
|
||||
// loadCredentials 內的 best-effort 搬遷在回應組出來之前就已 await 完成,
|
||||
// 故 authStoreStatus 已經反映搬遷後的狀態
|
||||
expect(data.auth_store.console_configured).toBe(true);
|
||||
|
||||
const shards = puts();
|
||||
expect(shards.length).toBe(1);
|
||||
const shard = JSON.parse(shards[0].text) as { console: { email: string; hash: string } };
|
||||
expect(shard.console.email).toBe(EMAIL);
|
||||
expect(shard.console.hash).toBe(hash); // 原樣搬過去,不重新雜湊
|
||||
});
|
||||
|
||||
it('搬遷後再打一次:新家已經有了,直接命中新家(不用再查舊 KV)', async () => {
|
||||
const res = await json('GET', '/console/auth-status');
|
||||
const data = (await res.json()) as { credentials_source: string };
|
||||
expect(data.credentials_source).toBe('secrets');
|
||||
});
|
||||
|
||||
it('用搬遷過去的帳密登入 → 200(搬遷沒有讓帳密變得登不進去)', async () => {
|
||||
const res = await json('POST', '/console/login', { email: EMAIL, password: PASSWORD });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean };
|
||||
expect(data.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* console-auth.ts 測試(D61:console 管理員帳密搬進認證儲存,ADR D61 / Leo/arcrun-rag#55)
|
||||
*
|
||||
* 這組帳密(/console/setup、/console/login…)原本住 SESSIONS_KV `console:credentials`
|
||||
* (沒有 TTL)——KV 靠 binding 指過去,重裝會被指到新建的空 KV ⇒ 帳密憑空消失
|
||||
* (console-auth.ts 檔頭「KV=暫存、非長期真相源」第三次被違反,這次違反的是大門的鎖)。
|
||||
* D61 起改存進認證儲存(CF Workers Secrets),SESSIONS_KV 只留為回退讀路徑。
|
||||
*
|
||||
* 覆蓋(本檔在此之前不存在,D61 交辦要求的新增覆蓋):
|
||||
* 1. 全新實例:auth-status 回 configured:false;login 回「讀不到認證資料」(不是密碼錯)。
|
||||
* 2. 首次設定成功:POST /console/setup 寫進認證儲存(CF Workers Secrets),不再寫 KV。
|
||||
* 3. 已設定過 → 409,訊息明講「你剛才輸入的密碼沒有被採用」(D61 明顯失敗,取代舊版
|
||||
* 只說「已設定過」卻不說清楚剛才那組密碼發生了什麼事的誤導文案)。
|
||||
* 4. 登入對錯:帳密正確 200;密碼錯 401。
|
||||
* 5. /console/setup/reset:舊密碼驗證+新密碼寫進新家;換密碼後舊密碼立即失效。
|
||||
*
|
||||
* 認證儲存寫入會呼叫 `https://api.cloudflare.com/.../secrets`(PUT),走 fetchMock 假 host
|
||||
* 攔截(同 portal-auth.test.ts 的 mockAuthStoreWrite),不外連;wrangler.test.toml 已預設
|
||||
* CF_SECRETS_API_TOKEN/CF_ACCOUNT_ID 就緒。
|
||||
*
|
||||
* ⚠️ 測試順序不可打亂:portal-auth-store.ts 的 per-isolate overlay 是模組級全域變數,
|
||||
* 一旦某則測試讓 /console/setup 或 reset 真的寫成功,overlay.console 就會在**這支檔案**
|
||||
* 剩下的測試裡持續存在(同檔案不會在測試之間重置模組全域,只有 KV/D1 等 storage 才有
|
||||
* isolatedStorage 重置)。因此本檔刻意排成一條線性故事:先驗證「全新、尚未設定」的分支,
|
||||
* 再做一次成功的 /console/setup(之後永久變成「已設定」),後面的測試都建立在這個已設定
|
||||
* 的基礎上。「帳密只存在舊 KV(尚未搬遷過)」這個分支需要 overlay 是空的,因此另開一支
|
||||
* 檔案 tests/console-auth-legacy.test.ts(不同檔案=不同 worker 執行個體,狀態不互相污染)。
|
||||
*/
|
||||
import { SELF, env, fetchMock } from 'cloudflare:test';
|
||||
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
||||
|
||||
const CF_API = 'https://api.cloudflare.com';
|
||||
|
||||
beforeAll(() => {
|
||||
fetchMock.activate();
|
||||
fetchMock.disableNetConnect();
|
||||
});
|
||||
afterEach(() => fetchMock.assertNoPendingInterceptors());
|
||||
|
||||
function json(method: string, path: string, body?: unknown, headers: Record<string, string> = {}) {
|
||||
return SELF.fetch(`http://localhost${path}`, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
/** D61:認證儲存寫入路徑(同 portal-auth.test.ts 的同名 helper,那邊有完整說明)。 */
|
||||
function mockAuthStoreWrite(times = 1): { puts: () => Array<{ name: string; text: string }> } {
|
||||
const captured: Array<{ name: string; text: string }> = [];
|
||||
fetchMock
|
||||
.get(CF_API)
|
||||
.intercept({ path: (p: string) => p.includes('/secrets'), method: 'PUT' })
|
||||
.reply(200, (opts) => {
|
||||
const body = JSON.parse(String(opts.body)) as { name: string; text: string };
|
||||
captured.push(body);
|
||||
return { success: true };
|
||||
})
|
||||
.times(times);
|
||||
return { puts: () => captured };
|
||||
}
|
||||
|
||||
const OWNER_EMAIL = 'owner@example.com';
|
||||
const OWNER_PW = 'owner-first-pw-1';
|
||||
|
||||
// ═══════════════ 1. 全新實例(尚未設定過,必須排最前面)═══════════════
|
||||
|
||||
describe('全新實例(尚未設定過任何管理員帳密)', () => {
|
||||
it('GET /console/auth-status → configured:false,不洩漏 email', async () => {
|
||||
const res = await json('GET', '/console/auth-status');
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { configured: boolean; credentials_source: string; auth_store: { present: boolean } };
|
||||
expect(data.configured).toBe(false);
|
||||
expect(data.credentials_source).toBe('none');
|
||||
expect(JSON.stringify(data)).not.toContain('@'); // 不洩漏 email
|
||||
});
|
||||
|
||||
it('POST /console/login → 400「讀不到認證資料」,不是密碼錯(D61 明顯失敗)', async () => {
|
||||
const res = await json('POST', '/console/login', { email: 'anyone@example.com', password: 'whatever-pw-1' });
|
||||
expect(res.status).toBe(400);
|
||||
const data = (await res.json()) as { code: string; error: string };
|
||||
expect(data.code).toBe('auth_store_empty');
|
||||
expect(data.error).not.toBe('email 或密碼錯誤'); // 不是密碼錯誤路徑用的那句通用訊息
|
||||
});
|
||||
|
||||
it('POST /console/setup/reset(還沒設定過就想換密碼)→ 400,叫去用 /console/setup', async () => {
|
||||
const res = await json('POST', '/console/setup/reset', {
|
||||
current_password: 'whatever', email: 'x@y.co', password: 'newpassword1',
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 2. 首次設定:成功寫進認證儲存(D61 起唯一寫入路徑)═══════════════
|
||||
|
||||
describe('POST /console/setup — 首次設定', () => {
|
||||
it('成功:寫進認證儲存(不再寫 SESSIONS_KV),回 session_token', async () => {
|
||||
const { puts } = mockAuthStoreWrite();
|
||||
const res = await json('POST', '/console/setup', { email: OWNER_EMAIL.toUpperCase(), password: OWNER_PW });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean; session_token: string; tenant: string };
|
||||
expect(data.success).toBe(true);
|
||||
expect(typeof data.session_token).toBe('string');
|
||||
|
||||
// 寫入認證儲存:一片、含小寫 email,明碼密碼絕不落地
|
||||
const shards = puts();
|
||||
expect(shards.length).toBe(1);
|
||||
expect(shards[0].name).toBe('ARCRUN_AUTH_STORE');
|
||||
expect(shards[0].text).not.toContain(OWNER_PW);
|
||||
const shard = JSON.parse(shards[0].text) as { console: { email: string; salt: string; hash: string } };
|
||||
expect(shard.console.email).toBe(OWNER_EMAIL); // 存小寫
|
||||
expect(typeof shard.console.salt).toBe('string');
|
||||
expect(typeof shard.console.hash).toBe('string');
|
||||
|
||||
// D61:不再寫舊 KV——這是本次變更的核心(舊版寫 SESSIONS_KV,重裝就蒸發)
|
||||
expect(await env.SESSIONS_KV.get('console:credentials')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 3. 已設定過 → 409(D61 明顯失敗:說得出「沒有被採用」)═══════════════
|
||||
|
||||
describe('POST /console/setup — 已設定過(重複設定)', () => {
|
||||
it('409,訊息明講「你剛才輸入的密碼沒有被採用」,不誤導成「設定成功」', async () => {
|
||||
const res = await json('POST', '/console/setup', { email: 'attacker@example.com', password: 'trying-to-hijack-1' });
|
||||
expect(res.status).toBe(409);
|
||||
const data = (await res.json()) as {
|
||||
error: string; code: string; password_applied: boolean; reset_path: string;
|
||||
};
|
||||
expect(data.code).toBe('already_configured');
|
||||
expect(data.password_applied).toBe(false);
|
||||
expect(data.error).toContain('沒有被採用');
|
||||
expect(data.reset_path).toBe('/console/setup/reset');
|
||||
// 攻擊者填的帳密真的沒有生效:用它登入應該失敗(下一個 describe 也會正面驗證原帳密仍有效)
|
||||
});
|
||||
|
||||
it('GET /console/auth-status → configured:true,credentials_source:secrets(新家優先命中)', async () => {
|
||||
const res = await json('GET', '/console/auth-status');
|
||||
const data = (await res.json()) as { configured: boolean; credentials_source: string; auth_store: { console_configured: boolean } };
|
||||
expect(data.configured).toBe(true);
|
||||
expect(data.credentials_source).toBe('secrets');
|
||||
expect(data.auth_store.console_configured).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 4. 登入對錯(用第 2 節設定的帳密)═══════════════
|
||||
|
||||
describe('POST /console/login', () => {
|
||||
it('帳密正確 → 200,發 session token', async () => {
|
||||
const res = await json('POST', '/console/login', { email: OWNER_EMAIL, password: OWNER_PW });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean; session_token: string };
|
||||
expect(data.success).toBe(true);
|
||||
expect(typeof data.session_token).toBe('string');
|
||||
});
|
||||
|
||||
it('密碼錯 → 401', async () => {
|
||||
const res = await json('POST', '/console/login', { email: OWNER_EMAIL, password: 'wrong-password-x' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('攻擊者在第 3 節試圖搶注的帳密登不進來(證明真的「沒有被採用」)', async () => {
|
||||
const res = await json('POST', '/console/login', { email: 'attacker@example.com', password: 'trying-to-hijack-1' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 5. /console/setup/reset:換密碼,寫進新家 ═══════════════
|
||||
|
||||
describe('POST /console/setup/reset', () => {
|
||||
const NEW_PW = 'brand-new-owner-pw-1';
|
||||
|
||||
it('舊密碼錯 → 401,不寫入', async () => {
|
||||
const res = await json('POST', '/console/setup/reset', {
|
||||
current_password: 'still-wrong', email: OWNER_EMAIL, password: NEW_PW,
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('舊密碼對 → 200,新 hash 寫進新家;換完後舊密碼立即失效、新密碼生效', async () => {
|
||||
const { puts } = mockAuthStoreWrite();
|
||||
const res = await json('POST', '/console/setup/reset', {
|
||||
current_password: OWNER_PW, email: OWNER_EMAIL, password: NEW_PW,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean };
|
||||
expect(data.success).toBe(true);
|
||||
|
||||
const shards = puts();
|
||||
expect(shards.length).toBe(1);
|
||||
expect(shards[0].text).not.toContain(NEW_PW); // 明碼不落地
|
||||
const shard = JSON.parse(shards[0].text) as { console: { email: string } };
|
||||
expect(shard.console.email).toBe(OWNER_EMAIL);
|
||||
|
||||
// 舊密碼立即失效
|
||||
const oldLogin = await json('POST', '/console/login', { email: OWNER_EMAIL, password: OWNER_PW });
|
||||
expect(oldLogin.status).toBe(401);
|
||||
// 新密碼生效
|
||||
const newLogin = await json('POST', '/console/login', { email: OWNER_EMAIL, password: NEW_PW });
|
||||
expect(newLogin.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -4,9 +4,10 @@
|
||||
* 覆蓋(=tasks.md P4+總管派工驗收重點):
|
||||
* 1. **最後一個 active admin 鎖死保護**:停用 → 409;降級 role=user → 409;
|
||||
* 「還有另一個 active admin」才放行;另一個 admin 是 disabled 不算數。
|
||||
* 2. 一次性密碼:新增未帶密碼 → generated_password 只在回應出現一次、明碼不落 KBDB
|
||||
* (庫裡只有 pbkdf2 hash);自帶密碼 → 回應無 generated_password。
|
||||
* 3. reset-password:回一次性新密碼;PATCH 進 KBDB 的是 hash 非明碼。
|
||||
* 2. 一次性密碼:新增未帶密碼 → generated_password 只在回應出現一次、明碼不落地
|
||||
* (新家只有 pbkdf2 hash,D61 起帳號建立走認證儲存不再落 KBDB);自帶密碼 → 回應無 generated_password。
|
||||
* 3. reset-password:回一次性新密碼;PATCH 落地的是 hash 非明碼(目標帳號沿用舊家 fixture,
|
||||
* 仍走 KBDB PATCH——見下方 mockPatchPrelude 的說明)。
|
||||
* 4. 庫權限:PATCH libraries=["*"](全庫)合法;空陣列/壞庫名 → 400。
|
||||
* 5. 庫目錄:POST 建庫寫 {tenant}::portal 子 namespace;PATCH graph_source boolean。
|
||||
* 6. /portal HTML 殼(P4 admin 頁):admin view 存在;**仍零租戶字串、零 /kbdb/、
|
||||
@@ -14,12 +15,21 @@
|
||||
*
|
||||
* KBDB 打 fetchMock 假 host(wrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+
|
||||
* disableNetConnect——絕不外連。UI 全流程由本機隔離雙 worker 端到端 curl 驗證(PR 證據表)。
|
||||
*
|
||||
* D61(ADR D61 / Leo/arcrun-rag#55):本檔測試裡的帳號 fixture(rec_admin/rec_u1/rec_admin2…)
|
||||
* 全部沿用「record_id 不是 auth: 開頭」這個既有慣例——這正是 portal.ts 的相容分流點
|
||||
* (isAuthStoreId(recordId)),非 auth: 開頭的 id 一律走原本的 KBDB 路徑,行為與 D61 之前
|
||||
* 完全一致,故本檔絕大多數測試不需要改。**只有「新建帳號」這個動作**(POST /portal/admin/users、
|
||||
* POST /portal/admin/bootstrap 走同一支 createPortalUser)改成寫進認證儲存(CF Workers
|
||||
* Secrets),需要額外攔截 `https://api.cloudflare.com/.../secrets`(PUT)——見 mockAuthStoreWrite。
|
||||
*/
|
||||
import { SELF, env, fetchMock } from 'cloudflare:test';
|
||||
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
||||
import { hashPassword, PBKDF2_ITERATIONS } from '../src/lib/portal-auth';
|
||||
import { AUTH_ID_PREFIX } from '../src/lib/portal-auth-store';
|
||||
|
||||
const KBDB = 'https://kbdb.test';
|
||||
const CF_API = 'https://api.cloudflare.com';
|
||||
const NS = 'leo::portal'; // wrangler.test.toml CONSOLE_TENANT=leo → 子 namespace
|
||||
|
||||
let storedHash: string;
|
||||
@@ -39,6 +49,21 @@ function json(method: string, path: string, body?: unknown, headers: Record<stri
|
||||
});
|
||||
}
|
||||
|
||||
/** D61:認證儲存寫入路徑(同 portal-auth.test.ts 的同名 helper,見那邊檔頭的完整說明)。 */
|
||||
function mockAuthStoreWrite(times = 1): { puts: () => Array<{ name: string; text: string }> } {
|
||||
const captured: Array<{ name: string; text: string }> = [];
|
||||
fetchMock
|
||||
.get(CF_API)
|
||||
.intercept({ path: (p: string) => p.includes('/secrets'), method: 'PUT' })
|
||||
.reply(200, (opts) => {
|
||||
const body = JSON.parse(String(opts.body)) as { name: string; text: string };
|
||||
captured.push(body);
|
||||
return { success: true };
|
||||
})
|
||||
.times(times);
|
||||
return { puts: () => captured };
|
||||
}
|
||||
|
||||
function mockHeadLookup(email: string, recordId: string | null) {
|
||||
const needle = new URLSearchParams({ page_name: email }).toString();
|
||||
fetchMock
|
||||
@@ -177,23 +202,11 @@ describe('last-admin 鎖死保護(PATCH /portal/admin/users/:id)', () => {
|
||||
// ═══════════════ 2. 一次性密碼(新增帳號)═══════════════
|
||||
|
||||
describe('POST /portal/admin/users(一次性密碼)', () => {
|
||||
it('未帶 password → generated_password 回一次(16 碼);KBDB 落的是 hash 非明碼', async () => {
|
||||
it('未帶 password → generated_password 回一次(16 碼);認證儲存落的是 hash 非明碼(D61)', async () => {
|
||||
await seedAdminSession();
|
||||
mockGetRecord('rec_admin', adminValues());
|
||||
mockHeadLookup('new@example.com', null); // email 未占用
|
||||
let recordBody = '';
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/records', method: 'POST' })
|
||||
.reply(200, (opts) => {
|
||||
recordBody = String(opts.body);
|
||||
return { success: true, record: { record_id: 'rec_new', template_id: 'tpl_pu', values: {} } };
|
||||
});
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/entries', method: 'POST' })
|
||||
.reply(200, { success: true, entry: { id: 'e_head' } });
|
||||
mockGetRecord('rec_new', userValues({ email: 'new@example.com' })); // 回應用的回讀
|
||||
mockHeadLookup('new@example.com', null); // email 未占用(新家找不到 → 回退查舊家)
|
||||
const { puts } = mockAuthStoreWrite();
|
||||
const res = await json(
|
||||
'POST',
|
||||
'/portal/admin/users',
|
||||
@@ -205,26 +218,23 @@ describe('POST /portal/admin/users(一次性密碼)', () => {
|
||||
expect(typeof data.generated_password).toBe('string');
|
||||
expect(data.generated_password!.length).toBe(16);
|
||||
expect('password_hash' in data.user).toBe(false);
|
||||
// 一次性密碼不落庫:KBDB 收到的 record body 只有 hash、無明碼
|
||||
expect(recordBody).not.toContain(data.generated_password!);
|
||||
const rec = JSON.parse(recordBody) as { owner_id: string; values: Record<string, string> };
|
||||
expect(rec.owner_id).toBe(NS);
|
||||
expect(rec.values.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
|
||||
expect((data.user as { record_id: string }).record_id.startsWith(AUTH_ID_PREFIX)).toBe(true); // 住新家
|
||||
|
||||
// 一次性密碼不落地:認證儲存收到的 shard 只有 hash、無明碼
|
||||
const shards = puts();
|
||||
expect(shards.length).toBe(1);
|
||||
expect(shards[0].text).not.toContain(data.generated_password!);
|
||||
const shard = JSON.parse(shards[0].text) as { users: Array<{ email: string; password_hash: string }> };
|
||||
const stored = shard.users.find((u) => u.email === 'new@example.com');
|
||||
expect(stored).toBeDefined();
|
||||
expect(stored!.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
|
||||
});
|
||||
|
||||
it('自帶 password → 回應**無** generated_password', async () => {
|
||||
await seedAdminSession();
|
||||
mockGetRecord('rec_admin', adminValues());
|
||||
mockHeadLookup('own@example.com', null);
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/records', method: 'POST' })
|
||||
.reply(200, { success: true, record: { record_id: 'rec_own', template_id: 'tpl_pu', values: {} } });
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/entries', method: 'POST' })
|
||||
.reply(200, { success: true, entry: { id: 'e_head2' } });
|
||||
mockGetRecord('rec_own', userValues({ email: 'own@example.com' }));
|
||||
mockAuthStoreWrite();
|
||||
const res = await json(
|
||||
'POST',
|
||||
'/portal/admin/users',
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 覆蓋(=tasks.md P2 測試項):
|
||||
* 1. KDF:pbkdf2-sha256$100000$… 格式(CF Workers runtime 上限 100k,2026-07-14 真雲實撞)、
|
||||
* 驗證對錯、壞格式誠實 false、600k 舊 hash 相容(迭代數從儲存值解析)
|
||||
* 2. bootstrap 閘:無 console session → 401;建 admin 寫 {tenant}::portal 子 namespace;
|
||||
* 2. bootstrap 閘:無 console session → 401;建 admin 寫進**認證儲存**(D61);
|
||||
* 已有 admin → 409
|
||||
* 3. 登入對錯:成功發 token(回應**無租戶字串**)、密碼錯 401、停用 403、未知 email 401
|
||||
* 4. 節流:5 次失敗 → 429(KV TTL 計數)
|
||||
@@ -12,16 +12,28 @@
|
||||
* 6. 改密碼:驗舊密;新 hash 以 100k 格式落 slot
|
||||
* 7. role 閘:非 admin 打 admin 端點 → 403;admin 列表**剝除 password_hash**
|
||||
*
|
||||
* D61(ADR D61 / Leo/arcrun-rag#55)補的覆蓋(原本沒有,這次變更的重點):
|
||||
* 8. 整台實例沒有任何認證資料 → 登入回「讀不到認證資料」(不是密碼錯),且不計入鎖定
|
||||
* 9. 舊實例相容:帳號只存在 KBDB(舊家)時仍登得進去,登入成功後自動搬進認證儲存
|
||||
*
|
||||
* KBDB 打 fetchMock 假 host(wrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+
|
||||
* disableNetConnect——絕不外連。子 namespace 隔離的「搜 email 搜不到」由本機雙 worker
|
||||
* 端到端 curl 驗證(PR 驗收證據表),這裡驗「寫入時 owner_id=leo::portal」的機械事實。
|
||||
*
|
||||
* D61 起,帳號的家從 KBDB 換成認證儲存(CF Workers Secrets)——寫入會呼叫
|
||||
* `https://api.cloudflare.com/.../secrets`(PUT),同樣走 fetchMock 假 host 攔截,不外連。
|
||||
* wrangler.test.toml 已預設 CF_SECRETS_API_TOKEN/CF_ACCOUNT_ID 就緒(比照真實裝妥的實例)。
|
||||
*/
|
||||
import { SELF, env, fetchMock } from 'cloudflare:test';
|
||||
import { beforeAll, beforeEach, afterEach, describe, it, expect } from 'vitest';
|
||||
import { hashPassword, verifyPassword, PBKDF2_ITERATIONS } from '../src/lib/portal-auth';
|
||||
import { PORTAL_TEMPLATE_SEEDS } from '../src/lib/portal-seeds';
|
||||
import { AUTH_ID_PREFIX } from '../src/lib/portal-auth-store';
|
||||
import { portalRouter } from '../src/routes/portal';
|
||||
import type { Bindings, ExecutionContext } from '../src/types';
|
||||
|
||||
const KBDB = 'https://kbdb.test';
|
||||
const CF_API = 'https://api.cloudflare.com';
|
||||
const NS = 'leo::portal'; // wrangler.test.toml CONSOLE_TENANT=leo → 子 namespace
|
||||
const EMAIL = 'user@example.com';
|
||||
const PASSWORD = 'correct-horse-9';
|
||||
@@ -44,6 +56,34 @@ function json(method: string, path: string, body?: unknown, headers: Record<stri
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* D61:認證儲存的寫入路徑(單元測試層級——一次呼叫=一片,測試資料量小不會觸發溢位分片)。
|
||||
* 攔截 CF Workers Scripts secrets 管理 API 的 PUT,捕捉 body 供斷言(片名/內容)。
|
||||
* 用法:每個會觸發寫入的測試呼叫一次,回傳的 `puts()` 拿到依序捕捉到的 {name, text}[]。
|
||||
*
|
||||
* ⚠️ 讀路徑沒有對應的「seed 進 env」捷徑可用:`cloudflare:test` 的 `env` 物件是傳給
|
||||
* `vitest` 主 context 用的,對 `SELF.fetch()` 打的那個 worker isolate **不生效**(實測驗證,
|
||||
* mutate `env.XXX` 後 SELF 端讀到的仍是 wrangler.test.toml 的原值)。因此「舊實例相容」
|
||||
* 一類的讀路徑測試,一律靠**既有的 KBDB fetchMock**(新家預設空,天然等於「帳號只在舊家」);
|
||||
* 要驗證「新家已經有資料」則靠**真的呼叫一次寫入端點**(bootstrap/新增同仁),讓 portal-auth-store
|
||||
* 模組內的 per-isolate overlay 落地——這個 overlay 在同一支測試檔案裡的後續測試\*也讀得到\*
|
||||
* (模組級全域變數不隨 test 重置,只有 KV/D1 等 storage 才有 isolatedStorage 重置),
|
||||
* 這是刻意善用而非意外:想要「乾淨無帳號」的情境,該測試必須排在檔案裡**第一個寫入動作之前**。
|
||||
*/
|
||||
function mockAuthStoreWrite(times = 1): { puts: () => Array<{ name: string; text: string }> } {
|
||||
const captured: Array<{ name: string; text: string }> = [];
|
||||
fetchMock
|
||||
.get(CF_API)
|
||||
.intercept({ path: (p: string) => p.includes('/secrets'), method: 'PUT' })
|
||||
.reply(200, (opts) => {
|
||||
const body = JSON.parse(String(opts.body)) as { name: string; text: string };
|
||||
captured.push(body);
|
||||
return { success: true };
|
||||
})
|
||||
.times(times);
|
||||
return { puts: () => captured };
|
||||
}
|
||||
|
||||
// ── KBDB mock helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/** head entry 查找(GET /entries?page_name=…&entry_type=portal_user&owner_id=ns&limit=1) */
|
||||
@@ -134,6 +174,33 @@ describe('PBKDF2 模組(lib/portal-auth)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 1.5 D61:整台實例沒有任何認證資料 ═══════════════
|
||||
//
|
||||
// 🔴 這個 describe 必須留在檔案裡「第一個會寫入認證儲存的測試」之前(下面 2. bootstrap
|
||||
// 的「console session OK」那則)——見 mockAuthStoreWrite 檔頭註解:portal-auth-store.ts
|
||||
// 的 per-isolate overlay 是模組級全域變數,同一支測試檔案跑起來不會在測試之間重置,
|
||||
// 一旦有測試寫入過,後面的測試都會看到那筆資料,「乾淨無帳號」的前提就不成立了。
|
||||
describe('D61:整台實例沒有任何認證資料(arcrun-rag#55,leo 2026-08-09 被誤鎖 15 分鐘的事故)', () => {
|
||||
it('登入回「讀不到認證資料」而不是「密碼錯誤」,且不計入失敗鎖定', async () => {
|
||||
// 新家(overlay/env bag)此刻還是空的(本測試特意排在任何寫入測試之前);
|
||||
// 舊家(KBDB)也回空——head lookup 查無此人+by-template 列表也空,兩邊都沒有帳號,
|
||||
// 才是「這台實例真的沒有認證資料」。
|
||||
mockHeadLookup('anyone@example.com', null);
|
||||
mockListByTemplate('portal_user', []);
|
||||
const res = await json('POST', '/portal/login', { email: 'anyone@example.com', password: 'whatever-pw-1' });
|
||||
expect(res.status).toBe(503);
|
||||
const data = (await res.json()) as { error: string; code: string; auth_store: { present: boolean; users: number } };
|
||||
expect(data.code).toBe('auth_store_empty');
|
||||
// 分得出來的錯:這句要誠實講「不是密碼錯」,而且**不能**是密碼錯誤那句通用訊息
|
||||
// (文案含混是 leo 被鎖 15 分鐘的根因——他的密碼從頭到尾是對的)。
|
||||
expect(data.error).toContain('不是密碼錯');
|
||||
expect(data.error).not.toBe('email 或密碼錯誤'); // 不是密碼錯誤路徑用的那句通用訊息
|
||||
expect(data.auth_store.users).toBe(0);
|
||||
// 不計入鎖定:lockfail 計數器完全沒被寫入
|
||||
expect(await env.SESSIONS_KV.get('portal_lockfail:anyone@example.com')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 2. bootstrap 閘 ═══════════════
|
||||
|
||||
describe('POST /portal/admin/bootstrap', () => {
|
||||
@@ -142,28 +209,12 @@ describe('POST /portal/admin/bootstrap', () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('console session OK → 建第一個 admin:record + head entry 都寫 {tenant}::portal 子 namespace', async () => {
|
||||
it('console session OK → 建第一個 admin:寫進認證儲存(D61,不再落 KBDB)', async () => {
|
||||
await env.SESSIONS_KV.put('console_sess:owner-token', JSON.stringify({ created_at: Date.now() }));
|
||||
mockTemplatesExist();
|
||||
mockListByTemplate('portal_user', []); // 尚無 admin
|
||||
mockHeadLookup('admin@example.com', null); // email 未占用
|
||||
|
||||
let recordBody = '';
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/records', method: 'POST' })
|
||||
.reply(200, (opts) => {
|
||||
recordBody = String(opts.body);
|
||||
return { success: true, record: { record_id: 'rec_admin', template_id: 'tpl_pu', values: {} } };
|
||||
});
|
||||
let headBody = '';
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/entries', method: 'POST' })
|
||||
.reply(200, (opts) => {
|
||||
headBody = String(opts.body);
|
||||
return { success: true, entry: { id: 'e_head' } };
|
||||
});
|
||||
mockListByTemplate('portal_user', []); // 尚無 admin(新家空,舊家也空)
|
||||
mockHeadLookup('admin@example.com', null); // email 未占用(新家找不到 → 回退查舊家)
|
||||
const { puts } = mockAuthStoreWrite();
|
||||
|
||||
const res = await json(
|
||||
'POST',
|
||||
@@ -174,23 +225,25 @@ describe('POST /portal/admin/bootstrap', () => {
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as Record<string, unknown>;
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.record_id).toBe('rec_admin');
|
||||
expect(typeof data.record_id).toBe('string');
|
||||
expect((data.record_id as string).startsWith(AUTH_ID_PREFIX)).toBe(true); // 住新家(D61)
|
||||
expect(data.email).toBe('admin@example.com'); // 存小寫(design §2.1)
|
||||
|
||||
const rec = JSON.parse(recordBody) as { owner_id: string; values: Record<string, string>; template: string };
|
||||
expect(rec.template).toBe('portal_user');
|
||||
expect(rec.owner_id).toBe(NS); // ← D-2 子 namespace 機械斷言
|
||||
expect(rec.values.role).toBe('admin');
|
||||
expect(rec.values.status).toBe('active');
|
||||
expect(rec.values.libraries).toBe('["*"]');
|
||||
expect(rec.values.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
|
||||
expect(recordBody).not.toContain('bootstrap-pw-1'); // 明碼絕不落 KBDB
|
||||
|
||||
const head = JSON.parse(headBody) as Record<string, string>;
|
||||
expect(head.owner_id).toBe(NS);
|
||||
expect(head.entry_type).toBe('portal_user');
|
||||
expect(head.page_name).toBe('admin@example.com');
|
||||
expect(head.content).toBe('rec_admin');
|
||||
// D61:一次寫入=一片,落進認證儲存(Workers Secrets),不再有 KBDB record/head entry
|
||||
const shards = puts();
|
||||
expect(shards.length).toBe(1);
|
||||
expect(shards[0].name).toBe('ARCRUN_AUTH_STORE');
|
||||
const shard = JSON.parse(shards[0].text) as {
|
||||
users: Array<{ email: string; role: string; status: string; libraries: string[]; password_hash: string }>;
|
||||
};
|
||||
expect(shard.users.length).toBe(1);
|
||||
const stored = shard.users[0];
|
||||
expect(stored.email).toBe('admin@example.com');
|
||||
expect(stored.role).toBe('admin');
|
||||
expect(stored.status).toBe('active');
|
||||
expect(stored.libraries).toEqual(['*']);
|
||||
expect(stored.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
|
||||
expect(shards[0].text).not.toContain('bootstrap-pw-1'); // 明碼絕不落地
|
||||
});
|
||||
|
||||
it('已有 admin → 409 拒絕重複 bootstrap', async () => {
|
||||
@@ -210,6 +263,12 @@ describe('POST /portal/admin/bootstrap', () => {
|
||||
// ═══════════════ 3. 登入對錯 ═══════════════
|
||||
|
||||
describe('POST /portal/login', () => {
|
||||
// 🔴 這一區塊全部共用 EMAIL/'rec_1' 這組舊家 fixture(原本就是),**故意不**在這裡驗證
|
||||
// 「登入成功後搬進新家」——promoteLegacyUser 一旦真的寫成功,會把 EMAIL 留進 overlay,
|
||||
// 而 overlay 是模組級全域、同檔案後面的測試都讀得到,會讓後面每一則「查 KBDB 的 EMAIL」
|
||||
// 全部改成「命中新家」而跳過 KBDB mock,導致假性的 pending-interceptor 骨牌。
|
||||
// 搬遷本身的驗證另開一組使用**專屬、不共用**email 的 describe(見檔案最後
|
||||
// 「D61:舊實例登入自癒」),避免污染這裡的既有 fixture。
|
||||
it('成功:發 session token;回 display_name/role/libraries;**無任何租戶字串欄位**', async () => {
|
||||
mockHeadLookup(EMAIL, 'rec_1');
|
||||
mockGetRecord('rec_1', activeUserValues());
|
||||
@@ -227,6 +286,11 @@ describe('POST /portal/login', () => {
|
||||
const sess = await env.SESSIONS_KV.get(`portal_sess:${data.session_token}`);
|
||||
expect(sess).toBeTruthy();
|
||||
expect((JSON.parse(sess!) as { record_id: string }).record_id).toBe('rec_1'); // 只存 record_id
|
||||
// D61:promoteLegacyUser 的實際寫入嘗試沒有掛 CF API mock,disableNetConnect 之下
|
||||
// 該次 fetch 會失敗,但函式本身 best-effort 吞掉(見 portal.ts promoteLegacyUser 的
|
||||
// try/catch)——這正是要驗的事:搬不動不影響本次登入已經成功這件事實(上面兩個
|
||||
// expect 已經成立)。afterEach 的 assertNoPendingInterceptors 只檢查「有登記但沒用到」
|
||||
// 的 mock,一次沒登記過 mock 的失敗呼叫不算數,故這裡不需要(也不能)額外掛 CF API mock。
|
||||
});
|
||||
|
||||
it('密碼錯 → 401 通用訊息+lockfail 計數 +1', async () => {
|
||||
@@ -464,3 +528,57 @@ describe('t130 — triplet template seed(PORTAL_TEMPLATE_SEEDS 補 triplet,e
|
||||
expect(data.portal_templates.existing).not.toContain('triplet');
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ D61:舊實例登入自癒(搬進新家)═══════════════
|
||||
//
|
||||
// 🔴 放在檔案最後、用**專屬 email**(不與上面任何一則共用):portal-auth-store.ts 的
|
||||
// per-isolate overlay 是模組級全域變數,寫入一旦成功就會留在同一支測試檔案的後續測試裡
|
||||
// (見 mockAuthStoreWrite 檔頭的長註解)。這裡就是要驗證那次「留下」,所以刻意隔離在最後,
|
||||
// 不會有更後面的測試共用這個 email 而被污染。
|
||||
describe('D61:舊實例登入自癒(帳號只在 KBDB,登入成功後 best-effort 搬進認證儲存)', () => {
|
||||
const LEGACY_EMAIL = 'legacy-promote@example.com';
|
||||
|
||||
it('登入成功;promoteLegacyUser 把這筆帳號寫進認證儲存(一片、含正確 email/hash)', async () => {
|
||||
mockHeadLookup(LEGACY_EMAIL, 'rec_legacy_1');
|
||||
mockGetRecord('rec_legacy_1', activeUserValues({ email: LEGACY_EMAIL }));
|
||||
const { puts } = mockAuthStoreWrite();
|
||||
|
||||
const res = await json('POST', '/portal/login', { email: LEGACY_EMAIL, password: PASSWORD });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean };
|
||||
expect(data.success).toBe(true);
|
||||
|
||||
const shards = puts();
|
||||
expect(shards.length).toBe(1);
|
||||
expect(shards[0].name).toBe('ARCRUN_AUTH_STORE');
|
||||
const shard = JSON.parse(shards[0].text) as { users: Array<{ email: string; password_hash: string }> };
|
||||
const promoted = shard.users.find((u) => u.email === LEGACY_EMAIL);
|
||||
expect(promoted).toBeDefined();
|
||||
expect(promoted!.password_hash).toBe(storedHash); // 原樣搬過去,不重新雜湊
|
||||
});
|
||||
|
||||
it('若新家寫入路徑未就緒(缺 CF_SECRETS_API_TOKEN),照樣登入成功——搬不動不擋門', async () => {
|
||||
// 直接呼叫 router、帶一份缺寫入路徑的 env(health.test.ts 已有的直呼叫慣例),
|
||||
// 證明 promoteLegacyUser 的失敗被 best-effort 吞掉,不影響登入本身。
|
||||
const email = 'legacy-promote-writeless@example.com';
|
||||
mockHeadLookup(email, 'rec_legacy_2');
|
||||
mockGetRecord('rec_legacy_2', activeUserValues({ email }));
|
||||
const fakeEnv = { ...env, CF_SECRETS_API_TOKEN: undefined, CF_ACCOUNT_ID: undefined } as unknown as Bindings;
|
||||
const res = await portalRouter.fetch(
|
||||
new Request('http://localhost/portal/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password: PASSWORD }),
|
||||
}),
|
||||
fakeEnv,
|
||||
{} as ExecutionContext,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean };
|
||||
expect(data.success).toBe(true);
|
||||
// 沒掛 CF API mock:若程式碼真的嘗試網呼叫且被 disableNetConnect 擋下,錯誤仍會被
|
||||
// best-effort 吞掉(不影響上面的 200 斷言);若程式碼正確地在 authStoreWritable() 檢查
|
||||
// 就提前短路,則根本不會嘗試呼叫——兩種情況這裡都驗不出差異,差異由 afterEach 的
|
||||
// assertNoPendingInterceptors 間接把關(沒有殘留 mock 代表沒有意外多打的請求)。
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,3 +49,11 @@ KBDB_BASE_URL = "https://kbdb.test"
|
||||
CONSOLE_TENANT = "leo"
|
||||
# portal-auth P3:graph 粗閘放行後的轉發目標也指假 host(fetchMock 攔截,絕不外連)
|
||||
KBDB_GRAPH_URL = "https://graph.test"
|
||||
# D61(ADR D61 / Leo/arcrun-rag#55):認證儲存(lib/portal-auth-store.ts)走 CF Workers
|
||||
# Scripts secrets 管理 API(https://api.cloudflare.com/...),authStoreWritable() 只看這兩項
|
||||
# 存不存在。測試環境預設就緒(比照真實已裝妥的實例),值是明顯的假字串、非真實金鑰;實際的
|
||||
# PUT/DELETE 呼叫一律靠 tests/*.ts 裡的 fetchMock 攔截,不外連。要測「寫入路徑未就緒」
|
||||
# 的分支才需要繞過 SELF、直接呼叫 router.fetch(req, fakeEnv, ctx) 帶缺項的 env(見
|
||||
# tests/health.test.ts 既有前例)。
|
||||
CF_SECRETS_API_TOKEN = "test-fake-not-a-real-token" # credential-ok:測試假值,見上方註解
|
||||
CF_ACCOUNT_ID = "test-account"
|
||||
|
||||
Reference in New Issue
Block a user