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:
@@ -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,58 +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(寫回去等於把病種回土裡)。
|
||||
*
|
||||
* `installToken`(2026-08-14,arcrun-rag#99):見 `lib/portal-auth-store.ts authStoreWritable`
|
||||
* 的完整說明——這是安裝精靈裝機當下遞來、cypher 自己不落地的臨時 CF token,補的是
|
||||
* 「這台 worker 從沒被種過 CF_SECRETS_API_TOKEN」這個結構性缺口。
|
||||
* 寫入 console 管理員帳密——**只寫 SESSIONS_KV**(binding,永不需要外部 CF token)。
|
||||
* 不再寫回認證儲存(Workers Secrets):那是要被淘汰的舊家,寫回去等於把債種回土裡。
|
||||
*/
|
||||
async function saveCredentials(env: Bindings, record: StoredCredentials, installToken?: string): Promise<void> {
|
||||
await mutateAuthStore(env, (data) => {
|
||||
data.console = record;
|
||||
}, installToken);
|
||||
async function saveCredentials(env: Bindings, record: StoredCredentials): Promise<void> {
|
||||
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(不可覆蓋,防外人搶注)。
|
||||
@@ -177,14 +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 {
|
||||
// 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'));
|
||||
// 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);
|
||||
@@ -215,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 });
|
||||
});
|
||||
@@ -225,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,
|
||||
);
|
||||
@@ -241,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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user