feat(auth): 認證儲存搬回 D1/KV——落實方案C(leo confirm「走C」,arcrun-rag#99)

實作 pending-changes.md「認證儲存要不要搬回 D1/KV」提案(commit 8286c8a):
D61 的病根「重裝時 binding 被安裝器照名字重新指到新建空資源」已被更通用的
shared/resource-rule(Arcrun#97,2026-08-13)解掉,故不再需要繞開 binding
去躲這個病——而繞開的代價正是這次要收的債:Workers Secrets 寫入需要外部
CF_SECRETS_API_TOKEN,這把 token 從安裝那天起就沒被種過,止血版只解掉
「建第一個帳號」這一格,之後的每一次寫入(換密碼/加帳號/改權限)仍卡死。

改動:
- console 管理員帳密:家改回 SESSIONS_KV(binding,console-auth.ts)
- portal 多人帳號:家改回 KBDB(binding,走 base HTTP API,D38 零 SQL,portal.ts)
- D61 認證儲存(CF Workers Secrets)留為舊實例的唯讀回退路徑:讀取零成本、
  零外部憑證需求(只有寫入才要 token);登入成功即 best-effort 自動搬進新家,
  且**這次登入發出的 session 就直接指向新 record_id**(不必等下一次登入)
- D61 的三項「明顯失敗」語意全部保留:auth_store_empty(讀不到不算密碼錯、
  不計入鎖定)、/console/setup 遇既有帳號說清楚密碼沒被採用、/health 與
  /console/auth-status 吐儲存狀態
- 移除止血版的 x-arcrun-install-token 表頭傳遞機制(installToken 參數)——
  帳號寫入從此不需要任何外部 CF token,這個結構性缺口已從根拔除

測試:cypher-executor 全套 vitest 439/453(14 個既存失敗與本改動無關,已用
git stash 對照 clean checkout 逐一比對檔名確認完全相同);tsc --noEmit
無新增錯誤(3 個既存錯誤同上核實無關)。已跑 build-worker-artifacts.mjs
重打 tier2 bundle,grep 複驗 createKbdbUserRecord/promoteToKbdb 進了成品、
promoteLegacyUser/x-arcrun-install-token 完全從成品消失。

未覆蓋:POST /credentials(一般 workflow API 金鑰儲存)仍依賴
CF_SECRETS_API_TOKEN——這是 01-tech-stack.md 既有的、獨立於 D61 之外的
credential 儲存架構(D19「擁有目錄不擁有內容物」),本提案範圍只涵蓋「認證」
(登入帳密),不涵蓋一般 credential 儲存;07-29 已知缺口仍待另案處理。

不准 merge 進 main(SDD 鐵律③,等總管審過再併);不准部署(D20 出貨閘)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-08-14 12:52:07 +08:00
parent 8286c8afec
commit 4b6cc159b8
9 changed files with 591 additions and 570 deletions
@@ -9200,9 +9200,6 @@ function shardIndex(name) {
function shardNameOf(index) {
return index === 0 ? AUTH_STORE_PREFIX : `${AUTH_STORE_PREFIX}_${index}`;
}
function authStorePresent(env) {
return shardNames(env).length > 0 || overlay !== null && Date.now() - overlayAt < AUTH_OVERLAY_TTL_MS;
}
function authStoreWritable(env, tokenOverride) {
return Boolean((tokenOverride || env.CF_SECRETS_API_TOKEN) && env.CF_ACCOUNT_ID);
}
@@ -9240,11 +9237,6 @@ function findAuthUserById(env, id) {
function isAuthStoreId(recordId) {
return recordId.startsWith(AUTH_ID_PREFIX);
}
function newAuthUserId() {
const arr = new Uint8Array(12);
crypto.getRandomValues(arr);
return AUTH_ID_PREFIX + Array.from(arr).map((b) => b.toString(16).padStart(2, "0")).join("");
}
async function writeAuthStore(env, data, tokenOverride) {
if (!authStoreWritable(env, tokenOverride)) {
throw new AuthStoreWriteError(
@@ -9328,19 +9320,16 @@ async function mutateAuthStore(env, fn, tokenOverride) {
await writeAuthStore(env, next, tokenOverride);
return next;
}
function authStoreStatus(env) {
const data = readAuthStore(env);
return {
present: authStorePresent(env),
writable: authStoreWritable(env),
users: data.users.length,
console_configured: Boolean(data.console),
shards: shardNames(env).length
};
}
// cypher-executor/src/routes/health.ts
var healthRouter = new Hono2();
function authStoreStatus(env) {
const legacy = readAuthStore(env);
return {
console: { home: "sessions-kv", writable: true, legacy_secrets_present: legacy.console !== null },
portal_users: { home: "kbdb", writable: true, legacy_secrets_present: legacy.users.length > 0 }
};
}
healthRouter.get("/health", (c) => {
const bundleVersion = c.env.ARCRUN_BUNDLE_VERSION;
const bundleCommit = c.env.ARCRUN_BUNDLE_COMMIT;
@@ -12627,35 +12616,35 @@ function tenantOf(c) {
return knowledgeOwner(c.env);
}
async function loadCredentials(env) {
let fromStore = readAuthStore(env).console;
if (!fromStore && await hydrateFromAccelerator(env)) {
fromStore = readAuthStore(env).console;
}
if (fromStore) return { creds: fromStore, source: "secrets" };
const raw2 = await env.SESSIONS_KV.get(CREDS_KEY);
if (!raw2) return { creds: null, source: "none" };
let legacy = null;
try {
legacy = JSON.parse(raw2);
} catch {
return { creds: null, source: "none" };
if (raw2) {
try {
return { creds: JSON.parse(raw2), source: "kv" };
} catch {
}
}
const legacy = readAuthStore(env).console;
if (!legacy) return { creds: null, source: "none" };
try {
await mutateAuthStore(env, (data) => {
if (!data.console) data.console = legacy;
});
await env.SESSIONS_KV.put(CREDS_KEY, JSON.stringify(legacy));
} catch {
}
return { creds: legacy, source: "legacy-kv" };
return { creds: legacy, source: "legacy-secrets" };
}
async function saveCredentials(env, record, installToken) {
await mutateAuthStore(env, (data) => {
data.console = record;
}, installToken);
async function saveCredentials(env, record) {
await env.SESSIONS_KV.put(CREDS_KEY, JSON.stringify(record));
}
function consoleAuthStoreStatus(env) {
return {
home: "sessions-kv",
writable: true,
// binding-based,只要 wrangler.toml 有這個 binding 就一定寫得進去
legacy_secrets_present: readAuthStore(env).console !== null
};
}
consoleAuthRouter.get("/console/auth-status", async (c) => {
const { creds, source } = await loadCredentials(c.env);
return c.json({ configured: !!creds, credentials_source: source, auth_store: authStoreStatus(c.env) });
return c.json({ configured: !!creds, credentials_source: source, auth_store: consoleAuthStoreStatus(c.env) });
});
consoleAuthRouter.post("/console/setup", async (c) => {
const { creds: existing } = await loadCredentials(c.env);
@@ -12679,10 +12668,9 @@ consoleAuthRouter.post("/console/setup", async (c) => {
const hash = await hashPassword(password, salt);
const record = { email: email.toLowerCase(), salt, hash, created_at: (/* @__PURE__ */ new Date()).toISOString() };
try {
await saveCredentials(c.env, record, c.req.header("x-arcrun-install-token"));
await saveCredentials(c.env, record);
} catch (e) {
const msg = e instanceof AuthStoreWriteError ? e.message : String(e);
return c.json({ error: `\u5E33\u5BC6\u6C92\u6709\u5B58\u8D77\u4F86\uFF1A${msg}`, code: "auth_store_not_writable" }, 502);
return c.json({ error: `\u5E33\u5BC6\u6C92\u6709\u5B58\u8D77\u4F86\uFF1A${e instanceof Error ? e.message : String(e)}`, 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() }), {
@@ -12707,8 +12695,7 @@ consoleAuthRouter.post("/console/setup/reset", async (c) => {
try {
await saveCredentials(c.env, record);
} catch (e) {
const msg = e instanceof AuthStoreWriteError ? e.message : String(e);
return c.json({ error: `\u65B0\u5E33\u5BC6\u6C92\u6709\u5B58\u8D77\u4F86\uFF1A${msg}`, code: "auth_store_not_writable" }, 502);
return c.json({ error: `\u65B0\u5E33\u5BC6\u6C92\u6709\u5B58\u8D77\u4F86\uFF1A${e instanceof Error ? e.message : String(e)}`, code: "auth_store_not_writable" }, 502);
}
return c.json({ success: true });
});
@@ -12719,7 +12706,7 @@ consoleAuthRouter.post("/console/login", async (c) => {
{
error: "\u9019\u53F0\u5BE6\u4F8B\u9084\u6C92\u6709\u7BA1\u7406\u54E1\u5E33\u5BC6\uFF08\u6216\u8B80\u4E0D\u5230\uFF09\u2014\u2014\u4E0D\u662F\u5BC6\u78BC\u932F\u3002\u8ACB\u5148\u5B8C\u6210\u9996\u6B21\u8A2D\u5B9A\u3002",
code: "auth_store_empty",
auth_store: authStoreStatus(c.env)
auth_store: consoleAuthStoreStatus(c.env)
},
400
);
@@ -12728,18 +12715,8 @@ consoleAuthRouter.post("/console/login", async (c) => {
const email = (body?.email ?? "").trim().toLowerCase();
const password = body?.password ?? "";
if (!email || !password) return c.json({ error: "email \u8207 password \u5FC5\u586B" }, 400);
let creds = existing;
let hash = await hashPassword(password, creds.salt);
if (email !== creds.email || hash !== creds.hash) {
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) {
const hash = await hashPassword(password, existing.salt);
if (email !== existing.email || hash !== existing.hash) {
return c.json({ error: "email \u6216\u5BC6\u78BC\u932F\u8AA4" }, 401);
}
const token = randomHex(32);
@@ -13010,24 +12987,31 @@ function recordValuesToAuthUser(id, v) {
updated_at: v.updated_at ?? (/* @__PURE__ */ new Date()).toISOString()
};
}
async function promoteLegacyUser(env, rec) {
async function promoteToKbdb(env, rec) {
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));
if (!email) return null;
const already = await findKbdbUserRecordId(env, email);
if (already) return already;
return await createKbdbUserRecord(env, email, {
display_name: rec.values.display_name ?? "",
status: rec.values.status ?? "active",
role: rec.values.role ?? "user",
password_hash: rec.values.password_hash ?? "",
libraries: rec.values.libraries ?? "[]",
created_at: rec.values.created_at ?? (/* @__PURE__ */ new Date()).toISOString(),
updated_at: rec.values.updated_at ?? (/* @__PURE__ */ new Date()).toISOString()
});
} catch {
return null;
}
}
async function findUserRecordId(env, email) {
const inStore = findAuthUserByEmail(env, email);
if (inStore) return inStore.id;
return findLegacyUserRecordId(env, email);
const inKbdb = await findKbdbUserRecordId(env, email);
if (inKbdb) return inKbdb;
return findAuthUserByEmail(env, email)?.id ?? null;
}
async function findLegacyUserRecordId(env, email) {
async function findKbdbUserRecordId(env, email) {
const ns = portalNamespace(env);
const params = new URLSearchParams({
page_name: email,
@@ -13096,42 +13080,53 @@ function daemonActiveKey(env) {
}
async function listRecordsByTemplate(env, template) {
if (template === USER_TEMPLATE) {
const fromStore = readAuthStore(env).users.map(authUserToRecord);
const seen = new Set(fromStore.map((r) => (r.values.email ?? "").toLowerCase()));
let legacy = [];
let fromKbdb = [];
try {
legacy = await listLegacyRecordsByTemplate(env, template);
fromKbdb = await listKbdbRecordsByTemplate(env, template);
} catch {
legacy = [];
fromKbdb = [];
}
return [...fromStore, ...legacy.filter((r) => !seen.has((r.values.email ?? "").toLowerCase()))];
const seen = new Set(fromKbdb.map((r) => (r.values.email ?? "").toLowerCase()));
const fromLegacy = readAuthStore(env).users.map(authUserToRecord).filter((r) => !seen.has((r.values.email ?? "").toLowerCase()));
return [...fromKbdb, ...fromLegacy];
}
return listLegacyRecordsByTemplate(env, template);
return listKbdbRecordsByTemplate(env, template);
}
async function listLegacyRecordsByTemplate(env, template) {
async function listKbdbRecordsByTemplate(env, template) {
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} \u2192 ${res.status}`);
const body = await res.json();
return body.records ?? [];
}
async function createPortalUser(env, input, installToken) {
async function createKbdbUserRecord(env, email, values) {
const ns = portalNamespace(env);
const res = await kbdbFetch(env, "/records", {
method: "POST",
body: JSON.stringify({ template: USER_TEMPLATE, owner_id: ns, values: { ...values, email } })
});
if (!res.ok) throw new KbdbError(`POST /records\uFF08portal_user\uFF09\u2192 ${res.status}`);
const body = await res.json();
const recordId = body.record?.record_id;
if (!recordId) throw new KbdbError("POST /records \u56DE\u61C9\u7F3A record_id");
const head = await kbdbFetch(env, "/entries", {
method: "POST",
body: JSON.stringify({ entry_type: USER_TEMPLATE, page_name: email, content: recordId, owner_id: ns })
});
if (!head.ok) throw new KbdbError(`head entry \u5EFA\u7ACB\u5931\u6557\uFF08record ${recordId} \u5DF2\u5EFA\uFF0C\u9700\u4EBA\u5DE5\u6536\u62FE\uFF09\u2192 ${head.status}`);
return recordId;
}
async function createPortalUser(env, input) {
const now2 = (/* @__PURE__ */ new Date()).toISOString();
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: now2,
updated_at: now2
});
}, installToken);
return id;
return createKbdbUserRecord(env, input.email.toLowerCase(), {
display_name: input.display_name,
status: "active",
role: input.role,
password_hash: input.password_hash,
libraries: JSON.stringify(input.libraries),
created_at: now2,
updated_at: now2
});
}
function parseLibraries(raw2) {
if (!raw2) return [];
@@ -13268,7 +13263,7 @@ async function clearLoginFail(env, email) {
async function instanceHasNoAuthData(env) {
if (readAuthStore(env).users.length > 0) return false;
try {
return (await listLegacyRecordsByTemplate(env, USER_TEMPLATE)).length === 0;
return (await listKbdbRecordsByTemplate(env, USER_TEMPLATE)).length === 0;
} catch {
return true;
}
@@ -13305,7 +13300,7 @@ portalRouter.post(
{
error: "\u9019\u53F0\u5BE6\u4F8B\u8B80\u4E0D\u5230\u4EFB\u4F55\u767B\u5165\u8CC7\u6599\u2014\u2014\u4E0D\u662F\u5BC6\u78BC\u932F\u3002\u8A8D\u8B49\u5132\u5B58\u662F\u7A7A\u7684\uFF0C\u8ACB\u91CD\u65B0\u57F7\u884C\u5B89\u88DD\uFF0F\u66F4\u65B0\u4EE5\u91CD\u65B0\u5EFA\u7ACB\u7BA1\u7406\u54E1\u5E33\u865F\u3002",
code: "auth_store_empty",
auth_store: authStoreStatus(c.env)
auth_store: { home: "kbdb", writable: true, users: 0 }
},
503
);
@@ -13320,10 +13315,14 @@ portalRouter.post(
await recordLoginFail(c.env, email);
return c.json({ error: "email \u6216\u5BC6\u78BC\u932F\u8AA4" }, 401);
}
if (!isAuthStoreId(recordId)) await promoteLegacyUser(c.env, rec);
let sessionRecordId = recordId;
if (isAuthStoreId(recordId)) {
const migrated = await promoteToKbdb(c.env, rec);
if (migrated) sessionRecordId = migrated;
}
await clearLoginFail(c.env, email);
const token = randomHex2(32);
await c.env.SESSIONS_KV.put(`${SESSION_PREFIX2}${token}`, JSON.stringify({ record_id: recordId }), {
await c.env.SESSIONS_KV.put(`${SESSION_PREFIX2}${token}`, JSON.stringify({ record_id: sessionRecordId }), {
expirationTtl: sessionTtl(c.env)
});
return c.json({
@@ -13555,7 +13554,7 @@ portalRouter.post(
libraries: ["*"],
// bootstrap admin 預設全庫(design §3.3["*"]=不注 library filter
password_hash: await hashPassword2(password)
}, c.req.header("x-arcrun-install-token"));
});
return c.json({ success: true, record_id: recordId, email, role: "admin" });
})
);
+5 -5
View File
@@ -1,18 +1,18 @@
{
"schema": 1,
"built_for": "arcrun-tier2-worker-artifacts",
"generated_at": "2026-08-14T04:06:12.523Z",
"repo_head": "ca1ed2aaf6525df247e8bbac5277bb77dc78ba6b",
"generated_at": "2026-08-14T04:50:51.373Z",
"repo_head": "8286c8afec1b225fcec21654cfd1cad3143231fe",
"repo_dirty": true,
"workers": [
{
"name": "arcrun-cypher-executor",
"source_dir": "cypher-executor",
"source_commit": "ca1ed2aaf6525df247e8bbac5277bb77dc78ba6b",
"source_commit": "8286c8afec1b225fcec21654cfd1cad3143231fe",
"main_module": "worker.mjs",
"main_file": "arcrun-cypher-executor/worker.mjs",
"js_bytes": 589408,
"content_sha256": "2c2fa5e2e0240ea27da424d543091b02d61b36ecc37310ca513220fe60c6096a",
"js_bytes": 589935,
"content_sha256": "8e6487478bc86bd289afaa5192cbd394b80b5235ea1eefb30ed716e6446a9312",
"modules": [],
"compat_date": "2025-02-19",
"compat_flags": [
+58 -71
View File
@@ -22,19 +22,18 @@
*/
import { Hono } from 'hono';
import type { Bindings } from '../types';
// D61ADR 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';
// D61 補充(2026-08-14leo confirm「走C」,pending-changes.md「認證儲存要不要搬回 D1/KV」):
// D61 把這組管理員帳密搬去認證儲存(CF Workers Secrets)是為了躲開「重裝時 binding 被安裝器
// 照名字重新指到新建的空資源」這個病根——但 Workers Secrets 的**寫入**需要外部
// `CF_SECRETS_API_TOKEN`,而這把 token 從安裝那天起就沒被種過,於是每一台全新實例永遠建不出
// 第一個帳號(arcrun-rag#99)。
// 病根本身已經在 2026-08-13 被更早、更通用的 `shared/resource-rule`Arcrun#97)解掉——
// 現在每次安裝/更新都會沿用既有 binding,不會再把 SESSIONS_KV 重指到空資源。既然病根已解,
// 就不需要為了躲 binding 而去揹「需要外部 token」這筆新債:**帳密改回住 SESSIONS_KV**
// `console:credentials`binding,永不需要外部 CF token)。
// 認證儲存(Workers Secrets)留著當「已經在跑 D61 的舊實例」的**讀路徑**——讀取零成本、
// 零外部憑證需求(只有寫入才要 token)——查到就順手搬回 SESSIONS_KV(見 loadCredentials)。
import { readAuthStore } from '../lib/portal-auth-store';
// Arcrun#108:租戶字串唯一產地。
import { knowledgeOwner } from '../lib/tenant';
@@ -94,58 +93,59 @@ function tenantOf(c: { env: Bindings }): string {
return knowledgeOwner(c.env);
}
// ── D61:帳密的家 ─────────────────────────────────────────────────────────────
// ── 帳密的家(2026-08-14 起:SESSIONS_KV 為主,認證儲存為舊實例回退讀路徑)────────────
/**
* 讀出 console 管理員帳密。**新家(Workers Secrets)優先**;沒有才回退舊家KV),
* 且一旦從舊家讀到就順手搬過去(best-effort,搬不動不影響本次登入)。
* 讀出 console 管理員帳密。**SESSIONS_KVbinding)優先**;沒有才回退舊家
* D61 的認證儲存,CF Workers Secrets——讀取零成本、零外部憑證需求),
* 且一旦從舊家讀到就順手搬回 SESSIONS_KVbest-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' };
async function loadCredentials(env: Bindings): Promise<{ creds: StoredCredentials | null; source: 'kv' | 'legacy-secrets' | 'none' }> {
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' };
if (raw) {
try {
return { creds: JSON.parse(raw) as StoredCredentials, source: 'kv' };
} catch {
/* KV 這份壞了,當作沒有,往下查舊家 */
}
}
// 舊家(D61 的認證儲存):純讀 env 字串,零網路呼叫、不需要任何外部 CF 憑證。
const legacy = readAuthStore(env).console;
if (!legacy) return { creds: null, source: 'none' };
try {
await mutateAuthStore(env, (data) => {
if (!data.console) data.console = legacy as AuthConsoleRecord;
});
// best-effort 搬回 SESSIONS_KV——這是 binding put,本來就不需要外部 token,
// 幾乎不會失敗;失敗也不影響本次用這份舊資料繼續(狀態看 /console/auth-status)。
await env.SESSIONS_KV.put(CREDS_KEY, JSON.stringify(legacy));
} catch {
/* 搬不動就照舊用 KV 這份(狀態看 /health 的 auth_store */
/* 照舊用這份,下次再試著搬一次 */
}
return { creds: legacy, source: 'legacy-kv' };
return { creds: legacy as StoredCredentials, source: 'legacy-secrets' };
}
/**
* 寫入 console 管理員帳密——**只寫新家**,不再寫 KV(寫回去等於把病種回土裡)。
*
* `installToken`2026-08-14arcrun-rag#99):見 `lib/portal-auth-store.ts authStoreWritable`
* 的完整說明——這是安裝精靈裝機當下遞來、cypher 自己不落地的臨時 CF token,補的是
* 「這台 worker 從沒被種過 CF_SECRETS_API_TOKEN」這個結構性缺口。
* 寫入 console 管理員帳密——**只寫 SESSIONS_KV**binding,永不需要外部 CF token)。
* 不再寫回認證儲存(Workers Secrets):那是要被淘汰的舊家,寫回去等於把債種回土裡。
*/
async function saveCredentials(env: Bindings, record: StoredCredentials, installToken?: string): Promise<void> {
await mutateAuthStore(env, (data) => {
data.console = record;
}, installToken);
async function saveCredentials(env: Bindings, record: StoredCredentials): Promise<void> {
await env.SESSIONS_KV.put(CREDS_KEY, JSON.stringify(record));
}
/** `/console/auth-status`、`/health` 共用的儲存狀態區塊(不洩漏 email/雜湊,只回統計)。 */
function consoleAuthStoreStatus(env: Bindings): { home: 'sessions-kv'; writable: true; legacy_secrets_present: boolean } {
return {
home: 'sessions-kv',
writable: true, // binding-based,只要 wrangler.toml 有這個 binding 就一定寫得進去
legacy_secrets_present: readAuthStore(env).console !== null,
};
}
// GET /console/auth-status — 前端用來決定顯示「首次設定」還是「登入」表單。不洩漏 email。
consoleAuthRouter.get('/console/auth-status', async (c) => {
const { creds, source } = await loadCredentials(c.env);
// D61多回一個 auth_store 區塊——「認證住在哪、寫不寫得進去」要在實例自己這一側看得出來,
// 多回一個 auth_store 區塊——「認證住在哪、寫不寫得進去」要在實例自己這一側看得出來,
// 不是等用戶登不進去才發現(#10「寧可明顯失敗,不要靜默錯置」)。
return c.json({ configured: !!creds, credentials_source: source, auth_store: authStoreStatus(c.env) });
return c.json({ configured: !!creds, credentials_source: source, auth_store: consoleAuthStoreStatus(c.env) });
});
// POST /console/setup — 首次設定帳密(body: {email, password})。已設定過 → 409(不可覆蓋,防外人搶注)。
@@ -177,14 +177,12 @@ consoleAuthRouter.post('/console/setup', async (c) => {
const hash = await hashPassword(password, salt);
const record: StoredCredentials = { email: email.toLowerCase(), salt, hash, created_at: new Date().toISOString() };
try {
// arcrun-rag#99:安裝精靈裝機當下把自己還有效的 OAuth token 隨這個表頭遞來
// 見 lib/portal-auth-store.ts authStoreWritable 的完整說明)。一般用戶自己在瀏覽器
// 敲 /console/setup 不會帶這個表頭,行為與今天完全一樣(沒有 token 就是沒有 override)。
await saveCredentials(c.env, record, c.req.header('x-arcrun-install-token'));
// 2026-08-14 起:寫 SESSIONS_KVbinding),不再需要安裝精靈遞任何臨時 CF token
// arcrun-rag#99 那個結構性缺口——見檔頭說明——已經隨儲存層搬回 binding 一併解掉)。
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);
// 寫不進去就誠實回報(不假綠:binding put 幾乎不會失敗,於是沒人處理過這條路)
return c.json({ error: `帳密沒有存起來:${e instanceof Error ? e.message : String(e)}`, code: 'auth_store_not_writable' }, 502);
}
const token = randomHex(32);
@@ -215,8 +213,7 @@ consoleAuthRouter.post('/console/setup/reset', async (c) => {
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({ error: `新帳密沒有存起來:${e instanceof Error ? e.message : String(e)}`, code: 'auth_store_not_writable' }, 502);
}
return c.json({ success: true });
});
@@ -225,12 +222,13 @@ consoleAuthRouter.post('/console/setup/reset', async (c) => {
consoleAuthRouter.post('/console/login', async (c) => {
const { creds: existing } = await loadCredentials(c.env);
if (!existing) {
// D61 明顯失敗:這是「這台實例讀不到認證資料」,不是「你帳密打錯」
// 明顯失敗(#10「寧可明顯失敗,不要靜默錯置」):這是「這台實例讀不到認證資料」,
// 不是「你帳密打錯」——兩句話混成一句正是 2026-08-09 leo 被誤鎖 15 分鐘的根因。
return c.json(
{
error: '這台實例還沒有管理員帳密(或讀不到)——不是密碼錯。請先完成首次設定。',
code: 'auth_store_empty',
auth_store: authStoreStatus(c.env),
auth_store: consoleAuthStoreStatus(c.env),
},
400,
);
@@ -241,19 +239,8 @@ consoleAuthRouter.post('/console/login', async (c) => {
const password = body?.password ?? '';
if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400);
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) {
const hash = await hashPassword(password, existing.salt);
if (email !== existing.email || hash !== existing.hash) {
return c.json({ error: 'email 或密碼錯誤' }, 401);
}
+18 -1
View File
@@ -1,9 +1,26 @@
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { authStoreStatus } from '../lib/portal-auth-store';
import { readAuthStore } from '../lib/portal-auth-store';
export const healthRouter = new Hono<{ Bindings: Bindings }>();
/**
* 認證儲存狀態(2026-08-14 起:console 帳密住 SESSIONS_KV、portal 帳號住 KBDB
* 兩者皆 binding-based,不再需要外部 CF token 才寫得進去——見 console-auth.tsportal.ts
* 檔頭「D61 補充」說明)。`legacy_*_present` 只回是否還有 D61 時代留在認證儲存(CF Workers
* Secrets)裡尚未搬遷的資料,不洩漏任何 email/雜湊內容。
*/
function authStoreStatus(env: Bindings): {
console: { home: 'sessions-kv'; writable: true; legacy_secrets_present: boolean };
portal_users: { home: 'kbdb'; writable: true; legacy_secrets_present: boolean };
} {
const legacy = readAuthStore(env);
return {
console: { home: 'sessions-kv', writable: true, legacy_secrets_present: legacy.console !== null },
portal_users: { home: 'kbdb', writable: true, legacy_secrets_present: legacy.users.length > 0 },
};
}
// t162leo 07-31 實撞:「小幫手一直顯示知識庫需要更新…重新更新後並不會消失」):
// daemon cloudVersionStale() 讀 /health 的 `bundle_version` 判斷是否過舊——
// 但本端點過去只回 {ok:true}**從沒吐這個欄位** ⇒ daemon 恆讀到空字串
+110 -74
View File
@@ -31,19 +31,25 @@ import { accountTenant, knowledgeOwner, ownerField, ownerQuery, tenantFromApiKey
// arcrun-rag#10/portal/admin/ai 存 Gemini key 走 credentials.ts 的**唯一**寫入路徑,
// 不在 portal 這層另造第二套儲存(D36:值進 Workers SecretD1 只留 ref)。
import { storeCredential, hasCredential } from './credentials';
// D61Leo/arcrun-rag#55ADR D61):**帳號不再住知識資料庫**。
// 讀寫一律先走 lib/portal-auth-storeCF Workers Secrets,不靠任何 binding),
// KBDB 只保留為「舊實例的既有帳號」回退讀路徑,且讀到就順手搬進新家(見 promoteLegacyUser)。
// D61 補充(2026-08-14leo confirm「走C」,pending-changes.md「認證儲存要不要搬回 D1/KV」):
// D61 把帳號搬去認證儲存(CF Workers Secrets)是為了躲開「重裝時 binding 被安裝器照名字
// 重新指到新建的空資源」這個病根——但 Workers Secrets 的**寫入**需要外部
// `CF_SECRETS_API_TOKEN`,這把 token 從安裝那天起就沒被種過,於是每一台全新實例永遠建不出
// 第一個帳號、也永遠加不了第二個(arcrun-rag#99)。
// 病根本身已經在 2026-08-13 被更早、更通用的 `shared/resource-rule`Arcrun#97)解掉——
// 現在每次安裝/更新都會沿用既有 binding,不會再把 KBDB D1 重指到空資源。既然病根已解,
// 就不需要為了躲 binding 而去揹「需要外部 token」這筆新債:**帳號改回住 KBDB**
// (走 base HTTP APID38 零 SQLbinding,永不需要外部 CF token)。
// 認證儲存(Workers Secrets)留著當「已經在跑 D61 的舊實例」的**讀路徑**——讀取零成本、
// 零外部憑證需求(只有寫入才要 token)——登入成功就順手搬進 KBDB(見 promoteToKbdb)。
import {
AuthStoreWriteError,
authStoreRecentlyWritten,
authStoreStatus,
findAuthUserByEmail,
findAuthUserById,
hydrateFromAccelerator,
isAuthStoreId,
mutateAuthStore,
newAuthUserId,
readAuthStore,
type AuthUserRecord,
} from '../lib/portal-auth-store';
@@ -114,7 +120,9 @@ export async function run(c: Context<{ Bindings: Bindings }>, fn: () => Promise<
try {
return await fn();
} catch (e) {
// D61認證儲存寫不進去要**看得出來是這件事**(不是 KBDB 的錯,也不是密碼的錯)
// 帳號還住在舊家(D61 認證儲存)時,寫入需要外部 CF_SECRETS_API_TOKEN——
// 新家(KBDB)不需要,但尚未搬遷的既有帳號仍可能撞到這格,要看得出來是這件事
// (不是 KBDB 的錯,也不是密碼的錯),且會在該帳號下次登入時自動搬進 KBDB 而解除。
if (e instanceof AuthStoreWriteError) {
return c.json({ error: `認證儲存寫入失敗:${e.message}`, code: 'auth_store_not_writable' }, 502);
}
@@ -193,7 +201,7 @@ export async function ensurePortalTemplates(
return { created, existing, errors };
}
// ── D61 認證儲存 ⇄ PortalRecord 轉換(呼叫端一律只認 PortalRecord,不必分辨住哪)─────
// ── 認證儲存(D61 舊家)⇄ PortalRecord 轉換(呼叫端一律只認 PortalRecord,不必分辨住哪)──
function authUserToRecord(u: AuthUserRecord): PortalRecord {
return {
@@ -227,33 +235,41 @@ function recordValuesToAuthUser(id: string, v: Record<string, string>): AuthUser
}
/**
* 舊實例自癒:在 KBDB 找到的既有帳號,原樣搬進認證儲存。
* best-effort——搬不動(寫入路徑未就緒)不影響這次登入,只是下次還會再走一次舊路。
* 這就是 #55「第一版不做跨版本遷移機制」的落地方式:**用一次成功的登入把自己搬過去**。
* 舊實例自癒:在認證儲存D61 舊家,CF Workers Secrets)找到的既有帳號,搬進 KBDB(新家)
* best-effort——搬不動(KBDB 不可達)不影響這次登入,只是下次還會再走一次舊路。
* 沿用 #55「第一版不做跨版本遷移機制」的落地方式:**用一次成功的登入把自己搬過去**。
* 回傳搬遷後的新 record_id;搬不動回 null(呼叫端沿用舊 record_id 繼續,讀路徑仍然通,
* 只是這次的 session 仍會落在舊家,下次登入會再試一次)。
*/
async function promoteLegacyUser(env: Bindings, rec: PortalRecord): Promise<void> {
async function promoteToKbdb(env: Bindings, rec: PortalRecord): Promise<string | null> {
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));
if (!email) return null;
const already = await findKbdbUserRecordId(env, email);
if (already) return already; // 更早一次登入已經搬過了,不重複建
return await createKbdbUserRecord(env, email, {
display_name: rec.values.display_name ?? '',
status: rec.values.status ?? 'active',
role: rec.values.role ?? 'user',
password_hash: rec.values.password_hash ?? '',
libraries: rec.values.libraries ?? '[]',
created_at: rec.values.created_at ?? new Date().toISOString(),
updated_at: rec.values.updated_at ?? new Date().toISOString(),
});
} catch {
/* 搬遷失敗不擋登入(誠實:狀態可從 /health 的 auth_store 看出來) */
return null; // 搬遷失敗不擋登入(誠實:狀態可從 /health 的 auth_store 看出來)
}
}
/** email → user record_id。**新家優先**;找不到才回退舊家(KBDB),並順手搬過去。 */
/** 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);
const inKbdb = await findKbdbUserRecordId(env, email);
if (inKbdb) return inKbdb;
return findAuthUserByEmail(env, email)?.id ?? null;
}
/** 舊家(KBDB的 email → record_iddesign §2.3 head entry O(1) 查找)。 */
async function findLegacyUserRecordId(env: Bindings, email: string): Promise<string | null> {
/** KBDB 的 email → record_iddesign §2.3 head entry O(1) 查找)。 */
async function findKbdbUserRecordId(env: Bindings, email: string): Promise<string | null> {
const ns = portalNamespace(env);
const params = new URLSearchParams({
page_name: email,
@@ -269,7 +285,8 @@ async function findLegacyUserRecordId(env: Bindings, email: string): Promise<str
}
async function getRecordById(env: Bindings, recordId: string): Promise<PortalRecord | null> {
// D61:住新家的帳號零網路呼叫直接讀 env(換 D1/換租戶代號都影響不到
// 舊家(D61 認證儲存)的帳號零網路呼叫直接讀 env(換 D1/換租戶代號都影響不到
// 這正是它當初被選為「不可能因重裝而不見」的理由——見 lib/portal-auth-store.ts 檔頭)。
if (isAuthStoreId(recordId)) {
const u = findAuthUserById(env, recordId);
return u ? authUserToRecord(u) : null;
@@ -282,7 +299,9 @@ 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改密碼/停用/改權限都在這條路上)
// 舊家(D61 認證儲存)的帳號改寫進 Workers Secrets需要 CF_SECRETS_API_TOKEN
// 沒有 token 就誠實拋 AuthStoreWriteError → run() 轉 502。這批帳號會在下次登入時
// 自動搬進 KBDB(見 promoteToKbdb),搬完之後就落進下面的 KBDB PATCH 分支。
if (isAuthStoreId(recordId)) {
let updated: AuthUserRecord | null = null;
await mutateAuthStore(env, (data) => {
@@ -329,24 +348,25 @@ function daemonActiveKey(env: Bindings): string {
}
export async function listRecordsByTemplate(env: Bindings, template: string): Promise<PortalRecord[]> {
// D61帳號清單=新家為主,舊家KBDB)尚未搬走的補在後面(同 email 以新家為準)。
// 家讀不到不算失敗——認證已經不靠它了,這裡只是把還沒搬完的人也列出來。
// 帳號清單=KBDB新家為主,認證儲存(舊家)尚未搬走的補在後面(同 email 以新家為準)。
// 家讀不到(KBDB 不可達)不算失敗——舊家仍是完整真相源之一,這裡只是把還沒搬完的人也列出來。
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[] = [];
let fromKbdb: PortalRecord[] = [];
try {
legacy = await listLegacyRecordsByTemplate(env, template);
fromKbdb = await listKbdbRecordsByTemplate(env, template);
} catch {
legacy = [];
fromKbdb = [];
}
return [...fromStore, ...legacy.filter((r) => !seen.has((r.values.email ?? '').toLowerCase()))];
const seen = new Set(fromKbdb.map((r) => (r.values.email ?? '').toLowerCase()));
const fromLegacy = readAuthStore(env).users.map(authUserToRecord)
.filter((r) => !seen.has((r.values.email ?? '').toLowerCase()));
return [...fromKbdb, ...fromLegacy];
}
return listLegacyRecordsByTemplate(env, template);
return listKbdbRecordsByTemplate(env, template);
}
/** KBDB 原生的 by-template 查詢(portal_library 等「資料」走這條,那些本來就住知識庫)。 */
async function listLegacyRecordsByTemplate(env: Bindings, template: string): Promise<PortalRecord[]> {
/** KBDB 原生的 by-template 查詢(portal_library 等「資料」走這條,那些本來就住知識庫)。 */
async function listKbdbRecordsByTemplate(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}`);
@@ -363,32 +383,44 @@ interface CreateUserInput {
}
/**
* 建帳號。**D61 起一律建在認證儲存(Workers Secrets),不再寫進 KBDB。**
* 寫入路徑未就緒就誠實拋錯(AuthStoreWriteError → 502),不偷偷退回舊家——
* 退回去等於這個帳號下次搬資料時又會不見,那正是本案要根治的病。
* 建 portal_user record(子 namespace)+ email head entrydesign §2.3)——低階寫入,
* `createPortalUser`(一般建帳號)與 `promoteToKbdb`(舊帳號搬遷)共用同一條寫入路徑。
*/
async function createKbdbUserRecord(env: Bindings, email: string, values: Record<string, string>): Promise<string> {
const ns = portalNamespace(env);
const res = await kbdbFetch(env, '/records', {
method: 'POST',
body: JSON.stringify({ template: USER_TEMPLATE, owner_id: ns, values: { ...values, email } }),
});
if (!res.ok) throw new KbdbError(`POST /recordsportal_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 entrypage_name=emailindexed)→ content=record_idO(1) 登入查找
const head = await kbdbFetch(env, '/entries', {
method: 'POST',
body: JSON.stringify({ entry_type: USER_TEMPLATE, page_name: email, content: recordId, owner_id: ns }),
});
if (!head.ok) throw new KbdbError(`head entry 建立失敗(record ${recordId} 已建,需人工收拾)→ ${head.status}`);
return recordId;
}
/**
* `installToken`2026-08-14arcrun-rag#99):只有 `/portal/admin/bootstrap`(安裝精靈那條路)
* 會傳這個值——見 `lib/portal-auth-store.ts authStoreWritable` 的完整說明。`/portal/admin/users`
* 這條「管理員事後手動加人」路徑不傳,行為不變(仍要 `env.CF_SECRETS_API_TOKEN` 就緒)。
* 建帳號。**2026-08-14 起改回一律建在 KBDB**binding,走 base HTTP APID38 零 SQL),
* 不再需要外部 `CF_SECRETS_API_TOKEN`——這正是本次補的結構性缺口(見檔頭「D61 補充」)。
*/
async function createPortalUser(env: Bindings, input: CreateUserInput, installToken?: string): Promise<string> {
async function createPortalUser(env: Bindings, input: CreateUserInput): Promise<string> {
const now = new Date().toISOString();
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,
});
}, installToken);
return id;
return createKbdbUserRecord(env, input.email.toLowerCase(), {
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,
});
}
// ── user 值域 helpers ──────────────────────────────────────────────────────
@@ -601,15 +633,15 @@ async function clearLoginFail(env: Bindings, email: string): Promise<void> {
}
/**
* D61這台實例是不是「一個帳號都沒有」(新家空、舊家也空/讀不到)。
* 只在「查無此帳號」時才呼叫,不進正常登入熱路徑。
* 這台實例是不是「一個帳號都沒有」(KBDB 新家空、認證儲存舊家也空/讀不到)。
* 只在「查無此帳號」時才呼叫,不進正常登入熱路徑(#10「寧可明顯失敗,不要靜默錯置」)
*/
async function instanceHasNoAuthData(env: Bindings): Promise<boolean> {
if (readAuthStore(env).users.length > 0) return false;
if (readAuthStore(env).users.length > 0) return false; // 舊家還有尚未搬遷的帳號
try {
return (await listLegacyRecordsByTemplate(env, USER_TEMPLATE)).length === 0;
return (await listKbdbRecordsByTemplate(env, USER_TEMPLATE)).length === 0;
} catch {
return true; // 舊家讀不到 家空 = 這台實例確實沒有可用的登入資料
return true; // KBDB 讀不到 家空 = 這台實例確實沒有可用的登入資料
}
}
@@ -660,7 +692,7 @@ portalRouter.post('/portal/login', (c) =>
const { recordId, rec, ok } = await findAndVerifyUser(c.env, email, password);
if (!recordId || !rec) {
// D61 明顯失敗(arcrun-rag#10「寧可明顯失敗,不要靜默錯置」套到門鎖上):
// 明顯失敗(arcrun-rag#10「寧可明顯失敗,不要靜默錯置」套到門鎖上):
// 「這台實例一個帳號都沒有」跟「你密碼打錯」是兩件事,不准混成同一句話——
// 2026-08-09 leo 就是被這個誤判鎖了 15 分鐘,而他的密碼從頭到尾都是對的。
// ⇒ 回一個**分得出來**的錯,而且**不計入鎖定**。
@@ -671,7 +703,7 @@ portalRouter.post('/portal/login', (c) =>
'這台實例讀不到任何登入資料——不是密碼錯。認證儲存是空的,' +
'請重新執行安裝/更新以重新建立管理員帳號。',
code: 'auth_store_empty',
auth_store: authStoreStatus(c.env),
auth_store: { home: 'kbdb', writable: true, users: 0 },
},
503,
);
@@ -687,14 +719,20 @@ portalRouter.post('/portal/login', (c) =>
return c.json({ error: 'email 或密碼錯誤' }, 401);
}
// D61 自癒:這次是拿舊家(KBDB)的帳號登進來的 → 順手搬進認證儲存
// 下次換庫/換租戶代號就不會再把他鎖在門外。
if (!isAuthStoreId(recordId)) await promoteLegacyUser(c.env, rec);
// 自癒:這次是拿舊家(D61 認證儲存)的帳號登進來的 → 順手搬進 KBDB(新家)
// 且**這次登入發出的 session 就直接指向新 record_id**(搬遷成功的話)——不必等
// 下一次登入,密碼變更/admin 編輯這類寫入這次就已經走得到不需要外部 token 的 KBDB 路。
// 搬不動(KBDB 不可達)就沿用舊 record_id,讀路徑仍然通,只是寫入仍會走舊家那格。
let sessionRecordId = recordId;
if (isAuthStoreId(recordId)) {
const migrated = await promoteToKbdb(c.env, rec);
if (migrated) sessionRecordId = migrated;
}
await clearLoginFail(c.env, email);
const token = randomHex(32);
// session 值只存 record_iddesign §4.3)——權限/狀態每請求回讀 record,不快取進 session
await c.env.SESSIONS_KV.put(`${SESSION_PREFIX}${token}`, JSON.stringify({ record_id: recordId }), {
await c.env.SESSIONS_KV.put(`${SESSION_PREFIX}${token}`, JSON.stringify({ record_id: sessionRecordId }), {
expirationTtl: sessionTtl(c.env),
});
return c.json({
@@ -1078,17 +1116,15 @@ portalRouter.post('/portal/admin/bootstrap', (c) =>
if (password.length < 8) return c.json({ error: '密碼至少 8 碼' }, 400);
if (await findUserRecordId(c.env, email)) return c.json({ error: '此 email 已存在' }, 409);
// arcrun-rag#99:安裝精靈裝機當下把自己還有效的 OAuth token 隨這個表頭遞來(一般用戶
// 自己在瀏覽器完成 bootstrap 不會帶這個表頭,行為與今天完全一樣)。只有這條「建立第一個
// admin」的路徑吃它——bootstrap 已經被上面的「已有 admin → 409」擋成只能成功一次,
// 不會被拿去反覆濫用;見 lib/portal-auth-store.ts authStoreWritable 的完整說明。
// 2026-08-14 起:createPortalUser 寫 KBDBbinding),不再需要安裝精靈遞任何臨時
// CF tokenarcrun-rag#99 那個結構性缺口已隨儲存層搬回 binding 一併解掉)。
const recordId = await createPortalUser(c.env, {
email,
display_name: displayName,
role: 'admin',
libraries: ['*'], // bootstrap admin 預設全庫(design §3.3["*"]=不注 library filter
password_hash: await hashPassword(password),
}, c.req.header('x-arcrun-install-token'));
});
return c.json({ success: true, record_id: recordId, email, role: 'admin' });
}),
);
@@ -1,17 +1,21 @@
/**
* console-auth.ts D61 SESSIONS_KV
* console-auth.ts D61 SESSIONS_KV
*
* portal-auth-store.ts per-isolate overlay
* console overlay.console ****
* worker
* tests/console-auth.test.ts
* KV
* 2026-08-14 D61 leo confirmCconsole SESSIONS_KVbinding
* D61 CF Workers Secrets
* CF_SECRETS_API_TOKEN
* SESSIONS_KVbest-effortbinding put
*
* portal-auth-store.ts
* worker console-auth.test.ts
* SESSIONS_KV
*/
import { SELF, env, fetchMock } from 'cloudflare:test';
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
import { mutateAuthStore } from '../src/lib/portal-auth-store';
import type { Bindings } from '../src/types';
const CF_API = 'https://api.cloudflare.com';
const CREDS_KEY = 'console:credentials';
beforeAll(() => {
fetchMock.activate();
@@ -27,18 +31,12 @@ function json(method: string, path: string, body?: unknown) {
});
}
function mockAuthStoreWrite(times = 1): { puts: () => Array<{ name: string; text: string }> } {
const captured: Array<{ name: string; text: string }> = [];
/** 種一筆進 D61 認證儲存(CF Workers Secrets)——直接呼叫 lib,不經任何 HTTP route。 */
function mockLegacySecretsWrite(): void {
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 };
.reply(200, { success: true });
}
/** console-auth.ts export sha256(salt+password) 3
@@ -57,45 +55,45 @@ 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 () => {
// 🔴 全部收在**同一個 `it()`** 裡(不拆成多則):`isolatedStorage`vitest-pool-workers 預設開)
// 在每一個 `it()` 前重置 SESSIONS_KV,但 D61 認證儲存的模組級記憶體變數 `overlay` 不受影響
// (見 lib/portal-auth-store.ts 檔頭)。若拆成多則,「搬回 SESSIONS_KV 後再打一次直接命中
// 新家」這一步在下一個 `it()` 會因為 KV 被重置而又落回 legacy-secrets 分支,驗不出「新家優先」
// 這件事——所以要在同一次測試、同一份 KV 狀態內連續打兩次才驗得出來。
describe('舊實例相容:console 帳密只在認證儲存(尚未搬回 SESSIONS_KV', () => {
it('GET /console/auth-status 讀到認證儲存這筆、順手搬回 SESSIONS_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();
mockLegacySecretsWrite();
await mutateAuthStore(env as unknown as Bindings, (data) => {
data.console = { email: EMAIL, salt: SALT, hash, created_at: '2026-01-01T00:00:00.000Z' };
});
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 };
auth_store: { legacy_secrets_present: 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);
expect(data.credentials_source).toBe('legacy-secrets'); // 這次是靠回退讀到的
// loadCredentials 內的 best-effort 搬遷在回應組出來之前就已 await 完成
expect(data.auth_store.legacy_secrets_present).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); // 原樣搬過去,不重新雜湊
});
const kvRaw = await env.SESSIONS_KV.get('console:credentials');
expect(kvRaw).toBeTruthy();
const stored = JSON.parse(kvRaw!) as { email: string; hash: string };
expect(stored.email).toBe(EMAIL);
expect(stored.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');
});
// 搬回後再打一次:SESSIONS_KV 已經有了,直接命中新家(不用再查認證儲存)
const res2 = await json('GET', '/console/auth-status');
const data2 = (await res2.json()) as { credentials_source: string };
expect(data2.credentials_source).toBe('kv');
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);
// 用搬回去的帳密登入 → 200(搬遷沒有讓帳密變得登不進去)
const login = await json('POST', '/console/login', { email: EMAIL, password: PASSWORD });
expect(login.status).toBe(200);
expect((await login.json() as { success: boolean }).success).toBe(true);
});
});
+63 -82
View File
@@ -1,81 +1,64 @@
/**
* console-auth.ts D61console ADR D61 / Leo/arcrun-rag#55
* console-auth.ts 2026-08-14 console SESSIONS_KV
* D61 pending-changes.md D1/KVleo confirmC
*
* /console/setup/console/login SESSIONS_KV `console:credentials`
* TTLKV binding KV
* console-auth.ts KV
* D61 CF Workers SecretsSESSIONS_KV 退
* /console/setup/console/login D61ADR D61 / Leo/arcrun-rag#55
* CF Workers Secrets binding
* Workers Secrets `CF_SECRETS_API_TOKEN` token
* arcrun-rag#99
* `shared/resource-rule`Arcrun#97 SESSIONS_KVbinding token
* 退 tests/console-auth-legacy.test.ts
*
* D61
*
* 1. auth-status configured:falselogin
* 2. POST /console/setup CF Workers Secrets KV
* 3. 409D61
* 2. POST /console/setup SESSIONS_KV
* 3. 409
*
* 4. 200 401
* 5. /console/setup/reset
* 5. /console/setup/reset SESSIONS_KV
*
* `https://api.cloudflare.com/.../secrets`PUT fetchMock host
* portal-auth.test.ts mockAuthStoreWritewrangler.test.toml
* CF_SECRETS_API_TOKEN/CF_ACCOUNT_ID
* CF API mockSESSIONS_KV bindingwrangler.test.toml mock KV
*
* 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
* 🔴 `@cloudflare/vitest-pool-workers`
* `isolatedStorage`** `it()`** KV/D1 storage bindings
* SESSIONS_KV
* POST /console/setup 沿 `it()`
* D61 `overlay` SESSIONS_KV
*/
import { SELF, env, fetchMock } from 'cloudflare:test';
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
import { SELF, env } from 'cloudflare:test';
import { 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> = {}) {
function json(method: string, path: string, body?: unknown) {
return SELF.fetch(`http://localhost${path}`, {
method,
headers: { 'Content-Type': 'application/json', ...headers },
headers: { 'Content-Type': 'application/json' },
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 };
/** 每則測試自建一組帳密(POST /console/setup),回傳供後續斷言使用。 */
async function setupOwner(email: string, password: string): Promise<void> {
const res = await json('POST', '/console/setup', { email, password });
expect(res.status).toBe(200);
}
const OWNER_EMAIL = 'owner@example.com';
const OWNER_PW = 'owner-first-pw-1';
// ═══════════════ 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 } };
const data = (await res.json()) as { configured: boolean; credentials_source: string; auth_store: { home: string } };
expect(data.configured).toBe(false);
expect(data.credentials_source).toBe('none');
expect(data.auth_store.home).toBe('sessions-kv');
expect(JSON.stringify(data)).not.toContain('@'); // 不洩漏 email
});
it('POST /console/login → 400「讀不到認證資料」,不是密碼錯(D61 明顯失敗)', async () => {
it('POST /console/login → 400「讀不到認證資料」,不是密碼錯(明顯失敗)', 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 };
@@ -91,36 +74,31 @@ describe('全新實例(尚未設定過任何管理員帳密)', () => {
});
});
// ═══════════════ 2. 首次設定:成功寫進認證儲存(D61 起唯一寫入路徑)═══════════════
// ═══════════════ 2. 首次設定:成功寫進 SESSIONS_KV ═══════════════
describe('POST /console/setup — 首次設定', () => {
it('成功:寫進認證儲存(不再寫 SESSIONS_KV),回 session_token', async () => {
const { puts } = mockAuthStoreWrite();
it('成功:寫進 SESSIONS_KV(binding,不需要任何外部 CF token),回 session_token', async () => {
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();
const raw = await env.SESSIONS_KV.get('console:credentials');
expect(raw).toBeTruthy();
expect(raw).not.toContain(OWNER_PW); // 明碼絕不落地
const stored = JSON.parse(raw!) as { email: string; salt: string; hash: string };
expect(stored.email).toBe(OWNER_EMAIL); // 存小寫
expect(typeof stored.salt).toBe('string');
expect(typeof stored.hash).toBe('string');
});
});
// ═══════════════ 3. 已設定過 → 409(D61 明顯失敗:說得出「沒有被採用」)═══════════════
// ═══════════════ 3. 已設定過 → 409(明顯失敗:說得出「沒有被採用」)═══════════════
describe('POST /console/setup — 已設定過(重複設定)', () => {
it('409,訊息明講「你剛才輸入的密碼沒有被採用」,不誤導成「設定成功」', async () => {
await setupOwner(OWNER_EMAIL, OWNER_PW);
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 {
@@ -130,22 +108,29 @@ describe('POST /console/setup — 已設定過(重複設定)', () => {
expect(data.password_applied).toBe(false);
expect(data.error).toContain('沒有被採用');
expect(data.reset_path).toBe('/console/setup/reset');
// 攻擊者填的帳密真的沒有生效:用它登入應該失敗(下一個 describe 也會正面驗證原帳密仍有效)
// 攻擊者填的帳密真的沒有生效:用它登入應該失敗,原帳密仍有效
const attackerLogin = await json('POST', '/console/login', { email: 'attacker@example.com', password: 'trying-to-hijack-1' });
expect(attackerLogin.status).toBe(401);
const ownerLogin = await json('POST', '/console/login', { email: OWNER_EMAIL, password: OWNER_PW });
expect(ownerLogin.status).toBe(200);
});
it('GET /console/auth-status → configured:truecredentials_source:secrets(新家優先命中)', async () => {
it('GET /console/auth-status → configured:truecredentials_source:kv', async () => {
await setupOwner(OWNER_EMAIL, OWNER_PW);
const res = await json('GET', '/console/auth-status');
const data = (await res.json()) as { configured: boolean; credentials_source: string; auth_store: { console_configured: boolean } };
const data = (await res.json()) as { configured: boolean; credentials_source: string; auth_store: { writable: boolean } };
expect(data.configured).toBe(true);
expect(data.credentials_source).toBe('secrets');
expect(data.auth_store.console_configured).toBe(true);
expect(data.credentials_source).toBe('kv');
expect(data.auth_store.writable).toBe(true);
});
});
// ═══════════════ 4. 登入對錯(用第 2 節設定的帳密)═══════════════
// ═══════════════ 4. 登入對錯 ═══════════════
describe('POST /console/login', () => {
it('帳密正確 → 200,發 session token', async () => {
await setupOwner(OWNER_EMAIL, OWNER_PW);
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 };
@@ -154,30 +139,27 @@ describe('POST /console/login', () => {
});
it('密碼錯 → 401', async () => {
await setupOwner(OWNER_EMAIL, OWNER_PW);
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:換密碼,寫進新家 ═══════════════
// ═══════════════ 5. /console/setup/reset:換密碼,寫進 SESSIONS_KV ═══════════════
describe('POST /console/setup/reset', () => {
const NEW_PW = 'brand-new-owner-pw-1';
it('舊密碼錯 → 401,不寫入', async () => {
await setupOwner(OWNER_EMAIL, OWNER_PW);
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();
it('舊密碼對 → 200,新 hash 寫進 SESSIONS_KV;換完後舊密碼立即失效、新密碼生效', async () => {
await setupOwner(OWNER_EMAIL, OWNER_PW);
const res = await json('POST', '/console/setup/reset', {
current_password: OWNER_PW, email: OWNER_EMAIL, password: NEW_PW,
});
@@ -185,11 +167,10 @@ describe('POST /console/setup/reset', () => {
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 raw = await env.SESSIONS_KV.get('console:credentials');
expect(raw).not.toContain(NEW_PW); // 明碼不落地
const stored = JSON.parse(raw!) as { email: string };
expect(stored.email).toBe(OWNER_EMAIL);
// 舊密碼立即失效
const oldLogin = await json('POST', '/console/login', { email: OWNER_EMAIL, password: OWNER_PW });
+33 -32
View File
@@ -16,20 +16,20 @@
* KBDB fetchMock hostwrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+
* disableNetConnectUI worker curl PR
*
* D61ADR D61 / Leo/arcrun-rag#55 fixturerec_admin/rec_u1/rec_admin2
* 沿record_id auth: 開頭 portal.ts
* isAuthStoreId(recordId) auth: 開頭的 id KBDB D61
* ****POST /portal/admin/users
* POST /portal/admin/bootstrap createPortalUserCF Workers
* Secrets `https://api.cloudflare.com/.../secrets`PUT mockAuthStoreWrite
* 2026-08-14 D61 leo confirmC KBDBbinding
* pending-changes.md D1/KV fixture
* rec_admin/rec_u1/rec_admin2沿record_id auth: 開頭
* portal.ts isAuthStoreId(recordId) auth: 開頭的 id
* KBDB D61
* ****POST /portal/admin/usersPOST /portal/admin/bootstrap
* createPortalUser KBDBPOST /records + POST /entries head
* `https://api.cloudflare.com/.../secrets`
*/
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;
@@ -49,19 +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 }> = [];
/** 建帳號的 KBDB 寫入路徑(POST /records + POST /entries head entry)。 */
function mockCreateUser(recordId: string): { recordBody: () => string } {
let recordBody = '';
fetchMock
.get(CF_API)
.intercept({ path: (p: string) => p.includes('/secrets'), method: 'PUT' })
.get(KBDB)
.intercept({ path: '/records', method: 'POST' })
.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 };
recordBody = String(opts.body);
return { success: true, record: { record_id: recordId, template_id: 'tpl_pu', values: {} } };
});
fetchMock
.get(KBDB)
.intercept({ path: '/entries', method: 'POST' })
.reply(200, { success: true, entry: { id: `${recordId}_head` } });
return { recordBody: () => recordBody };
}
function mockHeadLookup(email: string, recordId: string | null) {
@@ -202,11 +204,12 @@ describe('last-admin 鎖死保護(PATCH /portal/admin/users/:id', () => {
// ═══════════════ 2. 一次性密碼(新增帳號)═══════════════
describe('POST /portal/admin/users(一次性密碼)', () => {
it('未帶 password → generated_password 回一次(16 碼);認證儲存落的是 hash 非明碼D61', async () => {
it('未帶 password → generated_password 回一次(16 碼);KBDB 落的是 hash 非明碼', async () => {
await seedAdminSession();
mockGetRecord('rec_admin', adminValues());
mockHeadLookup('new@example.com', null); // email 未占用(新家找不到 → 回退查舊家)
const { puts } = mockAuthStoreWrite();
mockHeadLookup('new@example.com', null); // email 未占用
const { recordBody } = mockCreateUser('rec_new');
mockGetRecord('rec_new', userValues({ email: 'new@example.com' })); // 回應用的回讀
const res = await json(
'POST',
'/portal/admin/users',
@@ -218,23 +221,21 @@ 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);
expect((data.user as { record_id: string }).record_id.startsWith(AUTH_ID_PREFIX)).toBe(true); // 住新家
expect((data.user as { record_id: string }).record_id).toBe('rec_new'); // 住 KBDB
// 一次性密碼不落地:認證儲存收到的 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);
// 一次性密碼不落地: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);
});
it('自帶 password → 回應**無** generated_password', async () => {
await seedAdminSession();
mockGetRecord('rec_admin', adminValues());
mockHeadLookup('own@example.com', null);
mockAuthStoreWrite();
mockCreateUser('rec_own');
mockGetRecord('rec_own', userValues({ email: 'own@example.com' }));
const res = await json(
'POST',
'/portal/admin/users',
+168 -166
View File
@@ -4,31 +4,29 @@
* =tasks.md P2
* 1. KDFpbkdf2-sha256$100000$ CF Workers runtime 100k2026-07-14
* false600k hash
* 2. bootstrap console session 401 admin ****D61
* admin 409
* 2. bootstrap console session 401 admin **KBDB** admin 409
* 3. token**** 401 403 email 401
* 4. 5 429KV TTL
* 5. session record session
* 6. hash 100k slot
* 7. role admin admin 403admin ** password_hash**
*
* D61ADR D61 / Leo/arcrun-rag#55
* 2026-08-14 D61 leo confirmCpending-changes.md
* D1/KVCF Workers SecretsD61 **KBDB**binding base
* HTTP API CF token
* 8.
* 9. KBDB
* 9. D61 KBDB
* ** session record_id**KBDB
*
* KBDB fetchMock hostwrangler.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 { beforeAll, 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 { AUTH_ID_PREFIX, isAuthStoreId, mutateAuthStore } from '../src/lib/portal-auth-store';
import { portalRouter } from '../src/routes/portal';
import type { Bindings, ExecutionContext } from '../src/types';
@@ -56,38 +54,13 @@ 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 */
function mockHeadLookup(email: string, recordId: string | null) {
/**
* head entry GET /entries?page_name=&entry_type=portal_user&owner_id=ns&limit=1
* `times``findUserRecordId``promoteToKbdb` email
*/
function mockHeadLookup(email: string, recordId: string | null, times = 1) {
const needle = new URLSearchParams({ page_name: email }).toString();
fetchMock
.get(KBDB)
@@ -96,7 +69,61 @@ function mockHeadLookup(email: string, recordId: string | null) {
p.startsWith('/entries?') && p.includes(needle) && p.includes(encodeURIComponent(NS)),
method: 'GET',
})
.reply(200, { success: true, entries: recordId ? [{ content: recordId }] : [], count: recordId ? 1 : 0, total: recordId ? 1 : 0 });
.reply(200, { success: true, entries: recordId ? [{ content: recordId }] : [], count: recordId ? 1 : 0, total: recordId ? 1 : 0 })
.times(times);
}
/** 建 portal_user 的 KBDB 寫入路徑(POST /records + POST /entries head entry)。 */
function mockCreateUser(recordId: string): { recordBody: () => string } {
let recordBody = '';
fetchMock
.get(KBDB)
.intercept({ path: '/records', method: 'POST' })
.reply(200, (opts) => {
recordBody = String(opts.body);
return { success: true, record: { record_id: recordId, template_id: 'tpl_pu', values: {} } };
});
fetchMock
.get(KBDB)
.intercept({ path: '/entries', method: 'POST' })
.reply(200, { success: true, entry: { id: `${recordId}_head` } });
return { recordBody: () => recordBody };
}
/** POST /records 建立失敗(模擬 KBDB 拒寫,用於「搬遷失敗不擋登入」的測試)。 */
function mockCreateUserFails(status = 500) {
fetchMock
.get(KBDB)
.intercept({ path: '/records', method: 'POST' })
.reply(status, { success: false, error: 'kbdb write failed (test)' });
}
/**
* ****D61 CF Workers Secrets lib HTTP
* routeconsoleAuthRouter/portalRouter CF Secrets PUT
* wrangler.test.toml CF_SECRETS_API_TOKEN/CF_ACCOUNT_ID
*/
async function seedLegacySecretsUser(overrides: Partial<{
email: string; display_name: string; status: string; role: string; libraries: string[]; password_hash: string;
}> = {}): Promise<void> {
fetchMock
.get(CF_API)
.intercept({ path: (p: string) => p.includes('/secrets'), method: 'PUT' })
.reply(200, { success: true });
const now = new Date().toISOString();
await mutateAuthStore(env as unknown as Bindings, (data) => {
data.users.push({
id: `auth:${crypto.randomUUID().replace(/-/g, '')}`,
email: (overrides.email ?? EMAIL).toLowerCase(),
display_name: overrides.display_name ?? '舊實例同仁',
status: overrides.status ?? 'active',
role: overrides.role ?? 'user',
libraries: overrides.libraries ?? ['general'],
password_hash: overrides.password_hash ?? storedHash,
created_at: now,
updated_at: now,
});
});
}
function mockGetRecord(recordId: string, values: Record<string, string>) {
@@ -176,20 +203,20 @@ 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#55leo 2026-08-09 被誤鎖 15 分鐘的事故)', () => {
// 🔴 這個 describe 必須留在檔案裡「第一個會入認證儲存(舊家)的測試」之前——見
// seedLegacySecretsUser 檔頭註解:portal-auth-store.ts 的 per-isolate overlay 是模組級全域
// 變數,同一支測試檔案跑起來不會在測試之間重置,一旦有測試種過舊家資料,後面的測試都會看到
// 那筆資料,「乾淨無帳號」的前提就不成立了。
describe('整台實例沒有任何認證資料(arcrun-rag#55leo 2026-08-09 被誤鎖 15 分鐘的事故)', () => {
it('登入回「讀不到認證資料」而不是「密碼錯誤」,且不計入失敗鎖定', async () => {
// 新家(overlay/env bag)此刻還是空的(本測試特意排在任何寫入測試之前)
// 舊家(KBDB)也回空——head lookup 查無此人+by-template 列表也空,兩邊都沒有帳號,
// 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 } };
const data = (await res.json()) as { error: string; code: string; auth_store: { users: number } };
expect(data.code).toBe('auth_store_empty');
// 分得出來的錯:這句要誠實講「不是密碼錯」,而且**不能**是密碼錯誤那句通用訊息
// (文案含混是 leo 被鎖 15 分鐘的根因——他的密碼從頭到尾是對的)。
@@ -209,12 +236,12 @@ describe('POST /portal/admin/bootstrap', () => {
expect(res.status).toBe(401);
});
it('console session OK → 建第一個 admin:寫進認證儲存(D61,不再落 KBDB', async () => {
it('console session OK → 建第一個 admin:寫進 KBDB,不需要任何外部 CF token', 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 未占用(新家找不到 → 回退查舊家)
const { puts } = mockAuthStoreWrite();
mockListByTemplate('portal_user', []); // 尚無 adminKBDB 空,認證儲存舊家也空)
mockHeadLookup('admin@example.com', null); // email 未占用
const { recordBody } = mockCreateUser('rec_admin_new');
const res = await json(
'POST',
@@ -225,25 +252,18 @@ 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(typeof data.record_id).toBe('string');
expect((data.record_id as string).startsWith(AUTH_ID_PREFIX)).toBe(true); // 住新家(D61
expect(data.record_id).toBe('rec_admin_new'); // 住 KBDB(新 record_id 來自 KBDB 回應)
expect(data.email).toBe('admin@example.com'); // 存小寫(design §2.1
// 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'); // 明碼絕不落地
// 一次 POST /records=一筆,body 含 owner_id 子 namespace,明碼絕不落地
const rec = JSON.parse(recordBody()) as { owner_id: string; values: Record<string, string> };
expect(rec.owner_id).toBe(NS);
expect(rec.values.email).toBe('admin@example.com');
expect(rec.values.role).toBe('admin');
expect(rec.values.status).toBe('active');
expect(JSON.parse(rec.values.libraries)).toEqual(['*']);
expect(rec.values.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
expect(recordBody()).not.toContain('bootstrap-pw-1'); // 明碼絕不落地
});
it('已有 admin → 409 拒絕重複 bootstrap', async () => {
@@ -263,12 +283,10 @@ 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。
// 🔴 這一區塊全部共用 EMAIL/'rec_1' 這組 **KBDB 原生** fixture(非 auth: 開頭 id)——
// 2026-08-14 起 KBDB 是新家,這批帳號本來就住在該住的地方,**不會**觸發任何搬遷嘗試
// isAuthStoreId('rec_1') 為 false)。搬遷本身的驗證另開一組使用**專屬、不共用**email
// 的 describe(見檔案最後「舊實例登入自癒」),避免污染這裡的既有 fixture
it('成功:發 session token;回 display_name/role/libraries**無任何租戶字串欄位**', async () => {
mockHeadLookup(EMAIL, 'rec_1');
mockGetRecord('rec_1', activeUserValues());
@@ -286,11 +304,6 @@ 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
// D61promoteLegacyUser 的實際寫入嘗試沒有掛 CF API mockdisableNetConnect 之下
// 該次 fetch 會失敗,但函式本身 best-effort 吞掉(見 portal.ts promoteLegacyUser 的
// try/catch)——這正是要驗的事:搬不動不影響本次登入已經成功這件事實(上面兩個
// expect 已經成立)。afterEach 的 assertNoPendingInterceptors 只檢查「有登記但沒用到」
// 的 mock,一次沒登記過 mock 的失敗呼叫不算數,故這裡不需要(也不能)額外掛 CF API mock。
});
it('密碼錯 → 401 通用訊息+lockfail 計數 +1', async () => {
@@ -305,6 +318,10 @@ describe('POST /portal/login', () => {
it('未知 email → 401 同樣通用訊息(不洩帳號存在性)', async () => {
mockHeadLookup('ghost@example.com', null);
// instanceHasNoAuthData 在「查無此帳號」時會確認「是不是整台實例都沒帳號」——
// 這裡要證明的是「查無此人」而非「這台實例是空的」,故 by-template 要回非空列表
// (這台實例確實有別的帳號,只是不是 ghost@example.com)。
mockListByTemplate('portal_user', [{ record_id: 'rec_1', values: activeUserValues() }]);
const res = await json('POST', '/portal/login', { email: 'ghost@example.com', password: 'whatever-123' });
expect(res.status).toBe(401);
const data = (await res.json()) as { error: string };
@@ -529,57 +546,52 @@ describe('t130 — triplet template seedPORTAL_TEMPLATE_SEEDS 補 triplete
});
});
// ═══════════════ D61舊實例登入自癒(搬進新家)═══════════════
// ═══════════════ 舊實例登入自癒(認證儲存舊家 → 搬進 KBDB 新家)═══════════════
//
// 🔴 放在檔案最後、用**專屬 email**(不與上面任何一則共用):portal-auth-store.ts 的
// per-isolate overlay 是模組級全域變數,寫入一旦成功就會留在同一支測試檔案的後續測試裡
// (見 mockAuthStoreWrite 檔頭的長註解)。這裡就是要驗證那次「留下」,所以刻意隔離在最後,
// per-isolate overlay 是模組級全域變數,seedLegacySecretsUser 種一次資料就會留在同一支測試
// 檔案的後續測試裡(見該函式檔頭的長註解)。這裡就是要驗證那次「留下」,所以刻意隔離在最後,
// 不會有更後面的測試共用這個 email 而被污染。
describe('D61舊實例登入自癒(帳號只在 KBDB,登入成功後 best-effort 搬進認證儲存)', () => {
describe('舊實例登入自癒(帳號只在認證儲存舊家,登入成功後搬進 KBDB 新家', () => {
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();
it('登入成功;自動把這筆帳號寫進 KBDB(含正確 email/hash),且**這次的 session 就指向新 record_id**', async () => {
await seedLegacySecretsUser({ email: LEGACY_EMAIL });
// KBDB 查無此人:一次給 findUserRecordId(登入查找)、一次給 promoteToKbdb(搬遷前的
// 「已經搬過了嗎」確認)——兩次都要 mock,見 mockHeadLookup 的 times 參數說明。
mockHeadLookup(LEGACY_EMAIL, null, 2);
const { recordBody } = mockCreateUser('rec_legacy_promoted_1');
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 };
const data = (await res.json()) as { success: boolean; session_token: string };
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); // 原樣搬過去,不重新雜湊
const rec = JSON.parse(recordBody()) as { owner_id: string; values: Record<string, string> };
expect(rec.owner_id).toBe(NS);
expect(rec.values.email).toBe(LEGACY_EMAIL);
expect(rec.values.password_hash).toBe(storedHash); // 原樣搬過去,不重新雜湊
// 這次登入發出的 session 已經指向新 record_id(不必等下一次登入才生效)
const sess = await env.SESSIONS_KV.get(`portal_sess:${data.session_token}`);
expect((JSON.parse(sess!) as { record_id: string }).record_id).toBe('rec_legacy_promoted_1');
});
it('若新家寫入路徑未就緒(缺 CF_SECRETS_API_TOKEN),照樣登入成功——搬不動不擋門', async () => {
// 直接呼叫 router、帶一份缺寫入路徑的 envhealth.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,
);
it('KBDB 拒寫(搬遷失敗)——照樣登入成功,session 沿用舊家 record_id搬不動不擋門', async () => {
const email = 'legacy-promote-writefail@example.com';
await seedLegacySecretsUser({ email });
mockHeadLookup(email, null, 2); // 登入查找 + 搬遷前確認,KBDB 兩次都查無此人
mockCreateUserFails(500); // 搬遷寫入本身失敗(模擬 KBDB 不可達/拒寫)
const res = await json('POST', '/portal/login', { email, password: PASSWORD });
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean };
const data = (await res.json()) as { success: boolean; session_token: string };
expect(data.success).toBe(true);
// 沒掛 CF API mock:若程式碼真的嘗試網呼叫且被 disableNetConnect 擋下,錯誤仍會被
// best-effort 吞掉(不影響上面的 200 斷言);若程式碼正確地在 authStoreWritable() 檢查
// 就提前短路,則根本不會嘗試呼叫——兩種情況這裡都驗不出差異,差異由 afterEach 的
// assertNoPendingInterceptors 間接把關(沒有殘留 mock 代表沒有意外多打的請求)。
// 搬不動 → session 沿用舊家(認證儲存)的 record_id"auth:" 開頭),讀路徑仍然通
const sess = await env.SESSIONS_KV.get(`portal_sess:${data.session_token}`);
const sessRecordId = (JSON.parse(sess!) as { record_id: string }).record_id;
expect(isAuthStoreId(sessRecordId)).toBe(true);
});
});
@@ -587,7 +599,7 @@ describe('D61:舊實例登入自癒(帳號只在 KBDB,登入成功後 best
//
// ⚠️ 順序刻意:這兩個 describe 放在檔案最後,而且「D62」在前、「#66」在後。
// 原因=#66 那組會**故意把 per-isolate overlay 灌成一份沒有任何帳號的資料**(模擬傳播空窗),
// 而 overlay 是模組級全域變數、不隨 test 重置(見 mockAuthStoreWrite 檔頭長註解)。
// 而 overlay 是模組級全域變數、不隨 test 重置(見 seedLegacySecretsUser 檔頭長註解)。
// 任何需要「認證儲存裡有帳號」的測試都不能排在它後面。
describe('D62:改密碼與忘記密碼是同一個機制(同一支端點、同一條寫入路徑)', () => {
@@ -686,69 +698,59 @@ describe('arcrun-rag#66:傳播空窗期不可以銷毀 session', () => {
// ═══════════════ arcrun-rag#992026-08-14):全新安裝從沒種過 CF_SECRETS_API_TOKEN ═══════════════
//
// 每一台裝好的新實例過去都會在 bootstrap 這裡卡死(leo 本人+封測者都撞到「裝得起來,
// 但卡在註冊」——`/console/auth-status` 永遠回 `writable:false`)。安裝精靈裝機當下手上有
// 一把自己還有效的 OAuth token,讓它隨 `/portal/admin/bootstrap` 請求帶入
// `x-arcrun-install-token` 表頭),cypher 收到才用這一次、不落地(見 credentials.ts
// putWorkerSecret 的完整說明)。下面兩則證明:①這確實是原本會擋死的斷點 ②帶表頭後真的解掉
// 但卡在註冊」——`/console/auth-status` 永遠回 `writable:false`)。止血版(安裝精靈遞一把
// 臨時 OAuth token)只解掉「建第一個帳號」這一格;2026-08-14 confirm 走 C 之後,帳號改回
// 住 KBDBbinding),**從根拔掉整個問題**`x-arcrun-install-token` 這條路已經不存在了,
// 因為建帳號這件事本來就不再需要任何 CF API 憑證,無論裝機當下還是裝完之後都一樣
//
// 🔴 刻意放在檔案最後:這兩則會真的寫入認證儲存(per-isolate overlay 是模組級全域變數
// 不隨 test 重置,見 mockAuthStoreWrite 檔頭長註解),排在前面會污染後面測試的「乾淨」假設。
describe('arcrun-rag#99:全新實例(缺 CF_SECRETS_API_TOKEN)靠安裝表頭補完寫入路徑', () => {
it('沒帶安裝表頭 → 502 auth_store_not_writable,證明這是真斷點(不是想像出來的假設)', async () => {
// 下面這則是回歸守衛:**一台連 `CF_SECRETS_API_TOKEN``CF_ACCOUNT_ID` 都沒有的全新實例**
// 從 bootstrap 建第一個帳號、到之後用 /portal/admin/users 加第二個帳號,全程都要成功——
// 若哪天有人把帳號寫入路徑又改回去揹 CF Secrets 依賴,這裡會紅。
describe('arcrun-rag#99:全新實例(連 CF_SECRETS_API_TOKENCF_ACCOUNT_ID 都沒有)不再卡死', () => {
it('bootstrap 建第一個 admin、再用 admin session 加第二個 user,全程零 CF token', async () => {
await env.SESSIONS_KV.put('console_sess:owner-token-fresh', JSON.stringify({ created_at: Date.now() }));
mockTemplatesExist();
mockListByTemplate('portal_user', []);
mockHeadLookup('fresh-install-noheader@example.com', null);
// 刻意不掛 mockAuthStoreWriteauthStoreWritable() 應該在打任何 CF API 之前就短路。
// 若程式碼退化成先打了 fetch 才失敗,這裡沒有攔截器會接住它,disableNetConnect 讓那次
// 意外的 fetch 直接拋錯,一樣會讓這則測試失敗——兩種退化路徑都攔得住。
const fakeEnv = { ...env, CF_SECRETS_API_TOKEN: undefined } as unknown as Bindings;
const res = await portalRouter.fetch(
mockListByTemplate('portal_user', []); // 尚無 admin
mockHeadLookup('fresh-install-admin@example.com', null);
const { recordBody: bootstrapBody } = mockCreateUser('rec_fresh_admin');
const fakeEnv = { ...env, CF_SECRETS_API_TOKEN: undefined, CF_ACCOUNT_ID: undefined } as unknown as Bindings;
const bootstrapRes = await portalRouter.fetch(
new Request('http://localhost/portal/admin/bootstrap', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer owner-token-fresh' },
body: JSON.stringify({ email: 'fresh-install-noheader@example.com', password: 'bootstrap-pw-3' }),
body: JSON.stringify({ email: 'fresh-install-admin@example.com', password: 'bootstrap-pw-3' }),
}),
fakeEnv,
{} as ExecutionContext,
);
expect(res.status).toBe(502);
const data = (await res.json()) as { code: string };
expect(data.code).toBe('auth_store_not_writable');
});
expect(bootstrapRes.status).toBe(200);
const bootstrapData = (await bootstrapRes.json()) as Record<string, unknown>;
expect(bootstrapData.success).toBe(true);
expect(bootstrapData.record_id).toBe('rec_fresh_admin');
expect(JSON.parse(bootstrapBody()).values.password_hash.startsWith('pbkdf2-sha256$')).toBe(true);
expect(bootstrapBody()).not.toContain('bootstrap-pw-3'); // 明碼絕不落地
it('帶安裝表頭(安裝精靈那條路)→ 仍能建第一個 admin,明碼絕不落地', async () => {
await env.SESSIONS_KV.put('console_sess:owner-token-fresh2', JSON.stringify({ created_at: Date.now() }));
mockTemplatesExist();
mockListByTemplate('portal_user', []);
mockHeadLookup('fresh-install-admin@example.com', null);
const { puts } = mockAuthStoreWrite();
// 安裝精靈離場之後:管理員事後手動加第二個人(POST /portal/admin/users),
// 這條路本來就沒帶過任何臨時 token,過去在 D61 底下永遠 502——現在也要成功。
await env.SESSIONS_KV.put('portal_sess:tok-fresh-admin', JSON.stringify({ record_id: 'rec_fresh_admin' }));
mockGetRecord('rec_fresh_admin', activeUserValues({ email: 'fresh-install-admin@example.com', role: 'admin' }));
mockHeadLookup('second-user@example.com', null);
const { recordBody: secondBody } = mockCreateUser('rec_fresh_second');
mockGetRecord('rec_fresh_second', activeUserValues({ email: 'second-user@example.com' }));
const fakeEnv = { ...env, CF_SECRETS_API_TOKEN: undefined } as unknown as Bindings;
const res = await portalRouter.fetch(
new Request('http://localhost/portal/admin/bootstrap', {
const addUserRes = await portalRouter.fetch(
new Request('http://localhost/portal/admin/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer owner-token-fresh2',
'x-arcrun-install-token': 'fresh-oauth-token-from-installer',
},
body: JSON.stringify({ email: 'fresh-install-admin@example.com', password: 'bootstrap-pw-4' }),
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer tok-fresh-admin' },
body: JSON.stringify({ email: 'second-user@example.com', password: 'second-user-pw-1' }),
}),
fakeEnv,
{} as ExecutionContext,
);
expect(res.status).toBe(200);
const data = (await res.json()) as Record<string, unknown>;
expect(data.success).toBe(true);
expect((data.record_id as string).startsWith(AUTH_ID_PREFIX)).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; role: string }> };
expect(shard.users[0].email).toBe('fresh-install-admin@example.com');
expect(shard.users[0].role).toBe('admin');
expect(shards[0].text).not.toContain('bootstrap-pw-4'); // 明碼絕不落地
expect(addUserRes.status).toBe(200);
const addUserData = (await addUserRes.json()) as { success: boolean };
expect(addUserData.success).toBe(true);
expect(JSON.parse(secondBody()).owner_id).toBe(NS);
});
});