fix(auth): 全新安裝補上 CF_SECRETS_API_TOKEN 的缺口——第一個帳號建得起來了(arcrun-rag#99)
根因:D61(認證與資料分離,c4cee35)把 /console/setup、/portal/admin/bootstrap 的寫入全部改走 CF Workers Secrets(env.CF_SECRETS_API_TOKEN),但這把 token 從安裝那天起就沒被種過——07-29 已知缺口(pending-changes.md「credential 走 n8n 模式」),當時只降級某個功能;D61 之後升級成「連第一個帳號都建不起來」 的硬斷點,每台全新安裝必中(leo 本人+封測者實撞:裝得起來但卡在註冊)。 修法:putWorkerSecret/deleteWorkerSecret/authStoreWritable/writeAuthStore/ mutateAuthStore 新增可選的 tokenOverride 參數(呼叫端提供 > worker 自身 env)。 /console/setup、/portal/admin/bootstrap 讀取 x-arcrun-install-token 表頭, 只有安裝精靈(裝機當下手上有一把自己還有效的 OAuth token,workers-scripts.write scope,同一把已用於 putWorkerSecretDirect/seedCredential)會帶這個表頭; 一般使用者自己在瀏覽器操作不受影響。沿用既有「D36 安裝器代寫」precedent, 不是新開一條路;bootstrap 本身已被「已有 admin → 409」擋成只能成功一次, 不會被拿來反覆濫用。 測試:cypher-executor vitest 443/457(14 個既存失敗與本改動無關,已用 git stash 對照確認);新增 2 則直接證明「缺 token→502 auth_store_not_writable/ 帶 token→200」。tsc --noEmit 無新增錯誤。已跑 build-worker-artifacts.mjs 重打 tier2 bundle 供驗證(工作區未 commit 前提下的本地驗證版)。 未完成:安裝器(products/arcrun-rag/installer/oauth-prototype/worker.js) 端的 x-arcrun-install-token 表頭傳遞已另外修好,但兩邊都還沒部署——需要 ①重打正式 worker artifact ②install.arcrun.dev 的安裝器 wrangler deploy ③已卡住的封測者要再走一次安裝精靈讓他的 cypher worker 拿到新 bundle。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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" });
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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<void> {
|
||||
if (!authStoreWritable(env)) {
|
||||
export async function writeAuthStore(env: Bindings, data: AuthStoreData, tokenOverride?: string): Promise<void> {
|
||||
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<void>,
|
||||
tokenOverride?: string,
|
||||
): Promise<AuthStoreData> {
|
||||
await hydrateFromAccelerator(env);
|
||||
const next = unionStores(readAuthStore(env), readAuthStoreFromEnv(env));
|
||||
await fn(next);
|
||||
await writeAuthStore(env, next);
|
||||
await writeAuthStore(env, next, tokenOverride);
|
||||
return next;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
/**
|
||||
* 寫入 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<void> {
|
||||
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);
|
||||
|
||||
@@ -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<void> {
|
||||
if (!env.CF_SECRETS_API_TOKEN || !env.CF_ACCOUNT_ID) {
|
||||
export async function putWorkerSecret(env: Bindings, secretRef: string, value: string, tokenOverride?: string): Promise<void> {
|
||||
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<void> {
|
||||
if (!env.CF_SECRETS_API_TOKEN || !env.CF_ACCOUNT_ID) {
|
||||
export async function deleteWorkerSecret(env: Bindings, secretRef: string, tokenOverride?: string): Promise<void> {
|
||||
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
|
||||
|
||||
@@ -367,7 +367,12 @@ interface CreateUserInput {
|
||||
* 寫入路徑未就緒就誠實拋錯(AuthStoreWriteError → 502),不偷偷退回舊家——
|
||||
* 退回去等於這個帳號下次搬資料時又會不見,那正是本案要根治的病。
|
||||
*/
|
||||
async function createPortalUser(env: Bindings, input: CreateUserInput): Promise<string> {
|
||||
/**
|
||||
* `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<string> {
|
||||
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' });
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
expect(data.success).toBe(true);
|
||||
expect((data.record_id as string).startsWith(AUTH_ID_PREFIX)).toBe(true);
|
||||
|
||||
const shards = puts();
|
||||
expect(shards.length).toBe(1);
|
||||
expect(shards[0].name).toBe('ARCRUN_AUTH_STORE');
|
||||
const shard = JSON.parse(shards[0].text) as { users: Array<{ email: string; role: string }> };
|
||||
expect(shard.users[0].email).toBe('fresh-install-admin@example.com');
|
||||
expect(shard.users[0].role).toBe('admin');
|
||||
expect(shards[0].text).not.toContain('bootstrap-pw-4'); // 明碼絕不落地
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user