b223a69884
leo 2026-08-12 實撞:藏書地圖回 0 個庫,同一分鐘 KBDB 裡有 1854 條三元組,
`arcrun_whoami` 顯示 admin/全部知識庫、`kbdb_search` 也查得到——只有地圖那格是空的。
病根(不是資料掉了,是讀寫兩端各拿一個來源):
寫入端 owner_id = `~/.arcrun/config.yaml` 的 `api_key`(CLI push/小幫手上傳/MCP,
leo = `bfezv28v`)
讀取端過濾 = `portalTenant(env) = env.CONSOLE_TENANT || "leo"`
——repo toml 帶的**官方 prod 值**,而 `acr` 從來不注入 CONSOLE_TENANT
⇒ 那個 `"leo"` 不是理論邊角,是每台 self-hosted 實例的實際行為,1854 條全被濾掉。
與 #105(`env.MCP_OWNER_NAMESPACE || "leo"`)同一句話,換一個檔案。
租戶字串該從哪裡來(本票的核心判斷):
**從「寫入這批知識的那一方」來,不是從一份手抄的環境變數預設值來。**
不是「掛到每個帳號上」——portal 帳號共用同一台實例的知識庫(design D-2),
帳號之間的差別是 libraries 權限不是 owner_id;複製一份到帳號上只是多一個會過期的副本。
#105 真正的教訓是:過濾用的租戶字串要有單一權威來源、解析不到要誠實失敗、且要能機械驗證。
修法:
1. 唯一產地 `cypher-executor/src/lib/tenant.ts`
- `knowledgeOwner(env)` → branded `TenantId`:`ARCRUN_NAMESPACE` → `CONSOLE_TENANT` →
丟 `TenantUnresolvedError`。**沒有字面預設值**——`|| 'leo'` 正是把「這台機器沒設定」
偽裝成「你沒有資料」的元凶。
- `accountTenant(env)` → 普通 `string`(帳號子 namespace `{tenant}::portal` 與 cypher
自己寫的設定用它)。**回 string 是刻意的**:型別上就不可能流進知識資料面。
- 資料面過濾一律經 `ownerQuery()` / `ownerField()`,只吃 `TenantId`。
2. 值的正解由 CLI 從真相源導出:`acr update` 把 config 的 `api_key` 注入成 `ARCRUN_NAMESPACE`,
但**先驗再寫**(`GET /kbdb/map?owner_id=<api_key>` 查得到庫才寫;查不到/問不到就一個字
都不動)。無條件覆蓋會把「知識本來就在 CONSOLE_TENANT 底下」的一鍵安裝實例指向空的那一格
——那是 #97/#106 那類「更新一次把人家的東西弄不見」,比原本的 bug 更糟。
未注入時回退 CONSOLE_TENANT ⇒ 對官方 prod 與未更新的實例,這次改動是惰性的。
3. 空地圖分四態(沿 #100「讀不到就說讀不到」):no_library_grant/filtered_out/
scope_mismatch/confirmed_empty。scope_mismatch 以前不存在,所以設定錯誤被畫成
「你沒有資料」。回應仍不含租戶字串(design §3.3 紅線)。
4. 同族一起修(同一道閘一次抓到):console-dashboard 4 處、console-auth 1 處
——console 首頁的規模數字與藏書地圖對 leo 也一直是空的。
留下的閘(規則存在但沒機制驗證=會再犯第三次):
· 型別閘:TenantId 只能由 tenant.ts 產出 → 拿隨手一個 string 去過濾,tsc 當場不給過。
· 出貨閘:scripts/build-worker-artifacts.mjs 編 tier2 成品前先掃,違規 → 編不出成品。
· 閘自己可測:規則是純函式(tenant-source-rules.mjs),tests/tenant-gate.test.ts
逐條驗「5 種壞例子會擋」+「11 種合法寫法零誤攔」;掃描範圍只有 src/,擋不到自己。
規範寫入 .claude/rules/02-forbidden.md 第六類、system-dev/wiki/mistakes.md #26。
沒動:庫權限過濾(一字未改,回歸測試釘住)、帳號資料落點、任何金鑰、租戶字串仍不下發前端。
驗證:
cypher vitest 441 綠 / 14 紅,14 紅與 base commit e05518a 逐字相同(既有)
tsc 5 個既有錯誤,零新增
cli node:test 60/60 綠(含本次新增 12 條);tsc 零錯誤
閘 壞例子實跑 exit 1;build 實跑「建置中止」;乾淨時實跑通過
端到端 ◐ 未驗:需部署到 leo21c,那道閘要 leo 親手解(見 PR ③)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
276 lines
13 KiB
TypeScript
276 lines
13 KiB
TypeScript
/**
|
||
* arcrun console 登入(Arcrun#3 發現②,2026-07-03)
|
||
*
|
||
* 背景:console v0 那格叫「API Key」但 self-hosted 單租戶下其實只是 namespace 明碼字串,
|
||
* 不是註冊制 key(leo 原話:理論上根本沒有 API Key 這件事)。leo 拍板:
|
||
* 換成簡單 email+password 登入頁(自己設一組帳密即可,不用第三方 OAuth 或高級機制),
|
||
* 登入成功後端發 session token 存 localStorage;**後端 API 呼叫仍是用固定租戶字串打
|
||
* KBDB**(登入系統只是擋外人看到頁面,不是要做多租戶)。
|
||
*
|
||
* 與 routes/auth.ts 的差異:auth.ts 是官方 SaaS 的 Google/GitHub OAuth 多租戶註冊(每個
|
||
* 使用者各自一把 ak_... api_key,各自一個租戶)。這裡是 self-hosted console 的「單一管理員
|
||
* 帳密」— 全站只有一組帳密,只為擋外人看頁面,不產生新租戶、不核發 API key。
|
||
*
|
||
* 帳密怎麼設(不是雲端工人幫 leo 決定密碼):首次造訪 /console 時若尚未設定過,前端會走
|
||
* 「首次設定」流程(POST /console/setup)——leo 自己在瀏覽器輸入 email/password,一次性寫入
|
||
* SESSIONS_KV `console:credentials`(已存在就 409,不能覆蓋,換帳密走 /console/setup/reset
|
||
* 需帶舊密碼)。之後才是一般登入(POST /console/login)。
|
||
*
|
||
* 固定租戶字串:CONSOLE_TENANT([vars],非機密——self-hosted 架構本就是明碼 namespace)。
|
||
* 預設 "leo"(Arcrun#3 發現①已核實:owner_id='leo' 是 D1 中 458,357 筆資料實際使用的租戶字串,
|
||
* ak_... 只有 2 筆孤兒資料,故統一收斂到 'leo',不製造第三個租戶)。
|
||
*/
|
||
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';
|
||
// Arcrun#108:租戶字串唯一產地。
|
||
import { knowledgeOwner } from '../lib/tenant';
|
||
|
||
export const consoleAuthRouter = new Hono<{ Bindings: Bindings }>();
|
||
|
||
const CREDS_KEY = 'console:credentials';
|
||
const SESSION_PREFIX = 'console_sess:';
|
||
const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 天
|
||
|
||
/**
|
||
* 驗證 console session(給其他 route 共用,如 /console/triage-data)。
|
||
* authHeader 形如 "Bearer <token>";有效回 true。
|
||
*/
|
||
export async function validateConsoleSession(env: Bindings, authHeader: string | undefined): Promise<boolean> {
|
||
const token = (authHeader ?? '').match(/^Bearer\s+(\S+)/i)?.[1];
|
||
if (!token) return false;
|
||
const sess = await env.SESSIONS_KV.get(`${SESSION_PREFIX}${token}`);
|
||
return !!sess;
|
||
}
|
||
|
||
interface StoredCredentials {
|
||
email: string;
|
||
salt: string; // hex
|
||
hash: string; // hex,sha256(salt + password) 迭代 3 次
|
||
created_at: string;
|
||
}
|
||
|
||
function randomHex(bytes: number): string {
|
||
const arr = new Uint8Array(bytes);
|
||
crypto.getRandomValues(arr);
|
||
return Array.from(arr).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||
}
|
||
|
||
async function sha256Hex(input: string): Promise<string> {
|
||
const data = new TextEncoder().encode(input);
|
||
const digest = await crypto.subtle.digest('SHA-256', data);
|
||
return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||
}
|
||
|
||
/** 簡易迭代雜湊(非 bcrypt/argon2,但比單輪 SHA-256 好一點;self-hosted 單管理員帳密,威脅模型輕)。 */
|
||
async function hashPassword(password: string, salt: string): Promise<string> {
|
||
let h = `${salt}:${password}`;
|
||
for (let i = 0; i < 3; i++) h = await sha256Hex(h);
|
||
return h;
|
||
}
|
||
|
||
/**
|
||
* console 登入後下發給前端當 api_key 用的租戶字串(舊 console 的設計,與 portal 不同:
|
||
* portal 絕不下發,console 會)。
|
||
*
|
||
* Arcrun#108:這是**知識資料面**的 owner_id(前端拿它直打 `/kbdb/*`),所以必須與寫入端
|
||
* (CLI/小幫手/MCP 用的實例 namespace)同源。以前直接讀 `env.CONSOLE_TENANT || 'leo'`
|
||
* ⇒ 與 portal 同一個病:資料在 `bfezv28v`、過濾拿 `leo`,console 首頁的藏書地圖同樣是空的。
|
||
* 現在走唯一產地 `lib/tenant.ts`。
|
||
*/
|
||
function tenantOf(c: { env: Bindings }): string {
|
||
return knowledgeOwner(c.env);
|
||
}
|
||
|
||
// ── D61:帳密的家 ─────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* 讀出 console 管理員帳密。**新家(Workers Secrets)優先**;沒有才回退舊家(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' };
|
||
|
||
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' };
|
||
}
|
||
try {
|
||
await mutateAuthStore(env, (data) => {
|
||
if (!data.console) data.console = legacy as AuthConsoleRecord;
|
||
});
|
||
} catch {
|
||
/* 搬不動就照舊用 KV 這份(狀態看 /health 的 auth_store) */
|
||
}
|
||
return { creds: legacy, source: 'legacy-kv' };
|
||
}
|
||
|
||
/** 寫入 console 管理員帳密——**只寫新家**,不再寫 KV(寫回去等於把病種回土裡)。 */
|
||
async function saveCredentials(env: Bindings, record: StoredCredentials): Promise<void> {
|
||
await mutateAuthStore(env, (data) => {
|
||
data.console = record;
|
||
});
|
||
}
|
||
|
||
// GET /console/auth-status — 前端用來決定顯示「首次設定」還是「登入」表單。不洩漏 email。
|
||
consoleAuthRouter.get('/console/auth-status', async (c) => {
|
||
const { creds, source } = await loadCredentials(c.env);
|
||
// D61:多回一個 auth_store 區塊——「認證住在哪、寫不寫得進去」要在實例自己這一側看得出來,
|
||
// 不是等用戶登不進去才發現(#10「寧可明顯失敗,不要靜默錯置」)。
|
||
return c.json({ configured: !!creds, credentials_source: source, auth_store: authStoreStatus(c.env) });
|
||
});
|
||
|
||
// POST /console/setup — 首次設定帳密(body: {email, password})。已設定過 → 409(不可覆蓋,防外人搶注)。
|
||
consoleAuthRouter.post('/console/setup', async (c) => {
|
||
const { creds: existing } = await loadCredentials(c.env);
|
||
if (existing) {
|
||
// D61 明顯失敗:舊版只說「已設定過」,**沒說剛才填的那組密碼被整個丟掉了**——
|
||
// 用戶(含安裝精靈裡的 leo)以為自己剛設好了新密碼,其實從頭到尾沒有被採用過。
|
||
return c.json(
|
||
{
|
||
error:
|
||
'這台實例已經有管理員帳密了,**你剛才輸入的密碼沒有被採用**,目前的密碼仍是當初設定的那一組。' +
|
||
'要用舊密碼登入,或用 /console/setup/reset(需要舊密碼)換一組。',
|
||
code: 'already_configured',
|
||
password_applied: false,
|
||
reset_path: '/console/setup/reset',
|
||
},
|
||
409,
|
||
);
|
||
}
|
||
|
||
const body = await c.req.json().catch(() => null);
|
||
const email = (body?.email ?? '').trim();
|
||
const password = body?.password ?? '';
|
||
if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400);
|
||
if (password.length < 8) return c.json({ error: '密碼至少 8 碼' }, 400);
|
||
|
||
const salt = randomHex(16);
|
||
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);
|
||
} catch (e) {
|
||
// 寫不進去就誠實回報(不假綠:舊版寫 KV 幾乎不會失敗,於是沒人處理過這條路)
|
||
const msg = e instanceof AuthStoreWriteError ? e.message : String(e);
|
||
return c.json({ error: `帳密沒有存起來:${msg}`, 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() }), {
|
||
expirationTtl: SESSION_TTL_SECONDS,
|
||
});
|
||
return c.json({ success: true, session_token: token, tenant: tenantOf(c) });
|
||
});
|
||
|
||
// POST /console/setup/reset — 換帳密(body: {current_password, email, password})。需驗舊密碼,防外人重設。
|
||
consoleAuthRouter.post('/console/setup/reset', async (c) => {
|
||
const { creds: existing } = await loadCredentials(c.env);
|
||
if (!existing) return c.json({ error: '尚未設定過,請用 /console/setup' }, 400);
|
||
|
||
const body = await c.req.json().catch(() => null);
|
||
const currentPassword = body?.current_password ?? '';
|
||
const email = (body?.email ?? '').trim();
|
||
const password = body?.password ?? '';
|
||
if (!currentPassword || !email || !password) return c.json({ error: 'current_password、email、password 必填' }, 400);
|
||
if (password.length < 8) return c.json({ error: '新密碼至少 8 碼' }, 400);
|
||
|
||
const currentHash = await hashPassword(currentPassword, existing.salt);
|
||
if (currentHash !== existing.hash) return c.json({ error: '舊密碼不正確' }, 401);
|
||
|
||
const salt = randomHex(16);
|
||
const hash = await hashPassword(password, salt);
|
||
const record: StoredCredentials = { email: email.toLowerCase(), salt, hash, created_at: existing.created_at };
|
||
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({ success: true });
|
||
});
|
||
|
||
// POST /console/login — body: {email, password}。成功 → session token(localStorage 存這個,不存密碼)。
|
||
consoleAuthRouter.post('/console/login', async (c) => {
|
||
const { creds: existing } = await loadCredentials(c.env);
|
||
if (!existing) {
|
||
// D61 明顯失敗:這是「這台實例讀不到認證資料」,不是「你帳密打錯」
|
||
return c.json(
|
||
{
|
||
error: '這台實例還沒有管理員帳密(或讀不到)——不是密碼錯。請先完成首次設定。',
|
||
code: 'auth_store_empty',
|
||
auth_store: authStoreStatus(c.env),
|
||
},
|
||
400,
|
||
);
|
||
}
|
||
|
||
const body = await c.req.json().catch(() => null);
|
||
const email = (body?.email ?? '').trim().toLowerCase();
|
||
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) {
|
||
return c.json({ error: 'email 或密碼錯誤' }, 401);
|
||
}
|
||
|
||
const token = randomHex(32);
|
||
await c.env.SESSIONS_KV.put(`${SESSION_PREFIX}${token}`, JSON.stringify({ created_at: Date.now() }), {
|
||
expirationTtl: SESSION_TTL_SECONDS,
|
||
});
|
||
return c.json({ success: true, session_token: token, tenant: tenantOf(c) });
|
||
});
|
||
|
||
// GET /console/session — Authorization: Bearer <session_token>。前端載入頁面時用來確認 session 還有效
|
||
// + 拿回固定租戶字串(不必再手貼 API Key)。
|
||
consoleAuthRouter.get('/console/session', async (c) => {
|
||
const auth = c.req.header('authorization') ?? '';
|
||
const token = auth.match(/^Bearer\s+(\S+)/i)?.[1];
|
||
if (!token) return c.json({ valid: false }, 401);
|
||
const sess = await c.env.SESSIONS_KV.get(`${SESSION_PREFIX}${token}`);
|
||
if (!sess) return c.json({ valid: false }, 401);
|
||
return c.json({ valid: true, tenant: tenantOf(c) });
|
||
});
|
||
|
||
// POST /console/logout — Authorization: Bearer <session_token>。
|
||
consoleAuthRouter.post('/console/logout', async (c) => {
|
||
const auth = c.req.header('authorization') ?? '';
|
||
const token = auth.match(/^Bearer\s+(\S+)/i)?.[1];
|
||
if (token) await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`);
|
||
return c.json({ success: true });
|
||
});
|