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:
uncle6me-web
2026-08-14 11:46:56 +08:00
parent c3856470ad
commit 88f308642e
7 changed files with 160 additions and 46 deletions
@@ -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" });
})
);