diff --git a/.worker-builds/arcrun-cypher-executor/worker.mjs b/.worker-builds/arcrun-cypher-executor/worker.mjs index 8e2d874..27eddf6 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); @@ -9201,8 +9203,8 @@ function shardNameOf(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; @@ -9243,8 +9245,8 @@ function newAuthUserId() { 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" ); @@ -9269,10 +9271,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,11 +9321,11 @@ 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) { @@ -12646,10 +12648,10 @@ async function loadCredentials(env) { } return { creds: legacy, source: "legacy-kv" }; } -async function saveCredentials(env, record) { +async function saveCredentials(env, record, installToken) { await mutateAuthStore(env, (data) => { data.console = record; - }); + }, installToken); } consoleAuthRouter.get("/console/auth-status", async (c) => { const { creds, source } = await loadCredentials(c.env); @@ -12677,7 +12679,7 @@ consoleAuthRouter.post("/console/setup", async (c) => { const hash = await hashPassword(password, salt); const record = { email: email.toLowerCase(), salt, hash, created_at: (/* @__PURE__ */ new Date()).toISOString() }; try { - await saveCredentials(c.env, record); + await saveCredentials(c.env, record, c.req.header("x-arcrun-install-token")); } 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); @@ -13113,7 +13115,7 @@ async function listLegacyRecordsByTemplate(env, template) { const body = await res.json(); return body.records ?? []; } -async function createPortalUser(env, input) { +async function createPortalUser(env, input, installToken) { const now2 = (/* @__PURE__ */ new Date()).toISOString(); const id = newAuthUserId(); await mutateAuthStore(env, (data) => { @@ -13128,7 +13130,7 @@ async function createPortalUser(env, input) { created_at: now2, updated_at: now2 }); - }); + }, installToken); return id; } function parseLibraries(raw2) { @@ -13553,7 +13555,7 @@ portalRouter.post( libraries: ["*"], // bootstrap admin 預設全庫(design §3.3:["*"]=不注 library filter) password_hash: await hashPassword2(password) - }); + }, c.req.header("x-arcrun-install-token")); return c.json({ success: true, record_id: recordId, email, role: "admin" }); }) ); diff --git a/.worker-builds/manifest.json b/.worker-builds/manifest.json index 5ba07a6..44486d3 100644 --- a/.worker-builds/manifest.json +++ b/.worker-builds/manifest.json @@ -1,9 +1,9 @@ { "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-14T03:45:54.247Z", + "repo_head": "c3856470ad5616b55aff846839581c15ed0975ed", + "repo_dirty": true, "workers": [ { "name": "arcrun-cypher-executor", @@ -11,8 +11,8 @@ "source_commit": "b223a698844be289c1b01f99eb34a8e2ac85bb74", "main_module": "worker.mjs", "main_file": "arcrun-cypher-executor/worker.mjs", - "js_bytes": 588587, - "content_sha256": "c43728d21251f7835497d7dc40a3e702526a70b33406b617ed64126ffb16e1e0", + "js_bytes": 588919, + "content_sha256": "d28f500a0d70f6e9364958dd7b4d446ceb7f3ce96584d6ef509d286fc2e86e86", "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..70ee20e 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,8 +208,8 @@ 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)) { throw new AuthStoreWriteError( '這台實例還不能寫入認證儲存(缺 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID)。' + '認證分離需要這兩項才寫得進 Workers Secrets——請重新執行安裝/更新讓它就緒。', @@ -232,10 +239,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 +327,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..f4ae943 100644 --- a/cypher-executor/src/routes/console-auth.ts +++ b/cypher-executor/src/routes/console-auth.ts @@ -127,11 +127,17 @@ async function loadCredentials(env: Bindings): Promise<{ creds: StoredCredential return { creds: legacy, source: 'legacy-kv' }; } -/** 寫入 console 管理員帳密——**只寫新家**,不再寫 KV(寫回去等於把病種回土裡)。 */ -async function saveCredentials(env: Bindings, record: StoredCredentials): Promise { +/** + * 寫入 console 管理員帳密——**只寫新家**,不再寫 KV(寫回去等於把病種回土裡)。 + * + * `installToken`(2026-08-14,arcrun-rag#99):見 `lib/portal-auth-store.ts authStoreWritable` + * 的完整說明——這是安裝精靈裝機當下遞來、cypher 自己不落地的臨時 CF token,補的是 + * 「這台 worker 從沒被種過 CF_SECRETS_API_TOKEN」這個結構性缺口。 + */ +async function saveCredentials(env: Bindings, record: StoredCredentials, installToken?: string): Promise { await mutateAuthStore(env, (data) => { data.console = record; - }); + }, installToken); } // GET /console/auth-status — 前端用來決定顯示「首次設定」還是「登入」表單。不洩漏 email。 @@ -171,7 +177,10 @@ 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 { - await saveCredentials(c.env, record); + // arcrun-rag#99:安裝精靈裝機當下把自己還有效的 OAuth token 隨這個表頭遞來 + // (見 lib/portal-auth-store.ts authStoreWritable 的完整說明)。一般用戶自己在瀏覽器 + // 敲 /console/setup 不會帶這個表頭,行為與今天完全一樣(沒有 token 就是沒有 override)。 + await saveCredentials(c.env, record, c.req.header('x-arcrun-install-token')); } catch (e) { // 寫不進去就誠實回報(不假綠:舊版寫 KV 幾乎不會失敗,於是沒人處理過這條路) const msg = e instanceof AuthStoreWriteError ? e.message : String(e); 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/portal.ts b/cypher-executor/src/routes/portal.ts index caf656e..3c674c3 100644 --- a/cypher-executor/src/routes/portal.ts +++ b/cypher-executor/src/routes/portal.ts @@ -367,7 +367,12 @@ interface CreateUserInput { * 寫入路徑未就緒就誠實拋錯(AuthStoreWriteError → 502),不偷偷退回舊家—— * 退回去等於這個帳號下次搬資料時又會不見,那正是本案要根治的病。 */ -async function createPortalUser(env: Bindings, input: CreateUserInput): Promise { +/** + * `installToken`(2026-08-14,arcrun-rag#99):只有 `/portal/admin/bootstrap`(安裝精靈那條路) + * 會傳這個值——見 `lib/portal-auth-store.ts authStoreWritable` 的完整說明。`/portal/admin/users` + * 這條「管理員事後手動加人」路徑不傳,行為不變(仍要 `env.CF_SECRETS_API_TOKEN` 就緒)。 + */ +async function createPortalUser(env: Bindings, input: CreateUserInput, installToken?: string): Promise { const now = new Date().toISOString(); const id = newAuthUserId(); await mutateAuthStore(env, (data) => { @@ -382,7 +387,7 @@ async function createPortalUser(env: Bindings, input: CreateUserInput): Promise< created_at: now, updated_at: now, }); - }); + }, installToken); return id; } @@ -1073,13 +1078,17 @@ portalRouter.post('/portal/admin/bootstrap', (c) => if (password.length < 8) return c.json({ error: '密碼至少 8 碼' }, 400); if (await findUserRecordId(c.env, email)) return c.json({ error: '此 email 已存在' }, 409); + // arcrun-rag#99:安裝精靈裝機當下把自己還有效的 OAuth token 隨這個表頭遞來(一般用戶 + // 自己在瀏覽器完成 bootstrap 不會帶這個表頭,行為與今天完全一樣)。只有這條「建立第一個 + // admin」的路徑吃它——bootstrap 已經被上面的「已有 admin → 409」擋成只能成功一次, + // 不會被拿去反覆濫用;見 lib/portal-auth-store.ts authStoreWritable 的完整說明。 const recordId = await createPortalUser(c.env, { email, display_name: displayName, role: 'admin', libraries: ['*'], // bootstrap admin 預設全庫(design §3.3:["*"]=不注 library filter) password_hash: await hashPassword(password), - }); + }, c.req.header('x-arcrun-install-token')); return c.json({ success: true, record_id: recordId, email, role: 'admin' }); }), ); diff --git a/cypher-executor/tests/portal-auth.test.ts b/cypher-executor/tests/portal-auth.test.ts index 448a525..7e31bff 100644 --- a/cypher-executor/tests/portal-auth.test.ts +++ b/cypher-executor/tests/portal-auth.test.ts @@ -682,3 +682,73 @@ 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,讓它隨 `/portal/admin/bootstrap` 請求帶入 +// (`x-arcrun-install-token` 表頭),cypher 收到才用這一次、不落地(見 credentials.ts +// putWorkerSecret 的完整說明)。下面兩則證明:①這確實是原本會擋死的斷點 ②帶表頭後真的解掉。 +// +// 🔴 刻意放在檔案最後:這兩則會真的寫入認證儲存(per-isolate overlay 是模組級全域變數, +// 不隨 test 重置,見 mockAuthStoreWrite 檔頭長註解),排在前面會污染後面測試的「乾淨」假設。 +describe('arcrun-rag#99:全新實例(缺 CF_SECRETS_API_TOKEN)靠安裝表頭補完寫入路徑', () => { + it('沒帶安裝表頭 → 502 auth_store_not_writable,證明這是真斷點(不是想像出來的假設)', async () => { + await env.SESSIONS_KV.put('console_sess:owner-token-fresh', JSON.stringify({ created_at: Date.now() })); + mockTemplatesExist(); + mockListByTemplate('portal_user', []); + mockHeadLookup('fresh-install-noheader@example.com', null); + // 刻意不掛 mockAuthStoreWrite:authStoreWritable() 應該在打任何 CF API 之前就短路。 + // 若程式碼退化成先打了 fetch 才失敗,這裡沒有攔截器會接住它,disableNetConnect 讓那次 + // 意外的 fetch 直接拋錯,一樣會讓這則測試失敗——兩種退化路徑都攔得住。 + const fakeEnv = { ...env, CF_SECRETS_API_TOKEN: undefined } as unknown as Bindings; + const res = await portalRouter.fetch( + new Request('http://localhost/portal/admin/bootstrap', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer owner-token-fresh' }, + body: JSON.stringify({ email: 'fresh-install-noheader@example.com', password: 'bootstrap-pw-3' }), + }), + fakeEnv, + {} as ExecutionContext, + ); + expect(res.status).toBe(502); + const data = (await res.json()) as { code: string }; + expect(data.code).toBe('auth_store_not_writable'); + }); + + it('帶安裝表頭(安裝精靈那條路)→ 仍能建第一個 admin,明碼絕不落地', async () => { + await env.SESSIONS_KV.put('console_sess:owner-token-fresh2', JSON.stringify({ created_at: Date.now() })); + mockTemplatesExist(); + mockListByTemplate('portal_user', []); + mockHeadLookup('fresh-install-admin@example.com', null); + const { puts } = mockAuthStoreWrite(); + + const fakeEnv = { ...env, CF_SECRETS_API_TOKEN: undefined } as unknown as Bindings; + const res = await portalRouter.fetch( + new Request('http://localhost/portal/admin/bootstrap', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer owner-token-fresh2', + 'x-arcrun-install-token': 'fresh-oauth-token-from-installer', + }, + body: JSON.stringify({ email: 'fresh-install-admin@example.com', password: 'bootstrap-pw-4' }), + }), + fakeEnv, + {} as ExecutionContext, + ); + expect(res.status).toBe(200); + const data = (await res.json()) as Record; + expect(data.success).toBe(true); + expect((data.record_id as string).startsWith(AUTH_ID_PREFIX)).toBe(true); + + const shards = puts(); + expect(shards.length).toBe(1); + expect(shards[0].name).toBe('ARCRUN_AUTH_STORE'); + const shard = JSON.parse(shards[0].text) as { users: Array<{ email: string; role: string }> }; + expect(shard.users[0].email).toBe('fresh-install-admin@example.com'); + expect(shard.users[0].role).toBe('admin'); + expect(shards[0].text).not.toContain('bootstrap-pw-4'); // 明碼絕不落地 + }); +});