diff --git a/.worker-builds/arcrun-cypher-executor/worker.mjs b/.worker-builds/arcrun-cypher-executor/worker.mjs index 8e2d874..dcaa5bc 100644 --- a/.worker-builds/arcrun-cypher-executor/worker.mjs +++ b/.worker-builds/arcrun-cypher-executor/worker.mjs @@ -3400,8 +3400,9 @@ function validateName(name) { function validSensitivity(s) { return s === "standard" || s === "high"; } -async function putWorkerSecret(env, secretRef, value) { - if (!env.CF_SECRETS_API_TOKEN || !env.CF_ACCOUNT_ID) { +async function putWorkerSecret(env, secretRef, value, tokenOverride) { + const token = tokenOverride || env.CF_SECRETS_API_TOKEN; + if (!token || !env.CF_ACCOUNT_ID) { throw new Error( "\u6B64 worker \u7F3A CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID \u8A2D\u5B9A\uFF0C\u5BEB\u5165\u8DEF\u5F91\u672A\u5C31\u7DD2\uFF08\u898B credential-store-migration.md T3\uFF1Aacr init/update \u61C9\u78BA\u4FDD\u9019\u5169\u9805\u5C31\u7DD2\uFF09" ); @@ -3410,7 +3411,7 @@ async function putWorkerSecret(env, secretRef, value) { const res = await fetch(url, { method: "PUT", headers: { - Authorization: `Bearer ${env.CF_SECRETS_API_TOKEN}`, + Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: JSON.stringify({ name: secretRef, text: value, type: "secret_text" }) @@ -3421,14 +3422,15 @@ async function putWorkerSecret(env, secretRef, value) { throw new Error(`CF Workers Secrets \u5BEB\u5165\u5931\u6557\uFF1A${detail}`); } } -async function deleteWorkerSecret(env, secretRef) { - if (!env.CF_SECRETS_API_TOKEN || !env.CF_ACCOUNT_ID) { +async function deleteWorkerSecret(env, secretRef, tokenOverride) { + const token = tokenOverride || env.CF_SECRETS_API_TOKEN; + if (!token || !env.CF_ACCOUNT_ID) { throw new Error("\u6B64 worker \u7F3A CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID \u8A2D\u5B9A\uFF0C\u522A\u9664\u8DEF\u5F91\u672A\u5C31\u7DD2"); } const url = `https://api.cloudflare.com/client/v4/accounts/${env.CF_ACCOUNT_ID}/workers/scripts/${CYPHER_SCRIPT_NAME}/secrets/${secretRef}`; const res = await fetch(url, { method: "DELETE", - headers: { Authorization: `Bearer ${env.CF_SECRETS_API_TOKEN}` } + headers: { Authorization: `Bearer ${token}` } }); if (res.status === 404) return; const body = await res.json().catch(() => null); @@ -9198,11 +9200,8 @@ 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) { - return Boolean(env.CF_SECRETS_API_TOKEN && env.CF_ACCOUNT_ID); +function authStoreWritable(env, tokenOverride) { + return Boolean((tokenOverride || env.CF_SECRETS_API_TOKEN) && env.CF_ACCOUNT_ID); } function readAuthStore(env) { if (overlay && Date.now() - overlayAt < AUTH_OVERLAY_TTL_MS) return overlay; @@ -9238,15 +9237,10 @@ 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) { - if (!authStoreWritable(env)) { +async function writeAuthStore(env, data, tokenOverride) { + if (!authStoreWritable(env, tokenOverride)) { throw new AuthStoreWriteError( - "\u9019\u53F0\u5BE6\u4F8B\u9084\u4E0D\u80FD\u5BEB\u5165\u8A8D\u8B49\u5132\u5B58\uFF08\u7F3A CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID\uFF09\u3002\u8A8D\u8B49\u5206\u96E2\u9700\u8981\u9019\u5169\u9805\u624D\u5BEB\u5F97\u9032 Workers Secrets\u2014\u2014\u8ACB\u91CD\u65B0\u57F7\u884C\u5B89\u88DD\uFF0F\u66F4\u65B0\u8B93\u5B83\u5C31\u7DD2\u3002" + "\u9019\u53F0\u5BE6\u4F8B\u76EE\u524D\u5BEB\u4E0D\u9032\u8A8D\u8B49\u5132\u5B58\uFF08\u7F3A\u53EF\u7528\u7684 Cloudflare \u5BEB\u5165\u6191\u8B49\uFF1ACF_SECRETS_API_TOKEN\uFF09\u3002\u9019\u662F\u5E73\u53F0\u7AEF\u7684\u5DF2\u77E5\u9650\u5236\uFF0C\u4E0D\u662F\u4F60\u64CD\u4F5C\u932F\u8AA4\u2014\u2014\u76EE\u524D\u6C92\u6709\u4F60\u81EA\u5DF1\u5728\u756B\u9762\u4E0A\u80FD\u505A\u7684\u4E0B\u4E00\u6B65\uFF0C\u8ACB\u628A\u9019\u5247\u8A0A\u606F\u5B8C\u6574\u622A\u5716\uFF0F\u8907\u88FD\u7D66\u652F\u63F4\uFF0C\u4E26\u8A3B\u660E\u4F60\u525B\u624D\u5728\u505A\u4EC0\u9EBC\uFF08\u4F8B\u5982\uFF1A\u5B89\u88DD\u7CBE\u9748\u88E1\u5EFA\u7ACB\u7B2C\u4E00\u500B\u5E33\u865F\u3001\u4E8B\u5F8C\u65B0\u589E\u4F7F\u7528\u8005\u3001\u6216\u4FEE\u6539\u5BC6\u78BC\uFF09\uFF0C\u6703\u9700\u8981\u4EBA\u5DE5\u5354\u52A9\u6392\u9664\u3002" ); } const shards = []; @@ -9269,10 +9263,10 @@ async function writeAuthStore(env, data) { } const existing = shardNames(env); for (let i = 0; i < shards.length; i++) { - await putWorkerSecret(env, shardNameOf(i), shards[i]); + await putWorkerSecret(env, shardNameOf(i), shards[i], tokenOverride); } for (const name of existing) { - if (shardIndex(name) >= shards.length) await deleteWorkerSecret(env, name); + if (shardIndex(name) >= shards.length) await deleteWorkerSecret(env, name, tokenOverride); } overlay = { version: 1, console: data.console ?? null, users: [...data.users] }; overlayAt = Date.now(); @@ -9319,26 +9313,23 @@ function unionStores(a, b) { } return { version: 1, console: a.console ?? b.console ?? null, users: [...byId.values()] }; } -async function mutateAuthStore(env, fn) { +async function mutateAuthStore(env, fn, tokenOverride) { await hydrateFromAccelerator(env); const next = unionStores(readAuthStore(env), readAuthStoreFromEnv(env)); await fn(next); - await writeAuthStore(env, next); + 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; @@ -12625,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) { - await mutateAuthStore(env, (data) => { - data.console = 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,8 +12670,7 @@ consoleAuthRouter.post("/console/setup", async (c) => { try { 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() }), { @@ -12705,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 }); }); @@ -12717,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 ); @@ -12726,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); @@ -13008,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, @@ -13094,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 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 - }); + 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 }); - return id; } function parseLibraries(raw2) { if (!raw2) return []; @@ -13266,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; } @@ -13303,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 ); @@ -13318,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({ diff --git a/.worker-builds/manifest.json b/.worker-builds/manifest.json index 5ba07a6..46778c5 100644 --- a/.worker-builds/manifest.json +++ b/.worker-builds/manifest.json @@ -1,18 +1,18 @@ { "schema": 1, "built_for": "arcrun-tier2-worker-artifacts", - "generated_at": "2026-08-13T10:28:15.839Z", - "repo_head": "1ccee0055ebdb1f6d74094df3e807f56b849fa6f", - "repo_dirty": false, + "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": "b223a698844be289c1b01f99eb34a8e2ac85bb74", + "source_commit": "8286c8afec1b225fcec21654cfd1cad3143231fe", "main_module": "worker.mjs", "main_file": "arcrun-cypher-executor/worker.mjs", - "js_bytes": 588587, - "content_sha256": "c43728d21251f7835497d7dc40a3e702526a70b33406b617ed64126ffb16e1e0", + "js_bytes": 589935, + "content_sha256": "8e6487478bc86bd289afaa5192cbd394b80b5235ea1eefb30ed716e6446a9312", "modules": [], "compat_date": "2025-02-19", "compat_flags": [ diff --git a/cypher-executor/src/lib/portal-auth-store.ts b/cypher-executor/src/lib/portal-auth-store.ts index dec36cd..349e0f8 100644 --- a/cypher-executor/src/lib/portal-auth-store.ts +++ b/cypher-executor/src/lib/portal-auth-store.ts @@ -131,9 +131,16 @@ 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); +/** + * 寫入路徑是否就緒——缺就誠實回報「不能改密碼」,不假綠。 + * + * `tokenOverride`(2026-08-14,arcrun-rag#99):`env.CF_SECRETS_API_TOKEN` 從沒被安裝器種過, + * 這是每台新實例都會撞的硬斷點(不是 leo 個人的環境問題)。安裝精靈裝機當下手上有一把 + * 自己還有效的 OAuth token,讓 `/console/setup`/`/portal/admin/bootstrap` 把它隨請求帶入, + * 這裡就把它算進「寫得進去嗎」的判斷——見 `routes/credentials.ts putWorkerSecret` 的完整說明。 + */ +export function authStoreWritable(env: Bindings, tokenOverride?: string): boolean { + return Boolean((tokenOverride || env.CF_SECRETS_API_TOKEN) && env.CF_ACCOUNT_ID); } /** @@ -201,11 +208,21 @@ export function newAuthUserId(): string { * 分片規則:console 一定放第 0 片;users 依序塞,塞不下就開下一片。 * 多出來的舊分片會被刪掉(避免「刪了帳號卻還留在舊分片裡復活」)。 */ -export async function writeAuthStore(env: Bindings, data: AuthStoreData): Promise { - if (!authStoreWritable(env)) { +export async function writeAuthStore(env: Bindings, data: AuthStoreData, tokenOverride?: string): Promise { + if (!authStoreWritable(env, tokenOverride)) { + // arcrun-rag#99(2026-08-14,leo 傳實測畫面點破):舊文案叫用戶「重新執行安裝/更新」, + // 但在這個訊息會出現的所有情況下(包含安裝精靈本身,若它沒能把 install token 帶到)那句話 + // 都不保證成立——安裝/更新從來不會把 CF_SECRETS_API_TOKEN 種成一個常駐的值。 + // 對一個做不到的動作下指令=把人導向死路(他會以為自己操作錯誤,反覆重試)。 + // 改成誠實描述現況+給得出去的下一步(回報支援),不再承諾一個我們自己都不確定會生效的動作。 + // 只列 CF_SECRETS_API_TOKEN:CF_ACCOUNT_ID 已查證全新實例一定有 + //(installer/oauth-prototype/worker.js:1354 無條件注入每一顆部署的 worker), + // 列兩項只會讓下一個人以為兩項都要查,白工一次。 throw new AuthStoreWriteError( - '這台實例還不能寫入認證儲存(缺 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID)。' + - '認證分離需要這兩項才寫得進 Workers Secrets——請重新執行安裝/更新讓它就緒。', + '這台實例目前寫不進認證儲存(缺可用的 Cloudflare 寫入憑證:CF_SECRETS_API_TOKEN)。' + + '這是平台端的已知限制,不是你操作錯誤——目前沒有你自己在畫面上能做的下一步,' + + '請把這則訊息完整截圖/複製給支援,並註明你剛才在做什麼(例如:安裝精靈裡建立第一個帳號、' + + '事後新增使用者、或修改密碼),會需要人工協助排除。', ); } @@ -232,10 +249,10 @@ export async function writeAuthStore(env: Bindings, data: AuthStoreData): Promis const existing = shardNames(env); for (let i = 0; i < shards.length; i++) { - await putWorkerSecret(env, shardNameOf(i), shards[i]); + await putWorkerSecret(env, shardNameOf(i), shards[i], tokenOverride); } for (const name of existing) { - if (shardIndex(name) >= shards.length) await deleteWorkerSecret(env, name); + if (shardIndex(name) >= shards.length) await deleteWorkerSecret(env, name, tokenOverride); } overlay = { version: 1, console: data.console ?? null, users: [...data.users] }; @@ -320,11 +337,12 @@ function unionStores(a: AuthStoreData, b: AuthStoreData): AuthStoreData { export async function mutateAuthStore( env: Bindings, fn: (data: AuthStoreData) => void | Promise, + tokenOverride?: string, ): Promise { await hydrateFromAccelerator(env); const next = unionStores(readAuthStore(env), readAuthStoreFromEnv(env)); await fn(next); - await writeAuthStore(env, next); + await writeAuthStore(env, next, tokenOverride); return next; } diff --git a/cypher-executor/src/routes/console-auth.ts b/cypher-executor/src/routes/console-auth.ts index 5dcb12b..d1b662e 100644 --- a/cypher-executor/src/routes/console-auth.ts +++ b/cypher-executor/src/routes/console-auth.ts @@ -22,19 +22,18 @@ */ 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'; +// D61 補充(2026-08-14,leo 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,52 +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_KV(binding)優先**;沒有才回退讀舊家 + * (D61 的認證儲存,CF Workers Secrets——讀取零成本、零外部憑證需求), + * 且一旦從舊家讀到就順手搬回 SESSIONS_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' }; - +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(寫回去等於把病種回土裡)。 */ +/** + * 寫入 console 管理員帳密——**只寫 SESSIONS_KV**(binding,永不需要外部 CF token)。 + * 不再寫回認證儲存(Workers Secrets):那是要被淘汰的舊家,寫回去等於把債種回土裡。 + */ async function saveCredentials(env: Bindings, record: StoredCredentials): Promise { - await mutateAuthStore(env, (data) => { - data.console = record; - }); + 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(不可覆蓋,防外人搶注)。 @@ -171,11 +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 { + // 2026-08-14 起:寫 SESSIONS_KV(binding),不再需要安裝精靈遞任何臨時 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); @@ -206,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 }); }); @@ -216,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, ); @@ -232,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); } diff --git a/cypher-executor/src/routes/credentials.ts b/cypher-executor/src/routes/credentials.ts index 185cbee..3963c33 100644 --- a/cypher-executor/src/routes/credentials.ts +++ b/cypher-executor/src/routes/credentials.ts @@ -92,9 +92,21 @@ function validSensitivity(s: unknown): s is 'standard' | 'high' { /** * 呼叫 CF Workers Scripts secrets 管理 API,把明文值存進本 worker 的 per-script secret。 * 唯寫:這支 API 不回傳任何既有 secret 的值,只能 create/update/delete/list 名字(D19 對齊)。 + * + * `tokenOverride`(2026-08-14,arcrun-rag#99:全新帳號卡在註冊,`writable:false`): + * 本 worker 自己的 `env.CF_SECRETS_API_TOKEN` 從安裝那天起就沒被種過(07-29 已知缺口,記在 + * pending-changes.md「credential 走 n8n 模式」——當時只降級某個功能;D61 認證分離之後升級成 + * 「連第一個帳號都建不起來」的硬斷點,因為 `/console/setup`/`/portal/admin/bootstrap` + * 現在都走這條寫入路徑)。安裝精靈裝機當下手上有一把**自己還有效**的 OAuth token + * (`workers-scripts.write` scope,跟部署零件、種 credential 用的是同一把——見 + * `installer/oauth-prototype/worker.js` 的 `putWorkerSecretDirect`/`seedCredential`, + * 是同一個「安裝器代寫」精神,D36 第1步)。讓呼叫端把這把 token **隨請求帶入、不落地**, + * 補的正是「cypher 自己永遠拿不到長效寫入憑證」這個結構性缺口,不是新開一條路。 + * 優先權:呼叫端提供 > worker 自身 env。 */ -export async function putWorkerSecret(env: Bindings, secretRef: string, value: string): Promise { - if (!env.CF_SECRETS_API_TOKEN || !env.CF_ACCOUNT_ID) { +export async function putWorkerSecret(env: Bindings, secretRef: string, value: string, tokenOverride?: string): Promise { + const token = tokenOverride || env.CF_SECRETS_API_TOKEN; + if (!token || !env.CF_ACCOUNT_ID) { throw new Error( '此 worker 缺 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID 設定,寫入路徑未就緒(見 ' + 'credential-store-migration.md T3:acr init/update 應確保這兩項就緒)', @@ -104,7 +116,7 @@ export async function putWorkerSecret(env: Bindings, secretRef: string, value: s const res = await fetch(url, { method: 'PUT', headers: { - Authorization: `Bearer ${env.CF_SECRETS_API_TOKEN}`, + Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ name: secretRef, text: value, type: 'secret_text' }), @@ -121,15 +133,19 @@ export async function putWorkerSecret(env: Bindings, secretRef: string, value: s /** * 呼叫 CF Workers Scripts secrets 管理 API 刪除一個 per-script secret(T9 治理端點用)。 * 404(本來就不存在)視為成功(冪等刪除,呼叫端可能已被清過)。 + * + * `tokenOverride`:與 `putWorkerSecret` 同一組理由(見該函式註解)。auth store 分片重切時 + * 會刪多出來的舊分片,這條路徑也要能吃到安裝精靈臨時遞來的 token。 */ -export async function deleteWorkerSecret(env: Bindings, secretRef: string): Promise { - if (!env.CF_SECRETS_API_TOKEN || !env.CF_ACCOUNT_ID) { +export async function deleteWorkerSecret(env: Bindings, secretRef: string, tokenOverride?: string): Promise { + const token = tokenOverride || env.CF_SECRETS_API_TOKEN; + if (!token || !env.CF_ACCOUNT_ID) { throw new Error('此 worker 缺 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID 設定,刪除路徑未就緒'); } const url = `https://api.cloudflare.com/client/v4/accounts/${env.CF_ACCOUNT_ID}/workers/scripts/${CYPHER_SCRIPT_NAME}/secrets/${secretRef}`; const res = await fetch(url, { method: 'DELETE', - headers: { Authorization: `Bearer ${env.CF_SECRETS_API_TOKEN}` }, + headers: { Authorization: `Bearer ${token}` }, }); if (res.status === 404) return; const body = (await res.json().catch(() => null)) as diff --git a/cypher-executor/src/routes/health.ts b/cypher-executor/src/routes/health.ts index f7c9827..ed9fe4c 100644 --- a/cypher-executor/src/routes/health.ts +++ b/cypher-executor/src/routes/health.ts @@ -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.ts/portal.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 }, + }; +} + // t162(leo 07-31 實撞:「小幫手一直顯示知識庫需要更新…重新更新後並不會消失」): // daemon cloudVersionStale() 讀 /health 的 `bundle_version` 判斷是否過舊—— // 但本端點過去只回 {ok:true},**從沒吐這個欄位** ⇒ daemon 恆讀到空字串 diff --git a/cypher-executor/src/routes/portal.ts b/cypher-executor/src/routes/portal.ts index caf656e..c393c69 100644 --- a/cypher-executor/src/routes/portal.ts +++ b/cypher-executor/src/routes/portal.ts @@ -31,19 +31,25 @@ import { accountTenant, knowledgeOwner, ownerField, ownerQuery, tenantFromApiKey // 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)。 +// D61 補充(2026-08-14,leo 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 API,D38 零 SQL,binding,永不需要外部 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): 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 { +async function promoteToKbdb(env: Bindings, rec: PortalRecord): Promise { 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 { - 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_id(design §2.3 head entry O(1) 查找)。 */ -async function findLegacyUserRecordId(env: Bindings, email: string): Promise { +/** KBDB 的 email → record_id(design §2.3 head entry O(1) 查找)。 */ +async function findKbdbUserRecordId(env: Bindings, email: string): Promise { const ns = portalNamespace(env); const params = new URLSearchParams({ page_name: email, @@ -269,7 +285,8 @@ async function findLegacyUserRecordId(env: Bindings, email: string): Promise { - // 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): Promise { - // 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 { - // 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 { +/** KBDB 原生的 by-template 查詢(portal_library 等「資料」也走這條,那些本來就只住知識庫)。 */ +async function listKbdbRecordsByTemplate(env: Bindings, template: string): Promise { 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,27 +383,44 @@ interface CreateUserInput { } /** - * 建帳號。**D61 起一律建在認證儲存(Workers Secrets),不再寫進 KBDB。** - * 寫入路徑未就緒就誠實拋錯(AuthStoreWriteError → 502),不偷偷退回舊家—— - * 退回去等於這個帳號下次搬資料時又會不見,那正是本案要根治的病。 + * 建 portal_user record(子 namespace)+ email head entry(design §2.3)——低階寫入, + * `createPortalUser`(一般建帳號)與 `promoteToKbdb`(舊帳號搬遷)共用同一條寫入路徑。 + */ +async function createKbdbUserRecord(env: Bindings, email: string, values: Record): Promise { + 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(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: email, content: recordId, owner_id: ns }), + }); + if (!head.ok) throw new KbdbError(`head entry 建立失敗(record ${recordId} 已建,需人工收拾)→ ${head.status}`); + return recordId; +} + +/** + * 建帳號。**2026-08-14 起改回一律建在 KBDB**(binding,走 base HTTP API,D38 零 SQL), + * 不再需要外部 `CF_SECRETS_API_TOKEN`——這正是本次補的結構性缺口(見檔頭「D61 補充」)。 */ async function createPortalUser(env: Bindings, input: CreateUserInput): Promise { 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, - }); + 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, }); - return id; } // ── user 值域 helpers ────────────────────────────────────────────────────── @@ -596,15 +633,15 @@ async function clearLoginFail(env: Bindings, email: string): Promise { } /** - * D61:這台實例是不是「一個帳號都沒有」(新家空、舊家也空/讀不到)。 - * 只在「查無此帳號」時才呼叫,不進正常登入熱路徑。 + * 這台實例是不是「一個帳號都沒有」(KBDB 新家空、認證儲存舊家也空/讀不到)。 + * 只在「查無此帳號」時才呼叫,不進正常登入熱路徑(#10「寧可明顯失敗,不要靜默錯置」)。 */ async function instanceHasNoAuthData(env: Bindings): Promise { - 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 讀不到 + 舊家空 = 這台實例確實沒有可用的登入資料 } } @@ -655,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 分鐘,而他的密碼從頭到尾都是對的。 // ⇒ 回一個**分得出來**的錯,而且**不計入鎖定**。 @@ -666,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, ); @@ -682,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_id(design §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({ @@ -1073,6 +1116,8 @@ 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); + // 2026-08-14 起:createPortalUser 寫 KBDB(binding),不再需要安裝精靈遞任何臨時 + // CF token(arcrun-rag#99 那個結構性缺口已隨儲存層搬回 binding 一併解掉)。 const recordId = await createPortalUser(c.env, { email, display_name: displayName, diff --git a/cypher-executor/tests/console-auth-legacy.test.ts b/cypher-executor/tests/console-auth-legacy.test.ts index eb4a03e..4ff5da4 100644 --- a/cypher-executor/tests/console-auth-legacy.test.ts +++ b/cypher-executor/tests/console-auth-legacy.test.ts @@ -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 confirm「走C」):console 帳密的家改回 SESSIONS_KV(binding)。 + * 已經在跑 D61(帳密住 CF Workers Secrets)的實例不能因為這次改動而登不進去——讀取認證儲存 + * 零成本、零外部憑證需求(只有寫入才需要 CF_SECRETS_API_TOKEN),所以永遠讀得到;讀到就 + * 順手搬回 SESSIONS_KV(best-effort,binding 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); }); }); diff --git a/cypher-executor/tests/console-auth.test.ts b/cypher-executor/tests/console-auth.test.ts index 86ce2ba..8d362f6 100644 --- a/cypher-executor/tests/console-auth.test.ts +++ b/cypher-executor/tests/console-auth.test.ts @@ -1,81 +1,64 @@ /** - * console-auth.ts 測試(D61:console 管理員帳密搬進認證儲存,ADR D61 / Leo/arcrun-rag#55) + * console-auth.ts 測試(2026-08-14 起:console 管理員帳密改回住 SESSIONS_KV, + * D61 補充/pending-changes.md「認證儲存要不要搬回 D1/KV」,leo confirm「走C」) * - * 這組帳密(/console/setup、/console/login…)原本住 SESSIONS_KV `console:credentials` - * (沒有 TTL)——KV 靠 binding 指過去,重裝會被指到新建的空 KV ⇒ 帳密憑空消失 - * (console-auth.ts 檔頭「KV=暫存、非長期真相源」第三次被違反,這次違反的是大門的鎖)。 - * D61 起改存進認證儲存(CF Workers Secrets),SESSIONS_KV 只留為回退讀路徑。 + * 這組帳密(/console/setup、/console/login…)曾在 D61(ADR 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_KV(binding,永不需要外部 token)。 + * 認證儲存留為舊實例的回退讀路徑,見 tests/console-auth-legacy.test.ts。 * - * 覆蓋(本檔在此之前不存在,D61 交辦要求的新增覆蓋): + * 覆蓋: * 1. 全新實例:auth-status 回 configured:false;login 回「讀不到認證資料」(不是密碼錯)。 - * 2. 首次設定成功:POST /console/setup 寫進認證儲存(CF Workers Secrets),不再寫 KV。 - * 3. 已設定過 → 409,訊息明講「你剛才輸入的密碼沒有被採用」(D61 明顯失敗,取代舊版 + * 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 的 mockAuthStoreWrite),不外連;wrangler.test.toml 已預設 - * CF_SECRETS_API_TOKEN/CF_ACCOUNT_ID 就緒。 + * 全程不需要任何 CF API mock——SESSIONS_KV 是 binding,wrangler.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 = {}) { +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 { + 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:true,credentials_source:secrets(新家優先命中)', async () => { + it('GET /console/auth-status → configured:true,credentials_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 }); diff --git a/cypher-executor/tests/portal-admin.test.ts b/cypher-executor/tests/portal-admin.test.ts index ff883e7..a2c5403 100644 --- a/cypher-executor/tests/portal-admin.test.ts +++ b/cypher-executor/tests/portal-admin.test.ts @@ -16,20 +16,20 @@ * 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。 + * 2026-08-14 起(D61 補充,leo confirm「走C」):帳號改回住 KBDB(binding,見 + * 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/users、POST /portal/admin/bootstrap + * 走同一支 createPortalUser)改回直接寫 KBDB(POST /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 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 }; + 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', diff --git a/cypher-executor/tests/portal-auth.test.ts b/cypher-executor/tests/portal-auth.test.ts index 448a525..e751769 100644 --- a/cypher-executor/tests/portal-auth.test.ts +++ b/cypher-executor/tests/portal-auth.test.ts @@ -4,31 +4,29 @@ * 覆蓋(=tasks.md P2 測試項): * 1. KDF:pbkdf2-sha256$100000$… 格式(CF Workers runtime 上限 100k,2026-07-14 真雲實撞)、 * 驗證對錯、壞格式誠實 false、600k 舊 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 次失敗 → 429(KV TTL 計數) * 5. session:每請求回讀 record;停用即拒(既有 session 立即失效) * 6. 改密碼:驗舊密;新 hash 以 100k 格式落 slot * 7. role 閘:非 admin 打 admin 端點 → 403;admin 列表**剝除 password_hash** * - * D61(ADR D61 / Leo/arcrun-rag#55)補的覆蓋(原本沒有,這次變更的重點): + * 2026-08-14 起(D61 補充,leo confirm「走C」,pending-changes.md「認證儲存要不要搬回 + * D1/KV」):帳號的家從認證儲存(CF Workers Secrets,D61)改回 **KBDB**(binding,走 base + * HTTP API,不需要外部 CF token)。額外覆蓋: * 8. 整台實例沒有任何認證資料 → 登入回「讀不到認證資料」(不是密碼錯),且不計入鎖定 - * 9. 舊實例相容:帳號只存在 KBDB(舊家)時仍登得進去,登入成功後自動搬進認證儲存 + * 9. 舊實例相容:帳號只存在認證儲存(D61 舊家)時仍登得進去,登入成功後自動搬進 KBDB, + * 且**這次登入發出的 session 就直接指向新 record_id**;搬遷失敗(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 { 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 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 + * route(consoleAuthRouter/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 { + 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) { @@ -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#55,leo 2026-08-09 被誤鎖 15 分鐘的事故)', () => { +// 🔴 這個 describe 必須留在檔案裡「第一個會種入認證儲存(舊家)的測試」之前——見 +// seedLegacySecretsUser 檔頭註解:portal-auth-store.ts 的 per-isolate overlay 是模組級全域 +// 變數,同一支測試檔案跑起來不會在測試之間重置,一旦有測試種過舊家資料,後面的測試都會看到 +// 那筆資料,「乾淨無帳號」的前提就不成立了。 +describe('整台實例沒有任何認證資料(arcrun-rag#55,leo 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', []); // 尚無 admin(KBDB 空,認證儲存舊家也空) + 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; 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 }; + 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 - // 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 () => { @@ -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 seed(PORTAL_TEMPLATE_SEEDS 補 triplet,e }); }); -// ═══════════════ 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 }; + 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、帶一份缺寫入路徑的 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, - ); + 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:改密碼與忘記密碼是同一個機制(同一支端點、同一條寫入路徑)', () => { @@ -682,3 +694,63 @@ describe('arcrun-rag#66:傳播空窗期不可以銷毀 session', () => { expect(await env.SESSIONS_KV.get('portal_sess:broken-66')).toBeNull(); }); }); + +// ═══════════════ arcrun-rag#99(2026-08-14):全新安裝從沒種過 CF_SECRETS_API_TOKEN ═══════════════ +// +// 每一台裝好的新實例過去都會在 bootstrap 這裡卡死(leo 本人+封測者都撞到「裝得起來, +// 但卡在註冊」——`/console/auth-status` 永遠回 `writable:false`)。止血版(安裝精靈遞一把 +// 臨時 OAuth token)只解掉「建第一個帳號」這一格;2026-08-14 confirm 走 C 之後,帳號改回 +// 住 KBDB(binding),**從根拔掉整個問題**:`x-arcrun-install-token` 這條路已經不存在了, +// 因為建帳號這件事本來就不再需要任何 CF API 憑證,無論裝機當下還是裝完之後都一樣。 +// +// 下面這則是回歸守衛:**一台連 `CF_SECRETS_API_TOKEN`/`CF_ACCOUNT_ID` 都沒有的全新實例**, +// 從 bootstrap 建第一個帳號、到之後用 /portal/admin/users 加第二個帳號,全程都要成功—— +// 若哪天有人把帳號寫入路徑又改回去揹 CF Secrets 依賴,這裡會紅。 +describe('arcrun-rag#99:全新實例(連 CF_SECRETS_API_TOKEN/CF_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', []); // 尚無 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-admin@example.com', password: 'bootstrap-pw-3' }), + }), + fakeEnv, + {} as ExecutionContext, + ); + expect(bootstrapRes.status).toBe(200); + const bootstrapData = (await bootstrapRes.json()) as Record; + 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'); // 明碼絕不落地 + + // 安裝精靈離場之後:管理員事後手動加第二個人(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 addUserRes = await portalRouter.fetch( + new Request('http://localhost/portal/admin/users', { + method: 'POST', + 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(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); + }); +}); diff --git a/system-dev/docs/3-specs/pending-changes.md b/system-dev/docs/3-specs/pending-changes.md index fad654a..703f3a0 100644 --- a/system-dev/docs/3-specs/pending-changes.md +++ b/system-dev/docs/3-specs/pending-changes.md @@ -8,6 +8,105 @@ ## 待裁決 +### P?|認證儲存要不要搬回 D1/KV——resource-reuse 上線後,D61 想解的病可能已被更通用的機制解掉(2026-08-14) + +**觸發**:`arcrun-rag#99`/`inkstone/Arcrun#119`——全新安裝卡在「建立第一個帳號」,leo 本人與封測者 +都實撞。根因:D61(2026-08-10,`c4cee35`)把認證資料搬到 CF Workers per-script Secrets,需要 cypher +自己持有 `env.CF_SECRETS_API_TOKEN`;這把 token 從安裝那天起就沒被種過(07-29 已知缺口),於是**每 +一台全新實例**永遠 `writable:false`。 + +**已做的止血只解掉一格,其餘留債**:先做的修法(安裝精靈把裝機當下自己還有效的 OAuth token 隨 +`/console/setup`/`/portal/admin/bootstrap` 請求傳給 cypher,用這一次、不落地)讓「建立第一個帳號」 +能通,但 `console-auth.ts:216`(`/console/setup/reset`)、`/portal/admin/users`、`POST /credentials` +(存 API 金鑰)、`/portal/password/change`——**這些路徑安裝精靈都不在場,永遠拿不到那個表頭**,裝完 +之後這台實例對這些操作永遠 `writable:false`。leo 的判準是「別人不再出這個問題」且「後面不能留 +債」,這不合格。 + +**排除掉的兩個止血選項(留給下一個人看,避免重新踩一次)**: +- **A. 用 OAuth 權限「代鑄」一把不過期的 CF API Token**(呼叫 `POST /user/tokens`)——需要新的 + OAuth scope(目前 `OAUTH_SCOPES` 六項裡沒有「管理 API Tokens」這項),會讓用戶授權畫面多一條 + 同意項,是產品面決定,未經查證 CF 的 OAuth scope 目錄裡那個 scope 確切叫什麼。 +- **B. 讓用戶自己去 CF Dashboard 建一把 Token 貼進安裝精靈**——不需要新 scope,但打破「一鍵安裝」, + 也是產品面決定。 +- **(已明確排除)把 OAuth access_token 直接當長效 secret 存**——access_token 16 小時過期, + refresh_token 是 rotation 制、單次有效,cypher 沒有能力自己做 OAuth refresh(那等於要 cypher 自己 + 再長一份 OAuth client,且會跟安裝器搶同一條 refresh 鏈的競態)。存了會變成「`writable` 顯示 + `true`,但 16 小時後開始靜默 401,且沒有任何機制會發現」——比現在「明確顯示 `writable:false`」更 + 糟,直接違反 D61 commit 自己引用的 #10「寧可明顯失敗,不要靜默錯置」。 + +**本提案(C):查證結果——技術前提成立,有具體證據,不是推論** + +D61 的病根是「重裝時 binding 被安裝器照名字重新指到新建的空資源」(console 帳密住 `SESSIONS_KV`、 +portal_user 住 KBDB 的 D1)。**這個病根本身已經在 2026-08-13 被一個更早、更通用、範圍更廣的機制 +解掉**——`shared/resource-rule`(`Arcrun#97`/`arcrun-rag#87`:「已部署的 worker 綁著什麼,那就是 +事實 → 原封不動沿用,不管叫什麼名字」)。現在**每一次**安裝或更新都會先跑這條規則,涵蓋所有 +`kv_namespace`/`d1` binding,不分是不是認證用的——不是為了本提案才要新造的機制,是已經在保護 +workflows/recipes/總圖的既有基礎設施。 + +核實證據(讀碼+讀已生成的成品,非猜測): +- `.worker-builds/manifest.json`(本次為驗證這個提案,用 `scripts/build-worker-artifacts.mjs` + 重打的真實成品):`arcrun-cypher-executor.requires.kv` 含 `"SESSIONS_KV"`;`requires.d1` = + `{binding:"CREDENTIALS_DB", database_name:"arcrun-kbdb"}`——與 `arcrun-kbdb` worker 自己的 `DB` + binding 指向同一個資料庫名(portal_user 的家)。 +- `installer/oauth-prototype/worker.js:1762`:`runInstall` 真的呼叫 + `resolveResourcesByRule(token, accountId, manifestRequirements(manifest, baseName, true), mode)`, + `mode ∈ {'init','update'}`——**每次安裝/更新都會走這條路,不是選配**。 +- `manifestRequirements()`(`:818-843`)把 `requires.kv`/`requires.d1` 轉成規則輸入, + `SESSIONS_KV`/`CREDENTIALS_DB` 均未被排除。 +- `shared/resource-rule/rule.mjs` 對 `kv_namespace`/`d1` 一視同仁,不分 binding 名字;同 + `createName`(`arcrun-kbdb`)的 d1 需求會被 `shareSameResource` 收斂成同一顆。 + +**時序細節(誠實揭露,降低但不推翻結論)**:`Arcrun#97` 的觸發事故發生在 2026-08-12(比 D61 晚兩 +天),當時症狀含「portal 登出」——代表 D61 上線後、resource-reuse 落地前,這類事故確實還發生過一 +次。無法確認 leo21c 那次事故當下實際跑的是 D61 前還是後的 bundle(merge 進 main ≠ 部署到任何一台 +實例,這個落差在別處也反覆出現)。不論是哪一種:**resource-reuse 落地之後(08-13 起)**,這個機制 +對 `SESSIONS_KV`/KBDB D1 的保護,與 D61 對認證儲存的保護,範圍是重疊的——這件事在程式碼層級是確 +定的,不是本節不確定的部分。 + +**建議做法**(confirm 後才動,這裡先寫方向不寫實作細節): +1. 認證資料搬回 D1(走 KBDB base API,比照 D61 之前的舊機制)+ console 帳密搬回 `SESSIONS_KV`—— + 寫入走 Workers binding,不需要任何外部 CF token,零過期、零新 OAuth scope、零手動步驟。 +2. D61 加的三個「明顯失敗」機制**保留**,只是底層儲存換回 binding:讀不到不算密碼錯且不計入鎖定 + (`auth_store_empty`)、`/console/setup` 遇既有帳號說清楚密碼沒被採用、`/health`/ + `/console/auth-status` 吐儲存狀態。 +3. `portal-auth-store.ts` 整份(CF Secrets 分片機制)——需要人決定留著當可切換的備援路徑,還是直 + 接移除單一化;本提案不預設答案。 + +**影響分析(依 SDD-LIFECYCLE 第 3 條格式)**: +- **現行 active SDD**:`system-dev/docs/3-specs/portal-auth/`——本提案不影響該 SDD 已完成的任務 + (P2 bootstrap/登入/role 閘等),只改「認證資料住哪」這一層的實作,功能行為(登入/bootstrap/ + 改密碼的 API 契約)不變。 +- **尚未完成、待搬移**:本提案本身沒有現成 tasks 可搬,confirm 後在新工作項目下處理。 +- **既有相關**:`arcrun-rag#99`/`inkstone/Arcrun#119`(本次 P0 症狀單,confirm 後統一收斂修 + 法);D61(`Leo/arcrun-rag#55`)本身——confirm 即等於部分撤銷 D61 的儲存層決定(保留其明顯失敗 + 語意),需要在 ADR 記一筆「D61 補充/取代」。 +- **已裝好、正在跑 D61(CF Secrets 認證儲存)的實例怎麼辦**:需要一次性遷移(讀出 CF Secrets 裡的 + JSON → 寫回 D1/KV),且要處理「這把 CF Secrets 還留著沒人管」的收尾(刪除或忽略均可,留著不影 + 響正確性只是佔用 secret 額度)。confirm 後另立遷移步驟,不在本提案本身寫死做法。 + +**信心水位(誠實標,不假裝已驗證)**:上面「技術前提成立」的證據全部來自**靜態推論**——讀原始 +碼、讀本機重打出的 `.worker-builds/manifest.json`、讀呼叫鏈——**沒有跑過一次真的部署+動態驗 +證**。要把信心水位補到「實測過」,得先過 D20(部署 stage)才做得到,而部署本身要 leo 開閘 ⇒ +不能把它當成「先驗證才准寫提案」的前置,否則變成「等閘才能寫提案、寫了提案才要得到閘」的死結 +(總管裁決:先寫提案,把這段誠實揭露)。 + +**怎麼驗(confirm 後、真正動工前必跑,依 D84 之二三段式,不能只驗 update)**: +1. **測 update**:已裝好、正在跑 D61 的實例,走過一次性遷移後,`/console/auth-status` 回 + `writable:true`,既有帳密登入不受影響。 +2. **uninstall 拆掉後從乾淨狀態模擬第一次安裝**:全新帳號走一次安裝精靈,`/console/setup` 建帳密 + 成功、`/portal/admin/bootstrap` 建第一個 admin 成功,**且不需要安裝精靈傳任何表頭**(因為寫入 + 走 binding,不再依賴 CF_SECRETS_API_TOKEN 這條路)。 +3. **③ 目前做不到,如實標記**:D84 之二要求「除了測 update 還要用 uninstaller 刪除後再模擬第一次 + 安裝」——**uninstaller 現在還不存在**(另一條 subagent 正在做,`Arcrun#120` 家族)。本提案的驗 + 收要等 uninstaller 落地才補得齊第 2 步的「先真的拆過一次」那個誠實版本;在那之前,第 2 步只能 + 用「全新 CF 帳號」代替「先 uninstall 再裝」,兩者不完全等價(後者才驗得到「舊資源殘留時是否誤 + 沿用」這個 resource-rule 專門處理的情境)。 + +**⏸ 等 leo/總管 confirm 才動**(依 SDD 生命週期鐵律第 3 條)。confirm 前,止血用的表頭修法 +(`fix/install-bootstrap-cf-token-99`/`fix/installer-pass-install-token-99`,已 push 分支未合併) +繼續作為短期止血候選,兩者不衝突——表頭修法讓「建第一個帳號」現在能用,即使本提案要花時間走完整 +個遷移流程也不擋這條路先出。 + ### P2|fan-out 並行執行(一個節點的多條出邊目前是循序跑)— 2026-08-03 **觸發**:leo 08-03 原話——「這是在測試中的計畫,**希望體驗很好**,我發現用 gemma4 的反應非常慢。」