Files
Arcrun/cypher-executor/src/routes/portal.ts
T
uncle6me-web 66f1b59f7f t176:雲端不再管地端 LLM 設定(leo 08-03 緊急)
leo 回報四件事:①雲端 Gemini 無法用 ②搞不清楚設定 AI 是設雲端還是地端
③地端不會萃、每個人都死掉因為都去抓 Claude ④雲端還要設置明明就有 Workers AI。
目標:「雲端裝好就能用,地端輸一個 Gemini API Key,然後就開始用。」

根因:extractor_config 的 KV key 由 portalTenant() 組出,而 portalTenant 是
**worker 層級**環境變數(portal.ts:43)⇒ **全租戶共用一把**。任一處設了 claude,
所有人的 daemon 都收到 claude;沒裝 Claude Code 的機器萃取全滅,而 portal 的
Claude 勾選框又恆 disabled(daemon 從未實作 report-capabilities ⇒ daemon_caps 永遠空)
⇒ 用戶自己解不開(awindhon 08-03 實證:雲端同步成功、金鑰有效、零張卡)。

本次(雲端側):
- POST /portal/daemon/config **不再下發** extractor/gemini_api_key/llm_model,
  只回連線欄位。⚠️ route 本身與 daemon/libraries 資料夾管理完全不動(leo 明確劃界:
  「雲端拉地端檔案夾部分不要刪,刪掉從雲端設置地端 LLM 選項部分」)。
- 移除 POST|GET /portal/admin/extractor(t122)——這是「雲端指定地端引擎」的入口,
  且無任何伺服器端驗證,打一下就能把全租戶設成 claude。
- 移除 POST /portal/daemon/report-capabilities(t131)——daemon 端從未實作該呼叫。
- 移除 t131 那組重複註冊的 /portal/admin/ai。**它是重複 route**:後段(arcrun-rag#10)
  另有同路徑一組,Hono 先到先比 ⇒ 舊的一直贏,後段修好的「金鑰真的寫進 credentials」
  形同死碼。保留後段那組(只管 Gemini 金鑰、走 storeCredential),並拿掉 Claude 偏好欄。
- portal 設定頁「AI 設定」整塊移除,改成一句話說明:雲端不需設定(Workers AI 免金鑰),
  地端請在同步小幫手的「AI 設定…」填 Gemini Key。順手清掉 main 上既有的 conflict 標記。
- 清掉隨之孤兒化的 helper(ExtractorConfig/AiConfig/DaemonCapabilities/
  syncExtractorFromAiConfig/readAiPref 等)。

測試:**相對 merge 前 main 基準線,新增失敗 = 0**(基準線本就 9 紅:console 藏書地圖 6/
portal HTML 殼 2/POST execute 1,皆與本次無關)。t122/t131 兩組測試改寫成
**回歸守衛**(斷言那些端點/欄位確實 404、確實不存在),不是刪掉充綠。
順手修 health bundle_version 測試與實作對齊(實作刻意省略該欄,測試卻期待空字串)。

未送達:本 commit 只到 code,尚未部署上線。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 22:11:45 +08:00

1186 lines
57 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* RAG Portal 多人授權 — P2:用戶模型+認證 APIportal-auth design §2/§4/§5Gitea #24/#25
*
* 架構(design D-1):Portalcypher-executor 的 /portal 路由(非獨立 worker)。
* 本檔只做 P2 的 auth/admin APIP3/portal HTML 殼+ /portal/data/* enforce)另一波。
*
* 鐵律對照:
* - rule 2.1/2.2:這是 UI session 登入(console-auth 同類先例),非 workflow credential
* 原語。KDF 在 lib/portal-auth.tsWebCrypto PBKDF2),本檔無解密/簽章/template 展開。
* - 零新表(design §2):portal_user / portal_library 都是 KBDB 萬用表 template
* 資料經 KBDB base HTTP API 寫入(kbdbBase 慣例,不直連 D1、不寫 SQL)。
* - 子 namespacedesign D-2):一切帳號資料 owner_id`{CONSOLE_TENANT}::portal`。
* 既有租戶查詢面(/kbdb/*、MCP)都以 CONSOLE_TENANT 過濾 → 物理上搜不到帳號資料
* email/password_hash 不會出現在知識搜尋結果)。
* - sessiondesign §4.3):KV `portal_sess:{token}` 只存 record_idTTL 暫存=合規),
* **每個請求回讀 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 SecretD1 只留 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.35 次失敗
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';
}
/** 帳號子 namespacedesign 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<Response> {
const { base, headers } = kbdbBase(env);
let res: Response;
try {
res = await fetch(`${base}${path}`, { ...init, headers: { ...headers, ...(init?.headers as Record<string, string> | 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<Response>): Promise<Response> {
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<string, string>;
}
/** 冪等確保 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_iddesign §2.3 head entry O(1) 查找:page_name=email 走 index)。 */
async function findUserRecordId(env: Bindings, email: string): Promise<string | null> {
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<PortalRecord | null> {
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<string, string>): Promise<PortalRecord> {
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<boolean> {
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 namest135 daemon hint)。 */
function daemonActiveKey(env: Bindings): string {
return `${portalTenant(env)}:portal:daemon_active_libs`;
}
export async function listRecordsByTemplate(env: Bindings, template: string): Promise<PortalRecord[]> {
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<string> {
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 /recordsportal_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 entrypage_name=emailindexed)→ content=record_idO(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<string, string> };
export type AuthResult = { ok: true; user: AuthedUser } | { ok: false; res: Response };
/**
* portal session 閘:token → KV → record_id → **回讀 record**(唯一真相源)→ status=active。
* 停用即時生效(design §4.3);停用/孤兒 session 順手刪 KVbest-effort,正確性不依賴它)。
*/
export async function requirePortalUser(c: Context<{ Bindings: Bindings }>): Promise<AuthResult> {
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<AuthResult> {
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<string[]> {
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<boolean> {
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<PortalRecord | null> {
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<boolean> {
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<void> {
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<void> {
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_iddesign §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.3portal_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_allowedgraph 模式要不要顯示)、
// 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-7owner secret 是
// 安裝期人閘,不引入新 secret、不開放無閘註冊)。建第一個 role=admin 的 portal_user
// 已有 admin → 409 拒絕重複 bootstrap。順手冪等確保 templatesseed 是 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: '已有 adminbootstrap 只能執行一次;後續帳號請用 /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/librariesdesign §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<string, string> = {};
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?}]}。
// t52leo 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 /recordsportal_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) });
}),
);
// t176t122 的 extractor_config(雲端指定地端萃取引擎)整組移除——
// interfaceKV key/讀取函式都不再需要,因為雲端已不下發、也不再有設定入口。
// ⚠️ 舊實例的 KV 殘值無害:daemon 端 t176 起也不吃這個欄位了。
// POST /portal/daemon/config — body {email, password}。同步小幫手憑「用戶剛設的帳密」
// 直接換到自己的設定(t54,leo 07-25:「最好的就是把它的帳密直接輸入」)——
// 用戶不必再下載 config.json 丟隱藏資料夾,托盤第一次開啟輸入網址+帳密就上工。
// 認證=與 /portal/login 同一把(同樣吃節流與停用檢查);回傳只含連線設定,不含任何知識內容。
// t122extractor 改讀雲端設定(未設→預設 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);
// t176leo 08-03 架構翻案):**不再下發任何 LLM 設定**(extractor/金鑰/模型)。
// 地端用哪個模型、哪把金鑰,由使用者在同步小幫手的托盤「AI 設定…」自己設。
//
// 為什麼拔掉:extractor_config 這把 KV 的 key 是 `${portalTenant(env)}:portal:extractor_config`
// 而 portalTenant 是 **worker 層級**環境變數(見本檔 43 行)=**全租戶共用一把**。
// 任一處設了 claude,所有人的 daemon 都會收到 claude;沒裝 Claude Code 的機器
// 會萃取全滅,而 portal 的 Claude 勾選框又恆為 disableddaemon 從未回報 has_claude
// ⇒ 用戶自己解不開(08-03 封測實證:雲端同步成功、金鑰有效,卻零張卡)。
// leo:「地端要用什麼模型就在 daemon 上輸入 API Key 設置,而不是雲端設置後控制地端」。
//
// ⚠️ 只拔 LLM 欄位——連線欄位(cypher_urlnamespacelibrary)與本 route 本身照舊,
// daemon 靠它上線;資料夾/庫管理(daemon/libraries)也完全不動(leo 明確劃界)。
const daemonCfg: Record<string, string> = {
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 });
}),
);
// t176t131 這一版 `/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<string, unknown>;
try {
record = JSON.parse(raw) as Record<string, unknown>;
} 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<string, unknown>;
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 — 庫目錄列表。
// t52leo 2026-07-26:「地端 2 個資料夾、雲端就要 2 個庫,只有一個一定被罵」):
// 除了登記簿裡的庫,**也把資料裡實際蓋過章的庫一併列出**(標 auto:true)——
// 蓋章即現身,用戶不必先去登記;登記簿只負責顯示名/圖譜來源這些額外設定。
// t135:讀 daemon 最近回報的 active libsKV 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<string> | 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<string, number>();
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<string, number>();
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<string, unknown>).card_count = cardMap.get(lib.name) ?? 0;
(lib as Record<string, unknown>).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 });
}),
);
// t160leo 07-31:「要直通 daemon,同步,**沒有登記這回事**」):
// 人工建庫端點 POST /portal/admin/libraries 已刪——庫只從 daemon 同步自動出現
// /portal/daemon/librariest159)。現行 UIe28e190 起)本就零呼叫此端點(死端點);
// 人工登記只會製造對不上的空庫(07-27 leo 拿掉表單時已定調)。
// GET 列表與 PATCH(管理已存在的庫:改名/停用/graph_source)照舊。
// POST /portal/daemon/libraries — 小幫手(daemon)連線精靈時把看守資料夾的庫報上來自動登記。
// t1592026-07-31 leo prod 實走揪出):daemon registerLibrariesarcrun-tray main.go:505t52
// 一直在打這個端點,但 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 /recordsportal_librarydaemon 登記)→ ${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<string, string> = {};
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 同一個值 ⇒ 兩者落在同一租戶分區,查得到彼此。
// - t176Claude 偏好(use_claude_for_extractclaude_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_availableuse_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 keyrole=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 ?? ''}」。資料仍在,重新同步會再出現。`,
});
}),
);