feat(auth): 認證儲存搬回 D1/KV——落實方案C(leo confirm「走C」,arcrun-rag#99)
實作 pending-changes.md「認證儲存要不要搬回 D1/KV」提案(commit 8286c8a): D61 的病根「重裝時 binding 被安裝器照名字重新指到新建空資源」已被更通用的 shared/resource-rule(Arcrun#97,2026-08-13)解掉,故不再需要繞開 binding 去躲這個病——而繞開的代價正是這次要收的債:Workers Secrets 寫入需要外部 CF_SECRETS_API_TOKEN,這把 token 從安裝那天起就沒被種過,止血版只解掉 「建第一個帳號」這一格,之後的每一次寫入(換密碼/加帳號/改權限)仍卡死。 改動: - console 管理員帳密:家改回 SESSIONS_KV(binding,console-auth.ts) - portal 多人帳號:家改回 KBDB(binding,走 base HTTP API,D38 零 SQL,portal.ts) - D61 認證儲存(CF Workers Secrets)留為舊實例的唯讀回退路徑:讀取零成本、 零外部憑證需求(只有寫入才要 token);登入成功即 best-effort 自動搬進新家, 且**這次登入發出的 session 就直接指向新 record_id**(不必等下一次登入) - D61 的三項「明顯失敗」語意全部保留:auth_store_empty(讀不到不算密碼錯、 不計入鎖定)、/console/setup 遇既有帳號說清楚密碼沒被採用、/health 與 /console/auth-status 吐儲存狀態 - 移除止血版的 x-arcrun-install-token 表頭傳遞機制(installToken 參數)—— 帳號寫入從此不需要任何外部 CF token,這個結構性缺口已從根拔除 測試:cypher-executor 全套 vitest 439/453(14 個既存失敗與本改動無關,已用 git stash 對照 clean checkout 逐一比對檔名確認完全相同);tsc --noEmit 無新增錯誤(3 個既存錯誤同上核實無關)。已跑 build-worker-artifacts.mjs 重打 tier2 bundle,grep 複驗 createKbdbUserRecord/promoteToKbdb 進了成品、 promoteLegacyUser/x-arcrun-install-token 完全從成品消失。 未覆蓋:POST /credentials(一般 workflow API 金鑰儲存)仍依賴 CF_SECRETS_API_TOKEN——這是 01-tech-stack.md 既有的、獨立於 D61 之外的 credential 儲存架構(D19「擁有目錄不擁有內容物」),本提案範圍只涵蓋「認證」 (登入帳密),不涵蓋一般 credential 儲存;07-29 已知缺口仍待另案處理。 不准 merge 進 main(SDD 鐵律③,等總管審過再併);不准部署(D20 出貨閘)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -9200,9 +9200,6 @@ function shardIndex(name) {
|
||||
function shardNameOf(index) {
|
||||
return index === 0 ? AUTH_STORE_PREFIX : `${AUTH_STORE_PREFIX}_${index}`;
|
||||
}
|
||||
function authStorePresent(env) {
|
||||
return shardNames(env).length > 0 || overlay !== null && Date.now() - overlayAt < AUTH_OVERLAY_TTL_MS;
|
||||
}
|
||||
function authStoreWritable(env, tokenOverride) {
|
||||
return Boolean((tokenOverride || env.CF_SECRETS_API_TOKEN) && env.CF_ACCOUNT_ID);
|
||||
}
|
||||
@@ -9240,11 +9237,6 @@ function findAuthUserById(env, id) {
|
||||
function isAuthStoreId(recordId) {
|
||||
return recordId.startsWith(AUTH_ID_PREFIX);
|
||||
}
|
||||
function newAuthUserId() {
|
||||
const arr = new Uint8Array(12);
|
||||
crypto.getRandomValues(arr);
|
||||
return AUTH_ID_PREFIX + Array.from(arr).map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
async function writeAuthStore(env, data, tokenOverride) {
|
||||
if (!authStoreWritable(env, tokenOverride)) {
|
||||
throw new AuthStoreWriteError(
|
||||
@@ -9328,19 +9320,16 @@ async function mutateAuthStore(env, fn, tokenOverride) {
|
||||
await writeAuthStore(env, next, tokenOverride);
|
||||
return next;
|
||||
}
|
||||
function authStoreStatus(env) {
|
||||
const data = readAuthStore(env);
|
||||
return {
|
||||
present: authStorePresent(env),
|
||||
writable: authStoreWritable(env),
|
||||
users: data.users.length,
|
||||
console_configured: Boolean(data.console),
|
||||
shards: shardNames(env).length
|
||||
};
|
||||
}
|
||||
|
||||
// cypher-executor/src/routes/health.ts
|
||||
var healthRouter = new Hono2();
|
||||
function authStoreStatus(env) {
|
||||
const legacy = readAuthStore(env);
|
||||
return {
|
||||
console: { home: "sessions-kv", writable: true, legacy_secrets_present: legacy.console !== null },
|
||||
portal_users: { home: "kbdb", writable: true, legacy_secrets_present: legacy.users.length > 0 }
|
||||
};
|
||||
}
|
||||
healthRouter.get("/health", (c) => {
|
||||
const bundleVersion = c.env.ARCRUN_BUNDLE_VERSION;
|
||||
const bundleCommit = c.env.ARCRUN_BUNDLE_COMMIT;
|
||||
@@ -12627,35 +12616,35 @@ function tenantOf(c) {
|
||||
return knowledgeOwner(c.env);
|
||||
}
|
||||
async function loadCredentials(env) {
|
||||
let fromStore = readAuthStore(env).console;
|
||||
if (!fromStore && await hydrateFromAccelerator(env)) {
|
||||
fromStore = readAuthStore(env).console;
|
||||
}
|
||||
if (fromStore) return { creds: fromStore, source: "secrets" };
|
||||
const raw2 = await env.SESSIONS_KV.get(CREDS_KEY);
|
||||
if (!raw2) return { creds: null, source: "none" };
|
||||
let legacy = null;
|
||||
try {
|
||||
legacy = JSON.parse(raw2);
|
||||
} catch {
|
||||
return { creds: null, source: "none" };
|
||||
if (raw2) {
|
||||
try {
|
||||
return { creds: JSON.parse(raw2), source: "kv" };
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
const legacy = readAuthStore(env).console;
|
||||
if (!legacy) return { creds: null, source: "none" };
|
||||
try {
|
||||
await mutateAuthStore(env, (data) => {
|
||||
if (!data.console) data.console = legacy;
|
||||
});
|
||||
await env.SESSIONS_KV.put(CREDS_KEY, JSON.stringify(legacy));
|
||||
} catch {
|
||||
}
|
||||
return { creds: legacy, source: "legacy-kv" };
|
||||
return { creds: legacy, source: "legacy-secrets" };
|
||||
}
|
||||
async function saveCredentials(env, record, installToken) {
|
||||
await mutateAuthStore(env, (data) => {
|
||||
data.console = record;
|
||||
}, installToken);
|
||||
async function saveCredentials(env, record) {
|
||||
await env.SESSIONS_KV.put(CREDS_KEY, JSON.stringify(record));
|
||||
}
|
||||
function consoleAuthStoreStatus(env) {
|
||||
return {
|
||||
home: "sessions-kv",
|
||||
writable: true,
|
||||
// binding-based,只要 wrangler.toml 有這個 binding 就一定寫得進去
|
||||
legacy_secrets_present: readAuthStore(env).console !== null
|
||||
};
|
||||
}
|
||||
consoleAuthRouter.get("/console/auth-status", async (c) => {
|
||||
const { creds, source } = await loadCredentials(c.env);
|
||||
return c.json({ configured: !!creds, credentials_source: source, auth_store: authStoreStatus(c.env) });
|
||||
return c.json({ configured: !!creds, credentials_source: source, auth_store: consoleAuthStoreStatus(c.env) });
|
||||
});
|
||||
consoleAuthRouter.post("/console/setup", async (c) => {
|
||||
const { creds: existing } = await loadCredentials(c.env);
|
||||
@@ -12679,10 +12668,9 @@ consoleAuthRouter.post("/console/setup", async (c) => {
|
||||
const hash = await hashPassword(password, salt);
|
||||
const record = { email: email.toLowerCase(), salt, hash, created_at: (/* @__PURE__ */ new Date()).toISOString() };
|
||||
try {
|
||||
await saveCredentials(c.env, record, c.req.header("x-arcrun-install-token"));
|
||||
await saveCredentials(c.env, record);
|
||||
} catch (e) {
|
||||
const msg = e instanceof AuthStoreWriteError ? e.message : String(e);
|
||||
return c.json({ error: `\u5E33\u5BC6\u6C92\u6709\u5B58\u8D77\u4F86\uFF1A${msg}`, code: "auth_store_not_writable" }, 502);
|
||||
return c.json({ error: `\u5E33\u5BC6\u6C92\u6709\u5B58\u8D77\u4F86\uFF1A${e instanceof Error ? e.message : String(e)}`, code: "auth_store_not_writable" }, 502);
|
||||
}
|
||||
const token = randomHex(32);
|
||||
await c.env.SESSIONS_KV.put(`${SESSION_PREFIX}${token}`, JSON.stringify({ created_at: Date.now() }), {
|
||||
@@ -12707,8 +12695,7 @@ consoleAuthRouter.post("/console/setup/reset", async (c) => {
|
||||
try {
|
||||
await saveCredentials(c.env, record);
|
||||
} catch (e) {
|
||||
const msg = e instanceof AuthStoreWriteError ? e.message : String(e);
|
||||
return c.json({ error: `\u65B0\u5E33\u5BC6\u6C92\u6709\u5B58\u8D77\u4F86\uFF1A${msg}`, code: "auth_store_not_writable" }, 502);
|
||||
return c.json({ error: `\u65B0\u5E33\u5BC6\u6C92\u6709\u5B58\u8D77\u4F86\uFF1A${e instanceof Error ? e.message : String(e)}`, code: "auth_store_not_writable" }, 502);
|
||||
}
|
||||
return c.json({ success: true });
|
||||
});
|
||||
@@ -12719,7 +12706,7 @@ consoleAuthRouter.post("/console/login", async (c) => {
|
||||
{
|
||||
error: "\u9019\u53F0\u5BE6\u4F8B\u9084\u6C92\u6709\u7BA1\u7406\u54E1\u5E33\u5BC6\uFF08\u6216\u8B80\u4E0D\u5230\uFF09\u2014\u2014\u4E0D\u662F\u5BC6\u78BC\u932F\u3002\u8ACB\u5148\u5B8C\u6210\u9996\u6B21\u8A2D\u5B9A\u3002",
|
||||
code: "auth_store_empty",
|
||||
auth_store: authStoreStatus(c.env)
|
||||
auth_store: consoleAuthStoreStatus(c.env)
|
||||
},
|
||||
400
|
||||
);
|
||||
@@ -12728,18 +12715,8 @@ consoleAuthRouter.post("/console/login", async (c) => {
|
||||
const email = (body?.email ?? "").trim().toLowerCase();
|
||||
const password = body?.password ?? "";
|
||||
if (!email || !password) return c.json({ error: "email \u8207 password \u5FC5\u586B" }, 400);
|
||||
let creds = existing;
|
||||
let hash = await hashPassword(password, creds.salt);
|
||||
if (email !== creds.email || hash !== creds.hash) {
|
||||
if (await hydrateFromAccelerator(c.env)) {
|
||||
const again = (await loadCredentials(c.env)).creds;
|
||||
if (again) {
|
||||
creds = again;
|
||||
hash = await hashPassword(password, creds.salt);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (email !== creds.email || hash !== creds.hash) {
|
||||
const hash = await hashPassword(password, existing.salt);
|
||||
if (email !== existing.email || hash !== existing.hash) {
|
||||
return c.json({ error: "email \u6216\u5BC6\u78BC\u932F\u8AA4" }, 401);
|
||||
}
|
||||
const token = randomHex(32);
|
||||
@@ -13010,24 +12987,31 @@ function recordValuesToAuthUser(id, v) {
|
||||
updated_at: v.updated_at ?? (/* @__PURE__ */ new Date()).toISOString()
|
||||
};
|
||||
}
|
||||
async function promoteLegacyUser(env, rec) {
|
||||
async function promoteToKbdb(env, rec) {
|
||||
try {
|
||||
const email = (rec.values.email ?? "").toLowerCase();
|
||||
if (!email) return;
|
||||
if (findAuthUserByEmail(env, email)) return;
|
||||
await mutateAuthStore(env, (data) => {
|
||||
if (data.users.some((u) => u.email === email)) return;
|
||||
data.users.push(recordValuesToAuthUser(newAuthUserId(), rec.values));
|
||||
if (!email) return null;
|
||||
const already = await findKbdbUserRecordId(env, email);
|
||||
if (already) return already;
|
||||
return await createKbdbUserRecord(env, email, {
|
||||
display_name: rec.values.display_name ?? "",
|
||||
status: rec.values.status ?? "active",
|
||||
role: rec.values.role ?? "user",
|
||||
password_hash: rec.values.password_hash ?? "",
|
||||
libraries: rec.values.libraries ?? "[]",
|
||||
created_at: rec.values.created_at ?? (/* @__PURE__ */ new Date()).toISOString(),
|
||||
updated_at: rec.values.updated_at ?? (/* @__PURE__ */ new Date()).toISOString()
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
async function findUserRecordId(env, email) {
|
||||
const inStore = findAuthUserByEmail(env, email);
|
||||
if (inStore) return inStore.id;
|
||||
return findLegacyUserRecordId(env, email);
|
||||
const inKbdb = await findKbdbUserRecordId(env, email);
|
||||
if (inKbdb) return inKbdb;
|
||||
return findAuthUserByEmail(env, email)?.id ?? null;
|
||||
}
|
||||
async function findLegacyUserRecordId(env, email) {
|
||||
async function findKbdbUserRecordId(env, email) {
|
||||
const ns = portalNamespace(env);
|
||||
const params = new URLSearchParams({
|
||||
page_name: email,
|
||||
@@ -13096,42 +13080,53 @@ function daemonActiveKey(env) {
|
||||
}
|
||||
async function listRecordsByTemplate(env, template) {
|
||||
if (template === USER_TEMPLATE) {
|
||||
const fromStore = readAuthStore(env).users.map(authUserToRecord);
|
||||
const seen = new Set(fromStore.map((r) => (r.values.email ?? "").toLowerCase()));
|
||||
let legacy = [];
|
||||
let fromKbdb = [];
|
||||
try {
|
||||
legacy = await listLegacyRecordsByTemplate(env, template);
|
||||
fromKbdb = await listKbdbRecordsByTemplate(env, template);
|
||||
} catch {
|
||||
legacy = [];
|
||||
fromKbdb = [];
|
||||
}
|
||||
return [...fromStore, ...legacy.filter((r) => !seen.has((r.values.email ?? "").toLowerCase()))];
|
||||
const seen = new Set(fromKbdb.map((r) => (r.values.email ?? "").toLowerCase()));
|
||||
const fromLegacy = readAuthStore(env).users.map(authUserToRecord).filter((r) => !seen.has((r.values.email ?? "").toLowerCase()));
|
||||
return [...fromKbdb, ...fromLegacy];
|
||||
}
|
||||
return listLegacyRecordsByTemplate(env, template);
|
||||
return listKbdbRecordsByTemplate(env, template);
|
||||
}
|
||||
async function listLegacyRecordsByTemplate(env, template) {
|
||||
async function listKbdbRecordsByTemplate(env, template) {
|
||||
const ns = portalNamespace(env);
|
||||
const res = await kbdbFetch(env, `/records/by-template/${encodeURIComponent(template)}?owner_id=${encodeURIComponent(ns)}`);
|
||||
if (!res.ok) throw new KbdbError(`GET /records/by-template/${template} \u2192 ${res.status}`);
|
||||
const body = await res.json();
|
||||
return body.records ?? [];
|
||||
}
|
||||
async function createPortalUser(env, input, installToken) {
|
||||
async function createKbdbUserRecord(env, email, values) {
|
||||
const ns = portalNamespace(env);
|
||||
const res = await kbdbFetch(env, "/records", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ template: USER_TEMPLATE, owner_id: ns, values: { ...values, email } })
|
||||
});
|
||||
if (!res.ok) throw new KbdbError(`POST /records\uFF08portal_user\uFF09\u2192 ${res.status}`);
|
||||
const body = await res.json();
|
||||
const recordId = body.record?.record_id;
|
||||
if (!recordId) throw new KbdbError("POST /records \u56DE\u61C9\u7F3A record_id");
|
||||
const head = await kbdbFetch(env, "/entries", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ entry_type: USER_TEMPLATE, page_name: email, content: recordId, owner_id: ns })
|
||||
});
|
||||
if (!head.ok) throw new KbdbError(`head entry \u5EFA\u7ACB\u5931\u6557\uFF08record ${recordId} \u5DF2\u5EFA\uFF0C\u9700\u4EBA\u5DE5\u6536\u62FE\uFF09\u2192 ${head.status}`);
|
||||
return recordId;
|
||||
}
|
||||
async function createPortalUser(env, input) {
|
||||
const now2 = (/* @__PURE__ */ new Date()).toISOString();
|
||||
const id = newAuthUserId();
|
||||
await mutateAuthStore(env, (data) => {
|
||||
data.users.push({
|
||||
id,
|
||||
email: input.email.toLowerCase(),
|
||||
display_name: input.display_name,
|
||||
status: "active",
|
||||
role: input.role,
|
||||
libraries: input.libraries,
|
||||
password_hash: input.password_hash,
|
||||
created_at: now2,
|
||||
updated_at: now2
|
||||
});
|
||||
}, installToken);
|
||||
return id;
|
||||
return createKbdbUserRecord(env, input.email.toLowerCase(), {
|
||||
display_name: input.display_name,
|
||||
status: "active",
|
||||
role: input.role,
|
||||
password_hash: input.password_hash,
|
||||
libraries: JSON.stringify(input.libraries),
|
||||
created_at: now2,
|
||||
updated_at: now2
|
||||
});
|
||||
}
|
||||
function parseLibraries(raw2) {
|
||||
if (!raw2) return [];
|
||||
@@ -13268,7 +13263,7 @@ async function clearLoginFail(env, email) {
|
||||
async function instanceHasNoAuthData(env) {
|
||||
if (readAuthStore(env).users.length > 0) return false;
|
||||
try {
|
||||
return (await listLegacyRecordsByTemplate(env, USER_TEMPLATE)).length === 0;
|
||||
return (await listKbdbRecordsByTemplate(env, USER_TEMPLATE)).length === 0;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
@@ -13305,7 +13300,7 @@ portalRouter.post(
|
||||
{
|
||||
error: "\u9019\u53F0\u5BE6\u4F8B\u8B80\u4E0D\u5230\u4EFB\u4F55\u767B\u5165\u8CC7\u6599\u2014\u2014\u4E0D\u662F\u5BC6\u78BC\u932F\u3002\u8A8D\u8B49\u5132\u5B58\u662F\u7A7A\u7684\uFF0C\u8ACB\u91CD\u65B0\u57F7\u884C\u5B89\u88DD\uFF0F\u66F4\u65B0\u4EE5\u91CD\u65B0\u5EFA\u7ACB\u7BA1\u7406\u54E1\u5E33\u865F\u3002",
|
||||
code: "auth_store_empty",
|
||||
auth_store: authStoreStatus(c.env)
|
||||
auth_store: { home: "kbdb", writable: true, users: 0 }
|
||||
},
|
||||
503
|
||||
);
|
||||
@@ -13320,10 +13315,14 @@ portalRouter.post(
|
||||
await recordLoginFail(c.env, email);
|
||||
return c.json({ error: "email \u6216\u5BC6\u78BC\u932F\u8AA4" }, 401);
|
||||
}
|
||||
if (!isAuthStoreId(recordId)) await promoteLegacyUser(c.env, rec);
|
||||
let sessionRecordId = recordId;
|
||||
if (isAuthStoreId(recordId)) {
|
||||
const migrated = await promoteToKbdb(c.env, rec);
|
||||
if (migrated) sessionRecordId = migrated;
|
||||
}
|
||||
await clearLoginFail(c.env, email);
|
||||
const token = randomHex2(32);
|
||||
await c.env.SESSIONS_KV.put(`${SESSION_PREFIX2}${token}`, JSON.stringify({ record_id: recordId }), {
|
||||
await c.env.SESSIONS_KV.put(`${SESSION_PREFIX2}${token}`, JSON.stringify({ record_id: sessionRecordId }), {
|
||||
expirationTtl: sessionTtl(c.env)
|
||||
});
|
||||
return c.json({
|
||||
@@ -13555,7 +13554,7 @@ portalRouter.post(
|
||||
libraries: ["*"],
|
||||
// bootstrap admin 預設全庫(design §3.3:["*"]=不注 library filter)
|
||||
password_hash: await hashPassword2(password)
|
||||
}, c.req.header("x-arcrun-install-token"));
|
||||
});
|
||||
return c.json({ success: true, record_id: recordId, email, role: "admin" });
|
||||
})
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user