/** * RAG Portal 多人授權 — P2:用戶模型+認證 API(portal-auth design §2/§4/§5,Gitea #24/#25) * * 架構(design D-1):Portal=cypher-executor 的 /portal 路由(非獨立 worker)。 * 本檔只做 P2 的 auth/admin API;P3(/portal HTML 殼+ /portal/data/* enforce)另一波。 * * 鐵律對照: * - rule 2.1/2.2:這是 UI session 登入(console-auth 同類先例),非 workflow credential * 原語。KDF 在 lib/portal-auth.ts(WebCrypto PBKDF2),本檔無解密/簽章/template 展開。 * - 零新表(design §2):portal_user / portal_library 都是 KBDB 萬用表 template; * 資料經 KBDB base HTTP API 寫入(kbdbBase 慣例,不直連 D1、不寫 SQL)。 * - 子 namespace(design D-2):一切帳號資料 owner_id=`{CONSOLE_TENANT}::portal`。 * 既有租戶查詢面(/kbdb/*、MCP)都以 CONSOLE_TENANT 過濾 → 物理上搜不到帳號資料 * (email/password_hash 不會出現在知識搜尋結果)。 * - session(design §4.3):KV `portal_sess:{token}` 只存 record_id(TTL 暫存=合規), * **每個請求回讀 user record 當唯一真相源** → 停用/改權限即時生效,不靠 session 反向索引。 * - 絕不下發租戶字串(design §3.3 關鍵差異 vs console):/portal/session 只回 * display_name/role/libraries。 * - 密碼永不明碼儲存、永不進 log(本檔不 log 任何 body)。 */ import { Hono } from 'hono'; import type { Context } from 'hono'; import type { Bindings } from '../types'; import { kbdbBase } from './kbdb-proxy'; import { validateConsoleSession } from './console-auth'; import { hashPassword, verifyPassword, randomHex, generatePassword } from '../lib/portal-auth'; import { PORTAL_TEMPLATE_SEEDS } from '../lib/portal-seeds'; // arcrun-rag#10:/portal/admin/ai 存 Gemini key 走 credentials.ts 的**唯一**寫入路徑, // 不在 portal 這層另造第二套儲存(D36:值進 Workers Secret,D1 只留 ref)。 import { storeCredential } from './credentials'; export const portalRouter = new Hono<{ Bindings: Bindings }>(); const SESSION_PREFIX = 'portal_sess:'; const LOCKFAIL_PREFIX = 'portal_lockfail:'; const LOCK_LIMIT = 5; // design §4.3:5 次失敗 const LOCK_TTL_SECONDS = 15 * 60; // 鎖 15 分鐘(KV TTL 自然過期) const DEFAULT_SESSION_TTL = 604800; // 7 天(design §4.3,比 console 30 天緊) const USER_TEMPLATE = 'portal_user'; const LIBRARY_TEMPLATE = 'portal_library'; // ── 基礎 helpers ──────────────────────────────────────────────────────────── /** 租戶字串(=知識資料的 owner_id)。預設沿 console-auth 同款 'leo'。**只在 server 側使用,永不下發前端**。 */ export function portalTenant(env: Bindings): string { return env.CONSOLE_TENANT || 'leo'; } /** 帳號子 namespace(design D-2)。 */ function portalNamespace(env: Bindings): string { return `${portalTenant(env)}::portal`; } function sessionTtl(env: Bindings): number { const n = Number.parseInt(env.PORTAL_SESSION_TTL ?? '', 10); // KV expirationTtl 下限 60 秒;壞值誠實退回預設而非炸掉 return Number.isFinite(n) && n >= 60 ? n : DEFAULT_SESSION_TTL; } function bearerToken(c: Context<{ Bindings: Bindings }>): string | null { const auth = c.req.header('authorization') ?? ''; return auth.match(/^Bearer\s+(\S+)/i)?.[1] ?? null; } /** KBDB 不可達/回錯時拋這個 → 各 route 統一 502 誠實回報(不假綠、不偽裝成 401)。 */ export class KbdbError extends Error {} export async function kbdbFetch(env: Bindings, path: string, init?: RequestInit): Promise { const { base, headers } = kbdbBase(env); let res: Response; try { res = await fetch(`${base}${path}`, { ...init, headers: { ...headers, ...(init?.headers as Record | undefined) } }); } catch (e) { throw new KbdbError(`fetch ${path} 失敗:${e instanceof Error ? e.message : String(e)}`); } return res; } /** route handler 包一層:KbdbError → 502(誠實),其餘照拋。 */ export async function run(c: Context<{ Bindings: Bindings }>, fn: () => Promise): Promise { try { return await fn(); } catch (e) { if (e instanceof KbdbError) return c.json({ error: `KBDB 不可達或回錯:${e.message}` }, 502); throw e; } } // ── KBDB 資料層 helpers(全走 base HTTP API,零 SQL)──────────────────────────── export interface PortalRecord { record_id: string; template_id: string; values: Record; } /** 冪等確保 portal templates 存在(seed 是 API 行為,rule 07;/init/seed 與 bootstrap 共用)。 */ export async function ensurePortalTemplates( env: Bindings, ): Promise<{ created: string[]; existing: string[]; errors: string[] }> { const created: string[] = []; const existing: string[] = []; const errors: string[] = []; for (const seed of PORTAL_TEMPLATE_SEEDS) { try { const got = await kbdbFetch(env, `/templates/${encodeURIComponent(seed.name)}`); if (got.ok) { // 已存在 → 檢查 slots 是否落後 seed(如 P3 新增 portal_library.graph_source)。 // updateRecord 對「不在 template slots_json 的 slot」會 reject——不補 slot, // 舊實例就永遠寫不進新標記。PATCH 補聯集(冪等,既有 record 不動)。 const body = (await got.json().catch(() => null)) as { template?: { id: string; slots_json?: string }; } | null; const tpl = body?.template; if (tpl?.id && tpl.slots_json) { let currentSlots: string[] = []; try { const parsed = JSON.parse(tpl.slots_json); if (Array.isArray(parsed)) currentSlots = parsed.filter((s): s is string => typeof s === 'string'); } catch { /* slots_json 壞掉 → 視同空,補成 seed 全集 */ } const missing = seed.slots.filter((s) => !currentSlots.includes(s)); if (missing.length > 0) { const patched = await kbdbFetch(env, `/templates/${encodeURIComponent(tpl.id)}`, { method: 'PATCH', body: JSON.stringify({ slots: [...currentSlots, ...missing] }), }); if (!patched.ok) throw new KbdbError(`PATCH /templates/${seed.name} 補 slots → ${patched.status}`); } } existing.push(seed.name); continue; } if (got.status !== 404) throw new KbdbError(`GET /templates/${seed.name} → ${got.status}`); const res = await kbdbFetch(env, '/templates', { method: 'POST', body: JSON.stringify({ name: seed.name, slots: seed.slots, description: seed.description, created_by: seed.created_by, }), }); if (!res.ok) throw new KbdbError(`POST /templates ${seed.name} → ${res.status}`); created.push(seed.name); } catch (e) { errors.push(`${seed.name}: ${e instanceof Error ? e.message : String(e)}`); } } return { created, existing, errors }; } /** email → user record_id(design §2.3 head entry O(1) 查找:page_name=email 走 index)。 */ async function findUserRecordId(env: Bindings, email: string): Promise { const ns = portalNamespace(env); const params = new URLSearchParams({ page_name: email, entry_type: USER_TEMPLATE, owner_id: ns, limit: '1', }); const res = await kbdbFetch(env, `/entries?${params.toString()}`); if (!res.ok) throw new KbdbError(`head entry 查找 → ${res.status}`); const body = (await res.json()) as { entries?: { content: string | null }[] }; const content = body.entries?.[0]?.content; return content ?? null; } async function getRecordById(env: Bindings, recordId: string): Promise { const res = await kbdbFetch(env, `/records/${encodeURIComponent(recordId)}`); if (res.status === 404) return null; if (!res.ok) throw new KbdbError(`GET /records/${recordId} → ${res.status}`); const body = (await res.json()) as { record?: PortalRecord }; return body.record ?? null; } async function patchRecordValues(env: Bindings, recordId: string, values: Record): Promise { const res = await kbdbFetch(env, `/records/${encodeURIComponent(recordId)}`, { method: 'PATCH', body: JSON.stringify({ values }), }); if (!res.ok) throw new KbdbError(`PATCH /records/${recordId} → ${res.status}`); const body = (await res.json()) as { record?: PortalRecord }; if (!body.record) throw new KbdbError(`PATCH /records/${recordId} 回應缺 record`); return body.record; } async function deleteKbdbRecord(env: Bindings, recordId: string): Promise { const res = await kbdbFetch(env, `/records/${encodeURIComponent(recordId)}`, { method: 'DELETE' }); if (res.status === 404) return false; if (!res.ok) throw new KbdbError(`DELETE /records/${recordId} → ${res.status}`); return true; } /** KV key for daemon's most-recently-reported active library names(t135 daemon hint)。 */ function daemonActiveKey(env: Bindings): string { return `${portalTenant(env)}:portal:daemon_active_libs`; } export async function listRecordsByTemplate(env: Bindings, template: string): Promise { 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} → ${res.status}`); const body = (await res.json()) as { records?: PortalRecord[] }; return body.records ?? []; } interface CreateUserInput { email: string; display_name: string; role: 'user' | 'admin'; libraries: string[]; password_hash: string; } /** 建 portal_user record(子 namespace)+ email head entry(§2.3)。 */ async function createPortalUser(env: Bindings, input: CreateUserInput): Promise { const ns = portalNamespace(env); const now = new Date().toISOString(); const res = await kbdbFetch(env, '/records', { method: 'POST', body: JSON.stringify({ template: USER_TEMPLATE, owner_id: ns, values: { email: input.email, display_name: input.display_name, status: 'active', role: input.role, password_hash: input.password_hash, libraries: JSON.stringify(input.libraries), created_at: now, updated_at: now, }, }), }); if (!res.ok) throw new KbdbError(`POST /records(portal_user)→ ${res.status}`); const body = (await res.json()) as { record?: { record_id: string } }; const recordId = body.record?.record_id; if (!recordId) throw new KbdbError('POST /records 回應缺 record_id'); // head entry:page_name=email(indexed)→ content=record_id,O(1) 登入查找 const head = await kbdbFetch(env, '/entries', { method: 'POST', body: JSON.stringify({ entry_type: USER_TEMPLATE, page_name: input.email, content: recordId, owner_id: ns, }), }); if (!head.ok) throw new KbdbError(`head entry 建立失敗(record ${recordId} 已建,需人工收拾)→ ${head.status}`); return recordId; } // ── user 值域 helpers ────────────────────────────────────────────────────── export function parseLibraries(raw: string | undefined): string[] { if (!raw) return []; try { const arr = JSON.parse(raw); if (Array.isArray(arr) && arr.every((x) => typeof x === 'string')) return arr; } catch { /* fallthrough */ } return []; } function isValidEmail(email: string): boolean { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) && email.length <= 254; } /** 庫名進 metadata/query(逗號分隔參數),故禁逗號/空白/怪字元。 */ function isValidLibraryName(name: string): boolean { return /^(\*|[A-Za-z0-9_-]{1,64})$/.test(name); } function validLibrariesInput(libs: unknown): libs is string[] { return Array.isArray(libs) && libs.length > 0 && libs.every((x) => typeof x === 'string' && isValidLibraryName(x)); } /** admin 面向的公開 user 形狀:**絕不含 password_hash**。 */ function toPublicUser(rec: PortalRecord) { const v = rec.values; return { record_id: rec.record_id, email: v.email ?? '', display_name: v.display_name ?? '', status: v.status ?? '', role: v.role ?? '', libraries: parseLibraries(v.libraries), created_at: v.created_at ?? '', updated_at: v.updated_at ?? '', }; } // ── session 閘 ──────────────────────────────────────────────────────────── export type AuthedUser = { token: string; recordId: string; values: Record }; export type AuthResult = { ok: true; user: AuthedUser } | { ok: false; res: Response }; /** * portal session 閘:token → KV → record_id → **回讀 record**(唯一真相源)→ status=active。 * 停用即時生效(design §4.3);停用/孤兒 session 順手刪 KV(best-effort,正確性不依賴它)。 */ export async function requirePortalUser(c: Context<{ Bindings: Bindings }>): Promise { const token = bearerToken(c); if (!token) return { ok: false, res: c.json({ error: '未登入' }, 401) }; const sess = await c.env.SESSIONS_KV.get(`${SESSION_PREFIX}${token}`); if (!sess) return { ok: false, res: c.json({ error: 'session 無效或已過期' }, 401) }; let recordId: string | undefined; try { recordId = (JSON.parse(sess) as { record_id?: string }).record_id; } catch { /* fallthrough */ } if (!recordId) { await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`); return { ok: false, res: c.json({ error: 'session 無效或已過期' }, 401) }; } const rec = await getRecordById(c.env, recordId); if (!rec) { await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`); return { ok: false, res: c.json({ error: 'session 無效或已過期' }, 401) }; } if ((rec.values.status ?? '') !== 'active') { await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`); return { ok: false, res: c.json({ error: '帳號已停用' }, 403) }; } return { ok: true, user: { token, recordId, values: rec.values } }; } async function requirePortalAdmin(c: Context<{ Bindings: Bindings }>): Promise { const auth = await requirePortalUser(c); if (!auth.ok) return auth; if ((auth.user.values.role ?? '') !== 'admin') { return { ok: false, res: c.json({ error: '需要 admin 權限' }, 403) }; } return auth; } // ── D-4 graph 粗閘 / D-8 工作流頁能力(P3;server 是唯一裁決點,前端只照 session 渲染)──── /** * 知識圖譜的「來源庫」集合(design D-4):portal_library 中標 graph_source='true' * 且未停用的庫。**沒有任何庫標記時預設 ['general']**(D-4 定案)。 */ export async function graphSourceLibraries(env: Bindings): Promise { const libs = await listRecordsByTemplate(env, LIBRARY_TEMPLATE); const marked = libs .filter((l) => (l.values.graph_source ?? '') === 'true' && (l.values.status ?? 'active') !== 'disabled') .map((l) => l.values.name ?? '') .filter(Boolean); return marked.length > 0 ? marked : ['general']; } /** graph 粗閘判定:擁有任一 graph 來源庫的權限(或 ["*"] 全庫)才放行。 */ export async function hasGraphAccess(env: Bindings, userLibraries: string[]): Promise { if (userLibraries.includes('*')) return true; // 全庫 → 必含來源庫,省一次 KBDB 呼叫 if (userLibraries.length === 0) return false; const sources = await graphSourceLibraries(env); return sources.some((s) => userLibraries.includes(s)); } /** * 工作流頁可見性(design D-8 定案:admin):PORTAL_SHOW_WORKFLOWS = admin(預設)/ all / off。 * 壞值誠實退回預設 admin(不因 typo 意外全開)。 */ export function workflowsVisible(env: Bindings, role: string): boolean { const setting = (env.PORTAL_SHOW_WORKFLOWS ?? 'admin').toLowerCase(); if (setting === 'off') return false; if (setting === 'all') return true; return role === 'admin'; } /** * 上傳能力(portal-demo-suite):PORTAL_UPLOAD_REPO / PORTAL_UPLOAD_GITEA / PORTAL_UPLOAD_TOKEN * 三個 bindings **齊全**才啟用。Mira 零影響:未設=功能不存在(/portal/data/upload 404、 * 前端 nav 隱藏)。這裡只是顯示提示——真閘在 /portal/data/upload 路由層,前端藏不藏都繞不過。 */ export function uploadEnabled(env: Bindings): boolean { return Boolean(env.PORTAL_UPLOAD_REPO && env.PORTAL_UPLOAD_GITEA && env.PORTAL_UPLOAD_TOKEN); } /** * admin 操作目標 record 的成員資格驗證:record 的 email head entry(子 namespace 內) * 必須指回同一 record_id——同時證明「是 portal_user」且「在本實例的 {tenant}::portal 下」, * 防 admin 拿任意 record_id 改到不相干的 KBDB record。 */ async function assertPortalUserRecord(env: Bindings, recordId: string): Promise { const rec = await getRecordById(env, recordId); if (!rec) return null; const email = rec.values.email; if (!email) return null; const headRecordId = await findUserRecordId(env, email); if (headRecordId !== recordId) return null; return rec; } // ── 登入節流(design §4.3:KV 計數,TTL 自然過期)────────────────────────────── async function isLocked(env: Bindings, email: string): Promise { const raw = await env.SESSIONS_KV.get(`${LOCKFAIL_PREFIX}${email}`); if (!raw) return false; try { return ((JSON.parse(raw) as { count?: number }).count ?? 0) >= LOCK_LIMIT; } catch { return false; } } async function recordLoginFail(env: Bindings, email: string): Promise { const key = `${LOCKFAIL_PREFIX}${email}`; const raw = await env.SESSIONS_KV.get(key); let count = 0; if (raw) { try { count = (JSON.parse(raw) as { count?: number }).count ?? 0; } catch { count = 0; } } await env.SESSIONS_KV.put(key, JSON.stringify({ count: count + 1 }), { expirationTtl: LOCK_TTL_SECONDS }); } async function clearLoginFail(env: Bindings, email: string): Promise { await env.SESSIONS_KV.delete(`${LOCKFAIL_PREFIX}${email}`); } // ═══════════════════════════════ 認證端點 ═══════════════════════════════════ // POST /portal/login — body {email, password}。成功發 portal session token。 // 錯誤訊息刻意不分「帳號不存在 vs 密碼錯」(不洩帳號存在性);停用帳號誠實回 403。 portalRouter.post('/portal/login', (c) => run(c, async () => { const body = await c.req.json().catch(() => null); const email = String(body?.email ?? '').trim().toLowerCase(); const password = String(body?.password ?? ''); if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400); if (await isLocked(c.env, email)) { return c.json({ error: '登入失敗次數過多,已暫時鎖定,請 15 分鐘後再試' }, 429); } const recordId = await findUserRecordId(c.env, email); if (!recordId) { await recordLoginFail(c.env, email); return c.json({ error: 'email 或密碼錯誤' }, 401); } const rec = await getRecordById(c.env, recordId); if (!rec) { await recordLoginFail(c.env, email); return c.json({ error: 'email 或密碼錯誤' }, 401); } if ((rec.values.status ?? '') !== 'active') { return c.json({ error: '帳號已停用' }, 403); } const ok = await verifyPassword(password, rec.values.password_hash ?? ''); if (!ok) { await recordLoginFail(c.env, email); return c.json({ error: 'email 或密碼錯誤' }, 401); } await clearLoginFail(c.env, email); const token = randomHex(32); // session 值只存 record_id(design §4.3)——權限/狀態每請求回讀 record,不快取進 session await c.env.SESSIONS_KV.put(`${SESSION_PREFIX}${token}`, JSON.stringify({ record_id: recordId }), { expirationTtl: sessionTtl(c.env), }); return c.json({ success: true, session_token: token, display_name: rec.values.display_name ?? '', role: rec.values.role ?? 'user', libraries: parseLibraries(rec.values.libraries), // 絕不回租戶字串(design §3.3:portal_user 拿到租戶字串就能繞過庫 filter 直打 /kbdb/*) }); }), ); // POST /portal/logout portalRouter.post('/portal/logout', async (c) => { const token = bearerToken(c); if (token) await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`); return c.json({ success: true }); }); // GET /portal/session — 每請求回讀 user record(真相源);回 display_name/role/libraries, // **絕不回租戶字串**(design §5)。 // P3 補能力欄位(前端據此渲染,design §6/D-4/D-8):graph_allowed(graph 模式要不要顯示)、 // workflows_visible(工作流頁要不要顯示)。**這兩個只是顯示提示——真正的擋在 // /portal/data/* 路由層**(無權 403/404),前端藏不藏都繞不過。 portalRouter.get('/portal/session', (c) => run(c, async () => { const auth = await requirePortalUser(c); if (!auth.ok) return auth.res; const v = auth.user.values; const role = v.role ?? 'user'; const libraries = parseLibraries(v.libraries); return c.json({ valid: true, display_name: v.display_name ?? '', email: v.email ?? '', // t53:完成安裝清單在站內生 daemon config.json 要用(身分顯示欄) role, libraries, graph_allowed: await hasGraphAccess(c.env, libraries), workflows_visible: workflowsVisible(c.env, role), // portal-demo-suite:上傳頁能力(bindings 齊全才 true;同上,只是顯示提示,真閘在路由層) upload_enabled: uploadEnabled(c.env), }); }), ); // POST /portal/me/password — body {current, new}。驗舊密改新密。 portalRouter.post('/portal/me/password', (c) => run(c, async () => { const auth = await requirePortalUser(c); if (!auth.ok) return auth.res; const body = await c.req.json().catch(() => null); const current = String(body?.current ?? ''); const next = String(body?.new ?? ''); if (!current || !next) return c.json({ error: 'current 與 new 必填' }, 400); if (next.length < 8) return c.json({ error: '新密碼至少 8 碼' }, 400); const ok = await verifyPassword(current, auth.user.values.password_hash ?? ''); if (!ok) return c.json({ error: '舊密碼不正確' }, 401); const newHash = await hashPassword(next); await patchRecordValues(c.env, auth.user.recordId, { password_hash: newHash, updated_at: new Date().toISOString(), }); return c.json({ success: true }); }), ); // ═══════════════════════════════ admin 端點 ══════════════════════════════════ // POST /portal/admin/bootstrap — 需 **console owner session**(design D-7:owner secret 是 // 安裝期人閘,不引入新 secret、不開放無閘註冊)。建第一個 role=admin 的 portal_user; // 已有 admin → 409 拒絕重複 bootstrap。順手冪等確保 templates(seed 是 API 行為)。 portalRouter.post('/portal/admin/bootstrap', (c) => run(c, async () => { const consoleOk = await validateConsoleSession(c.env, c.req.header('authorization')); if (!consoleOk) return c.json({ error: '需要 console owner session(先登入 /console)' }, 401); const seeded = await ensurePortalTemplates(c.env); if (seeded.errors.length > 0) { return c.json({ error: `portal templates seed 失敗:${seeded.errors.join('; ')}` }, 502); } const users = await listRecordsByTemplate(c.env, USER_TEMPLATE); if (users.some((u) => (u.values.role ?? '') === 'admin')) { return c.json({ error: '已有 admin,bootstrap 只能執行一次;後續帳號請用 /portal/admin/users' }, 409); } const body = await c.req.json().catch(() => null); const email = String(body?.email ?? '').trim().toLowerCase(); const password = String(body?.password ?? ''); const displayName = String(body?.display_name ?? '').trim() || email; if (!isValidEmail(email)) return c.json({ error: 'email 格式不正確' }, 400); if (password.length < 8) return c.json({ error: '密碼至少 8 碼' }, 400); if (await findUserRecordId(c.env, email)) return c.json({ error: '此 email 已存在' }, 409); 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), }); return c.json({ success: true, record_id: recordId, email, role: 'admin' }); }), ); // GET /portal/admin/users — 同仁列表(role=admin 閘)。**回應剝除 password_hash**。 portalRouter.get('/portal/admin/users', (c) => run(c, async () => { const auth = await requirePortalAdmin(c); if (!auth.ok) return auth.res; const users = await listRecordsByTemplate(c.env, USER_TEMPLATE); return c.json({ success: true, users: users.map(toPublicUser), count: users.length }); }), ); // POST /portal/admin/users — 新增同仁。body {email, display_name?, role?, libraries?, password?}。 // 未帶 password → server 產一次性密碼隨回應回傳一次(不落地明碼,design §4.3 簡化版)。 portalRouter.post('/portal/admin/users', (c) => run(c, async () => { const auth = await requirePortalAdmin(c); if (!auth.ok) return auth.res; const body = await c.req.json().catch(() => null); const email = String(body?.email ?? '').trim().toLowerCase(); const displayName = String(body?.display_name ?? '').trim() || email; const role = body?.role === 'admin' ? 'admin' : 'user'; const libraries: string[] = validLibrariesInput(body?.libraries) ? (body.libraries as string[]) : ['general']; if (!isValidEmail(email)) return c.json({ error: 'email 格式不正確' }, 400); if (body?.libraries !== undefined && !validLibrariesInput(body?.libraries)) { return c.json({ error: 'libraries 須為非空字串陣列(庫名限 A-Za-z0-9_- 或 "*")' }, 400); } if (await findUserRecordId(c.env, email)) return c.json({ error: '此 email 已存在' }, 409); let password = body?.password !== undefined ? String(body.password) : ''; let generated: string | undefined; if (password) { if (password.length < 8) return c.json({ error: '密碼至少 8 碼' }, 400); } else { generated = generatePassword(); password = generated; } const recordId = await createPortalUser(c.env, { email, display_name: displayName, role, libraries, password_hash: await hashPassword(password), }); const rec = await getRecordById(c.env, recordId); return c.json({ success: true, user: rec ? toPublicUser(rec) : { record_id: recordId, email }, // 一次性回傳(不儲存明碼);admin 口頭轉交同仁後即失效於 server 側 ...(generated ? { generated_password: generated } : {}), }); }), ); // PATCH /portal/admin/users/:id — 改 status/role/libraries(design §5)。 // 停用即時生效機制=每請求回讀 record(§4.3),不依賴刪 session。 portalRouter.patch('/portal/admin/users/:id', (c) => run(c, async () => { const auth = await requirePortalAdmin(c); if (!auth.ok) return auth.res; const recordId = c.req.param('id'); const rec = await assertPortalUserRecord(c.env, recordId); if (!rec) return c.json({ error: '用戶不存在' }, 404); const body = await c.req.json().catch(() => null); if (!body) return c.json({ error: 'body 必須是 JSON' }, 400); const patch: Record = {}; if (body.status !== undefined) { if (body.status !== 'active' && body.status !== 'disabled') { return c.json({ error: 'status 只能是 active / disabled' }, 400); } patch.status = body.status; } if (body.role !== undefined) { if (body.role !== 'user' && body.role !== 'admin') return c.json({ error: 'role 只能是 user / admin' }, 400); patch.role = body.role; } if (body.libraries !== undefined) { if (!validLibrariesInput(body.libraries)) { return c.json({ error: 'libraries 須為非空字串陣列(庫名限 A-Za-z0-9_- 或 "*")' }, 400); } patch.libraries = JSON.stringify(body.libraries); } if (Object.keys(patch).length === 0) return c.json({ error: '沒有可更新的欄位(status/role/libraries)' }, 400); // 鎖死保護(P4,總管派工明定):**不可停用/降級最後一個 active admin**—— // 否則系統再無人能管帳號(bootstrap 只能跑一次,409),變成鎖死狀態。 // 只在「目標現在是 active admin 且 patch 會使它不再是」時才多打一次 list(平時零成本)。 const isActiveAdmin = (rec.values.role ?? '') === 'admin' && (rec.values.status ?? '') === 'active'; const wouldLoseAdmin = patch.status === 'disabled' || patch.role === 'user'; if (isActiveAdmin && wouldLoseAdmin) { const all = await listRecordsByTemplate(c.env, USER_TEMPLATE); const otherActiveAdmins = all.filter( (u) => u.record_id !== recordId && (u.values.role ?? '') === 'admin' && (u.values.status ?? '') === 'active', ); if (otherActiveAdmins.length === 0) { return c.json({ error: '不可停用或降級最後一個管理員——系統至少要保留一個 active admin' }, 409); } } patch.updated_at = new Date().toISOString(); const updated = await patchRecordValues(c.env, recordId, patch); return c.json({ success: true, user: toPublicUser(updated) }); }), ); // POST /portal/admin/users/:id/reset-password — 產一次性新密碼回傳(design §4.3 簡化版: // admin 口頭轉交;must_change 首登改密列第二波)。 portalRouter.post('/portal/admin/users/:id/reset-password', (c) => run(c, async () => { const auth = await requirePortalAdmin(c); if (!auth.ok) return auth.res; const recordId = c.req.param('id'); const rec = await assertPortalUserRecord(c.env, recordId); if (!rec) return c.json({ error: '用戶不存在' }, 404); const password = generatePassword(); await patchRecordValues(c.env, recordId, { password_hash: await hashPassword(password), updated_at: new Date().toISOString(), }); return c.json({ success: true, password }); // 一次性回傳,server 不留明碼 }), ); // ── 庫目錄(design §3.2 portal_library:登記簿;庫本體=條目上的 metadata 標記)──── function toPublicLibrary(rec: PortalRecord) { const v = rec.values; return { record_id: rec.record_id, name: v.name ?? '', display_name: v.display_name ?? '', description: v.description ?? '', status: v.status ?? '', // D-4:此庫是否為知識圖譜萃取來源(graph 粗閘按這個判定;全都沒標 → 預設 general) graph_source: (v.graph_source ?? '') === 'true', }; } // POST /portal/daemon/libraries — body {email, password, libraries:[{name, display_name?}]}。 // t52(leo 2026-07-26:「用戶可以看到我有 2 個庫,地端雲端都是 2 個,如果只有一個一定被罵」): // 小幫手回報它看守的資料夾各自對應的庫,雲端**自動登記**——庫目錄與地端資料夾一比一。 // 認證=同 /portal/daemon/config(用戶帳密)。已存在的庫略過(冪等),不覆寫顯示名。 portalRouter.post('/portal/daemon/libraries', (c) => run(c, async () => { const body = (await c.req.json().catch(() => null)) as | { email?: string; password?: string; libraries?: { name?: string; display_name?: string }[] } | null; const email = String(body?.email ?? '').trim().toLowerCase(); const password = String(body?.password ?? ''); if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400); if (await isLocked(c.env, email)) return c.json({ error: '登入失敗次數過多,請稍後再試' }, 429); const recordId = await findUserRecordId(c.env, email); const rec = recordId ? await getRecordById(c.env, recordId) : null; if (!rec || (rec.values.status ?? '') !== 'active' || !(await verifyPassword(password, rec.values.password_hash ?? ''))) { await recordLoginFail(c.env, email); return c.json({ error: 'email 或密碼錯誤' }, 401); } await clearLoginFail(c.env, email); const wanted = Array.isArray(body?.libraries) ? body!.libraries! : []; const seeded = await ensurePortalTemplates(c.env); if (seeded.errors.length > 0) { return c.json({ error: `portal templates seed 失敗:${seeded.errors.join('; ')}` }, 502); } const existing = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE); const have = new Set(existing.map((l) => String(l.values.name ?? ''))); const ns = portalNamespace(c.env); const created: string[] = []; for (const item of wanted) { const name = String(item?.name ?? '').trim(); if (!isValidLibraryName(name) || name === '*' || have.has(name)) continue; const res = await kbdbFetch(c.env, '/records', { method: 'POST', body: JSON.stringify({ template: LIBRARY_TEMPLATE, owner_id: ns, values: { name, display_name: String(item?.display_name ?? '').trim() || name, description: '同步小幫手看守的資料夾', status: 'active', }, }), }); if (!res.ok) throw new KbdbError(`POST /records(portal_library)→ ${res.status}`); have.add(name); created.push(name); } const after = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE); // t135:記下本次 daemon 回報的所有庫名(48h TTL)供 GET /portal/admin/libraries 顯示「未同步」提示。 const activeNames = wanted.map((item) => String(item?.name ?? '').trim()).filter(Boolean); if (activeNames.length > 0) { await c.env.WEBHOOKS.put(daemonActiveKey(c.env), JSON.stringify(activeNames), { expirationTtl: 172800 }); } return c.json({ success: true, created, libraries: after.map(toPublicLibrary) }); }), ); // t176:t122 的 extractor_config(雲端指定地端萃取引擎)整組移除—— // interface/KV key/讀取函式都不再需要,因為雲端已不下發、也不再有設定入口。 // ⚠️ 舊實例的 KV 殘值無害:daemon 端 t176 起也不吃這個欄位了。 // POST /portal/daemon/config — body {email, password}。同步小幫手憑「用戶剛設的帳密」 // 直接換到自己的設定(t54,leo 07-25:「最好的就是把它的帳密直接輸入」)—— // 用戶不必再下載 config.json 丟隱藏資料夾,托盤第一次開啟輸入網址+帳密就上工。 // 認證=與 /portal/login 同一把(同樣吃節流與停用檢查);回傳只含連線設定,不含任何知識內容。 // t122:extractor 改讀雲端設定(未設→預設 gemma;gemma+金鑰→一併下發金鑰)。 portalRouter.post('/portal/daemon/config', (c) => run(c, async () => { const body = (await c.req.json().catch(() => null)) as { email?: string; password?: string } | null; const email = String(body?.email ?? '').trim().toLowerCase(); const password = String(body?.password ?? ''); if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400); if (await isLocked(c.env, email)) { return c.json({ error: '登入失敗次數過多,已暫時鎖定,請 15 分鐘後再試' }, 429); } const recordId = await findUserRecordId(c.env, email); const rec = recordId ? await getRecordById(c.env, recordId) : null; if (!rec) { await recordLoginFail(c.env, email); return c.json({ error: 'email 或密碼錯誤' }, 401); } if ((rec.values.status ?? '') !== 'active') return c.json({ error: '帳號已停用' }, 403); if (!(await verifyPassword(password, rec.values.password_hash ?? ''))) { await recordLoginFail(c.env, email); return c.json({ error: 'email 或密碼錯誤' }, 401); } await clearLoginFail(c.env, email); const tenant = portalTenant(c.env); // t176(leo 08-03 架構翻案):**不再下發任何 LLM 設定**(extractor/金鑰/模型)。 // 地端用哪個模型、哪把金鑰,由使用者在同步小幫手的托盤「AI 設定…」自己設。 // // 為什麼拔掉:extractor_config 這把 KV 的 key 是 `${portalTenant(env)}:portal:extractor_config`, // 而 portalTenant 是 **worker 層級**環境變數(見本檔 43 行)=**全租戶共用一把**。 // 任一處設了 claude,所有人的 daemon 都會收到 claude;沒裝 Claude Code 的機器 // 會萃取全滅,而 portal 的 Claude 勾選框又恆為 disabled(daemon 從未回報 has_claude) // ⇒ 用戶自己解不開(08-03 封測實證:雲端同步成功、金鑰有效,卻零張卡)。 // leo:「地端要用什麼模型就在 daemon 上輸入 API Key 設置,而不是雲端設置後控制地端」。 // // ⚠️ 只拔 LLM 欄位——連線欄位(cypher_url/namespace/library)與本 route 本身照舊, // daemon 靠它上線;資料夾/庫管理(daemon/libraries)也完全不動(leo 明確劃界)。 const daemonCfg: Record = { cypher_url: new URL(c.req.url).origin, namespace: tenant, library: 'kb', email, instance_name: String(rec.values.display_name ?? ''), }; return c.json({ success: true, config: daemonCfg }); }), ); // t176:t131 這一版 `/portal/admin/ai`(POST/GET)**整組移除**。兩個原因: // ① 它是**重複註冊**——本檔後段(arcrun-rag#10 那版)另有一組同路徑 route。 // Hono 先到先比 ⇒ 舊的這組一直贏,後段那組修好的「金鑰真的寫進 credentials」形同死碼。 // 這正是「key 從來沒存進去」的 bug 在 merge 後仍可能復發的原因。 // ② 它把 use_claude_for_extract 同步進 extractor_config 下發給 daemon // (syncExtractorFromAiConfig),而那把 KV 是**全租戶共用**——正是 08-03 事故根因。 // 保留的是後段那組(只管 Gemini 金鑰,走 storeCredential 唯一寫入路徑,不碰 extractor)。 // t176:`POST /portal/daemon/report-capabilities` 已移除。 // 它的用途是收 daemon 回報的 has_claude 去解鎖 portal 的 Claude 勾選框; // 但 daemon 端從未實作這個呼叫(arcrun-rag 全庫 grep = 0 命中), // 導致 daemon_caps KV 永遠空、勾選框恆 disabled。t176 起地端模型由小幫手自己設, // 這條回報鏈整條不需要了。 // POST /portal/admin/chat-key — body {key}。保留舊端點相容(新 UI 走 /portal/admin/ai)。 // 舊版 setup checklist / 舊 UI 仍走這裡;只更新 rag_chat workflow,不同步 ai_config。 portalRouter.post('/portal/admin/chat-key', (c) => run(c, async () => { const auth = await requirePortalAdmin(c); if (!auth.ok) return auth.res; const body = (await c.req.json().catch(() => null)) as { key?: string } | null; const key = String(body?.key ?? '').trim(); if (!key) return c.json({ error: '請貼上你的 Google AI 金鑰' }, 400); const tenant = portalTenant(c.env); const kvKey = `${tenant}:wf:rag_chat`; const raw = await c.env.WEBHOOKS.get(kvKey, 'text'); if (!raw) return c.json({ error: '這個實例沒有安裝 AI 問答工作流' }, 404); let record: Record; try { record = JSON.parse(raw) as Record; } catch { return c.json({ error: 'AI 問答工作流記錄損壞,請重新安裝' }, 500); } // 結構不動、只換金鑰值:走遍 graph/config,凡 x-goog-api-key 欄一律設為新值 //(現值可能是 {{credential.gemini_api_key}} 佔位、空字串或舊 key,都直接覆蓋)。 let replaced = 0; const visit = (o: unknown): void => { if (Array.isArray(o)) { o.forEach(visit); return; } if (o && typeof o === 'object') { const rec = o as Record; for (const k of Object.keys(rec)) { if (k.toLowerCase() === 'x-goog-api-key') { rec[k] = key; replaced += 1; } else visit(rec[k]); } } }; visit(record['graph']); visit(record['config']); if (replaced === 0) return c.json({ error: '工作流裡找不到金鑰欄位,請重新安裝後再試' }, 500); await c.env.WEBHOOKS.put(kvKey, JSON.stringify(record)); return c.json({ success: true, replaced }); }), ); // t176:`POST|GET /portal/admin/extractor`(t122)已移除。 // 這是「雲端指定地端萃取引擎」的舊入口,且**沒有任何伺服器端驗證**—— // 只要打這條就能把 extractor_config 設成 claude,而那把 KV 全租戶共用 // ⇒ 所有沒裝 Claude Code 的機器萃取全滅(08-03 事故)。 // 地端模型現由同步小幫手托盤「AI 設定…」自己設,雲端不再有這個概念。 // GET /portal/admin/libraries — 庫目錄列表。 // t52(leo 2026-07-26:「地端 2 個資料夾、雲端就要 2 個庫,只有一個一定被罵」): // 除了登記簿裡的庫,**也把資料裡實際蓋過章的庫一併列出**(標 auto:true)—— // 蓋章即現身,用戶不必先去登記;登記簿只負責顯示名/圖譜來源這些額外設定。 // t135:讀 daemon 最近回報的 active libs(KV TTL 48h),已登記的庫若不在其中標 daemon_watching:false。 portalRouter.get('/portal/admin/libraries', (c) => run(c, async () => { const auth = await requirePortalAdmin(c); if (!auth.ok) return auth.res; const libs = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE); // 讀 daemon 最近回報的 active lib names(若 KV 不存在 = daemon 從未回報,不標 hint) let daemonActive: Set | null = null; try { const raw = await c.env.WEBHOOKS.get(daemonActiveKey(c.env), 'text'); if (raw) daemonActive = new Set((JSON.parse(raw) as string[]).map((n) => String(n).trim())); } catch { /* KV 不可達不擋主流程 */ } const out = libs.map((rec) => { const lib = toPublicLibrary(rec); const watching = daemonActive === null ? undefined : daemonActive.has(lib.name); return { ...lib, ...(watching !== undefined ? { daemon_watching: watching } : {}) }; }); const known = new Set(out.map((l) => l.name)); // t142:資料面實際出現的庫+統計數字(卡數、三元組數)並行撈取,避免 N+1。 // 任一端點失敗不擋登記簿列表(誠實降級:stats 保持 0,不炸主流程)。 try { const tenant = portalTenant(c.env); const ownerParam = `owner_id=${encodeURIComponent(tenant)}`; const [autoRes, cardRes, tripletRes] = await Promise.all([ kbdbFetch(c.env, `/entries/libraries?${ownerParam}`).catch(() => null), kbdbFetch(c.env, `/entries/library-stats?${ownerParam}`).catch(() => null), kbdbFetch(c.env, `/records/triplet-stats?${ownerParam}`).catch(() => null), ]); // 解析統計,建成 Map 供 O(1) 查找 const cardMap = new Map(); if (cardRes?.ok) { const body = (await cardRes.json()) as { stats?: { library: string; card_count: number }[] }; for (const s of body.stats ?? []) cardMap.set(s.library, s.card_count); } const tripletMap = new Map(); if (tripletRes?.ok) { const body = (await tripletRes.json()) as { stats?: { library: string; triplet_count: number }[] }; for (const s of body.stats ?? []) tripletMap.set(s.library, s.triplet_count); } // 已登記庫補入統計 for (const lib of out) { (lib as Record).card_count = cardMap.get(lib.name) ?? 0; (lib as Record).triplet_count = tripletMap.get(lib.name) ?? 0; } // 資料面自動出現的庫(蓋章即現身) if (autoRes?.ok) { const body = (await autoRes.json()) as { libraries?: string[] }; for (const name of body.libraries ?? []) { const n = String(name ?? '').trim(); // general 是系統內部「未標庫」桶(未標記 entry 的 fallback),不在用戶目錄露臉 if (!n || n === 'general' || known.has(n)) continue; known.add(n); const watching = daemonActive === null ? undefined : daemonActive.has(n); out.push({ record_id: '', name: n, display_name: n, description: '資料同步時自動出現(可在此補顯示名)', status: 'active', graph_source: false, auto: true, card_count: cardMap.get(n) ?? 0, triplet_count: tripletMap.get(n) ?? 0, ...(watching !== undefined ? { daemon_watching: watching } : {}), }); } } } catch { // 資料面查不到不擋登記簿(誠實降級:至少顯示已登記的庫) } return c.json({ success: true, libraries: out, count: out.length }); }), ); // t160(leo 07-31:「要直通 daemon,同步,**沒有登記這回事**」): // 人工建庫端點 POST /portal/admin/libraries 已刪——庫只從 daemon 同步自動出現 // (/portal/daemon/libraries,t159)。現行 UI(e28e190 起)本就零呼叫此端點(死端點); // 人工登記只會製造對不上的空庫(07-27 leo 拿掉表單時已定調)。 // GET 列表與 PATCH(管理已存在的庫:改名/停用/graph_source)照舊。 // POST /portal/daemon/libraries — 小幫手(daemon)連線精靈時把看守資料夾的庫報上來自動登記。 // t159(2026-07-31 leo prod 實走揪出):daemon registerLibraries(arcrun-tray main.go:505,t52) // 一直在打這個端點,但 cypher 從來沒有它 ⇒ 404 被 daemon「失敗不擋連線」靜默吞掉 // ⇒ portal_library 登記簿永遠空 ⇒ portal「庫目錄管理」空(資料同步倒是全正常—— // triplet records 的 library slot 都在,病只在登記簿沒人寫)。 // 契約照 daemon 既有呼叫:body {email, password, libraries:[{name, display_name}]}。 // 帳密驗證=與 /portal/session 同一套(daemon 只在精靈那一刻拿到帳密,不存)。 // 冪等:已登記(同 name)跳過——重跑精靈不堆重複。 portalRouter.post('/portal/daemon/libraries', (c) => run(c, async () => { const body = await c.req.json().catch(() => null); const email = String(body?.email ?? '').trim().toLowerCase(); const password = String(body?.password ?? ''); if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400); const items = Array.isArray(body?.libraries) ? body.libraries : []; if (items.length === 0) return c.json({ success: true, registered: [], skipped: [] }); // 帳密驗證(沿用 /portal/session 的鎖定與驗證機制) if (await isLocked(c.env, email)) return c.json({ error: '登入失敗次數過多,請稍後再試' }, 429); const recordId = await findUserRecordId(c.env, email); const rec = recordId ? await getRecordById(c.env, recordId) : null; if (!rec || (rec.values.status ?? '') !== 'active' || !(await verifyPassword(password, rec.values.password_hash ?? ''))) { await recordLoginFail(c.env, email); return c.json({ error: 'email 或密碼錯誤' }, 401); } await clearLoginFail(c.env, email); const seeded = await ensurePortalTemplates(c.env); if (seeded.errors.length > 0) { return c.json({ error: `portal templates seed 失敗:${seeded.errors.join('; ')}` }, 502); } const existing = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE); const have = new Set(existing.map((l) => l.values.name ?? '')); const ns = portalNamespace(c.env); const registered: string[] = []; const skipped: string[] = []; for (const it of items) { const name = String(it?.name ?? '').trim(); const displayName = String(it?.display_name ?? '').trim() || name; if (!isValidLibraryName(name) || name === '*') { skipped.push(name || '(空)'); continue; } if (have.has(name)) { skipped.push(name); continue; } const res = await kbdbFetch(c.env, '/records', { method: 'POST', body: JSON.stringify({ template: LIBRARY_TEMPLATE, owner_id: ns, values: { name, display_name: displayName, description: '', status: 'active' }, }), }); if (!res.ok) throw new KbdbError(`POST /records(portal_library,daemon 登記)→ ${res.status}`); have.add(name); registered.push(name); } return c.json({ success: true, registered, skipped }); }), ); // PATCH /portal/admin/libraries/:id — 改 display_name/description/status(停用庫=翻 status slot)。 portalRouter.patch('/portal/admin/libraries/:id', (c) => run(c, async () => { const auth = await requirePortalAdmin(c); if (!auth.ok) return auth.res; const recordId = c.req.param('id'); // 成員資格:record 必須在本實例的庫目錄列表內(庫數小,list 比對即可) const libs = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE); if (!libs.some((l) => l.record_id === recordId)) return c.json({ error: '庫不存在' }, 404); const body = await c.req.json().catch(() => null); if (!body) return c.json({ error: 'body 必須是 JSON' }, 400); const patch: Record = {}; if (body.display_name !== undefined) patch.display_name = String(body.display_name).trim(); if (body.description !== undefined) patch.description = String(body.description).trim(); if (body.status !== undefined) { if (body.status !== 'active' && body.status !== 'disabled') { return c.json({ error: 'status 只能是 active / disabled' }, 400); } patch.status = body.status; } // D-4(P3):標記/取消「知識圖譜來源庫」。boolean 進、slot 存 'true'/'false' 字串。 if (body.graph_source !== undefined) { if (typeof body.graph_source !== 'boolean') { return c.json({ error: 'graph_source 只能是 true / false' }, 400); } patch.graph_source = body.graph_source ? 'true' : 'false'; } if (Object.keys(patch).length === 0) { return c.json({ error: '沒有可更新的欄位(display_name/description/status/graph_source)' }, 400); } const updated = await patchRecordValues(c.env, recordId, patch); return c.json({ success: true, library: toPublicLibrary(updated) }); }), ); // ── AI 設定(arcrun-rag#10)──────────────────────────────────────────────────── // // 🔴 為什麼這段存在(2026-08-01 真因,別再讓它消失): // 前端設定頁**一直**在打 `GET|POST /portal/admin/ai`,但**後端從來沒有這條 route** // ⇒ 用戶填 Gemini key → 404 → **key 從來沒被存進任何地方**,畫面卻像存好了(藍字=假綠)。 // leo 實撞成「重裝後 key 不見」,但真相是「從來沒存進去,所以重填也沒用」。 // 產物層鐵證:bundle tier2/ui grep 'portal/admin/ai'=1、tier2/cypher=0。 // // 設計約束: // - **不另造第二套儲存**:POST 內部轉呼 credentials.ts 既有的 `storeCredential()` // (唯一寫入路徑=Workers Secret 明文 + D1 目錄列 ref)。 // - **永不回傳 key 本身**(D36):GET 只回 `has_key` 布林。 // - credential 的 `api_key` 欄=租戶 slug(`portalTenant`),與安裝器 seedCredential // 寫 `kbdb_internal_token` 用的 ns 同一個值 ⇒ 兩者落在同一租戶分區,查得到彼此。 // - t176:Claude 偏好(use_claude_for_extract/claude_available)已整組移除—— // 地端用哪個模型由同步小幫手自己設,雲端不再有這個概念。這裡只管 Gemini 金鑰。 // GET /portal/admin/ai — 回 AI 設定現況(role=admin 閘)。**只回 has_key 布林,永不回 key**。 portalRouter.get('/portal/admin/ai', (c) => run(c, async () => { const auth = await requirePortalAdmin(c); if (!auth.ok) return auth.res; const tenantSlug = portalTenant(c.env); let hasKey = false; try { const row = await c.env.CREDENTIALS_DB .prepare('SELECT 1 FROM credentials WHERE api_key = ? AND name = ? LIMIT 1') .bind(tenantSlug, 'gemini_api_key') .first(); hasKey = !!row; } catch { // D1 未就緒 ⇒ 當作沒設定(不擋頁面),但也不假裝有 hasKey = false; } // t176:不再有 claude_available/use_claude_for_extract——地端用哪個模型 // 由同步小幫手自己設,雲端不介入(leo 08-03)。 return c.json({ success: true, has_key: hasKey }); }), ); // DELETE /portal/admin/libraries/by-name/:name — 移除 auto 庫(只有資料章記、無登記簿 record)。 // 語意:把該庫的所有 entries 標 deprecated → 資料不刪、重新 ingest 可還原。 // ⚠️ 影響資料可搜性,要求 body.confirm 等於庫名才執行(二次確認)。 // ⚠️ 此路由必須在 DELETE /:id 之前宣告(Hono 先到先比;by-name 否則被當成 :id)。 portalRouter.delete('/portal/admin/libraries/by-name/:name', (c) => run(c, async () => { const auth = await requirePortalAdmin(c); if (!auth.ok) return auth.res; const name = decodeURIComponent(c.req.param('name')); const body = await c.req.json().catch(() => null); const confirm = String(body?.confirm ?? '').trim(); if (!confirm) return c.json({ error: 'body 須帶 { confirm: "<庫名>" } 才執行(移除會影響資料可搜性)' }, 400); if (confirm !== name) return c.json({ error: `confirm 值「${confirm}」與庫名「${name}」不符` }, 400); const ownerId = portalTenant(c.env); const res = await kbdbFetch(c.env, '/entries/deprecate-by-library', { method: 'PATCH', body: JSON.stringify({ owner_id: ownerId, library: name }), }); if (!res.ok) throw new KbdbError(`PATCH /entries/deprecate-by-library → ${res.status}`); const data = (await res.json()) as { deprecated_count?: number }; return c.json({ success: true, deprecated_count: data.deprecated_count ?? 0, message: `已從自動清單移除「${name}」(共標記 ${data.deprecated_count ?? 0} 筆資料不可搜)。資料保留可還原——重新同步時會再出現。`, }); }), ); // POST /portal/admin/ai — 存 Gemini key(role=admin 閘)。body: { gemini_api_key: string } // t176:Claude 偏好欄位已移除(地端模型由同步小幫手自己設,雲端不下發)。 portalRouter.post('/portal/admin/ai', (c) => run(c, async () => { const auth = await requirePortalAdmin(c); if (!auth.ok) return auth.res; const body = await c.req.json().catch(() => null) as { gemini_api_key?: unknown } | null; const rawKey = typeof body?.gemini_api_key === 'string' ? body.gemini_api_key.trim() : ''; if (!rawKey) { return c.json({ error: '沒有要變更的項目(金鑰留空)' }, 400); } const tenantSlug = portalTenant(c.env); try { // 唯一寫入路徑(credentials.ts):Workers Secret 存值 + D1 存 ref。 await storeCredential(c.env, tenantSlug, 'gemini_api_key', rawKey, 'gemini'); } catch (e) { // 誠實回報寫入失敗——這正是本 bug 的教訓:不能讓前端以為存好了。 return c.json( { error: `金鑰儲存失敗:${e instanceof Error ? e.message : String(e)}` }, 502, ); } return c.json({ success: true, has_key: true }); }), ); // DELETE /portal/admin/libraries/:id — 移除已登記庫(有 record_id 的登記簿 record)。 // 只刪登記簿那筆 record;知識資料(entries with library=name)完全不動。 // 資料若有的話,重新同步後會以 auto 庫重新出現。 portalRouter.delete('/portal/admin/libraries/:id', (c) => run(c, async () => { const auth = await requirePortalAdmin(c); if (!auth.ok) return auth.res; const recordId = c.req.param('id'); // 成員資格驗(防憑空 id 打到不相干 record) const libs = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE); const target = libs.find((l) => l.record_id === recordId); if (!target) return c.json({ error: '庫不存在' }, 404); const found = await deleteKbdbRecord(c.env, recordId); if (!found) return c.json({ error: '庫不存在' }, 404); return c.json({ success: true, name: target.values.name ?? '', message: `已從目錄移除「${target.values.display_name ?? target.values.name ?? ''}」。資料仍在,重新同步會再出現。`, }); }), );