Files
Arcrun/cypher-executor/src/routes/portal.ts
T
uncle6me-web 9d38d580f2 feat(t131): AI 設定合併成一把金鑰——聊天與萃取共用,Claude 為選填加強
leo:「拿到一把就很難了,還要拿兩把。一律規定先輸入 gemini api key,
如果想要強化本地萃取,可以選擇 claude……前面那個,聊天和萃都一次設好,後面那把,不填就是 gemini」
+「重點是更好的模型萃取知識更能抓重點,不然他也不知道加強什麼」(文案講感覺得到的差別)
+「本地如果有裝 claude,掃到,只要一個 checkbox 就好」(daemon 偵測回報,沒偵測到就停用選項)。
- POST/GET /portal/admin/ai(一次寫 chat-key 與 extractor)+/portal/daemon/report-capabilities
- 舊 chat-key/extractor 端點保留相容;UI 兩區合併成「AI 設定」
t131 新測 7 條全綠(總體 229 passed/9 紅皆 pre-existing:HTML shell 搬遷×2、library-map 未實作×6、
executor 零件×1)。(實作=子 CC;驗證+commit=總管)
2026-07-29 15:28:10 +08:00

1233 lines
56 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';
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) });
}),
);
// ── t122 萃取引擎設定(daemon 萃取用;與 chat-key AI 問答金鑰獨立管理)──────────────
// KV key = {tenant}:portal:extractor_config,存在 WEBHOOKS KV(同 chat-key 手法)。
// 金鑰不落 logGET 只回 has_key:bool,不回明文。
// daemon 未設定時預設 gemma(封測者不會有 claude,以 gemma 為友善預設)。
interface ExtractorConfig {
engine: 'gemma' | 'claude';
gemini_api_key?: string;
llm_model?: string;
}
function extractorConfigKey(env: Bindings): string {
return `${portalTenant(env)}:portal:extractor_config`;
}
async function getExtractorConfig(env: Bindings): Promise<ExtractorConfig | null> {
const raw = await env.WEBHOOKS.get(extractorConfigKey(env), 'text');
if (!raw) return null;
try { return JSON.parse(raw) as ExtractorConfig; } catch { return null; }
}
// 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);
const extractorCfg = await getExtractorConfig(c.env);
const engine = extractorCfg?.engine ?? 'gemma';
const daemonCfg: Record<string, string> = {
cypher_url: new URL(c.req.url).origin,
namespace: tenant,
library: 'kb',
extractor: engine,
email,
instance_name: String(rec.values.display_name ?? ''),
};
if (engine === 'gemma' && extractorCfg?.gemini_api_key) {
daemonCfg.gemini_api_key = extractorCfg.gemini_api_key;
}
if (extractorCfg?.llm_model) daemonCfg.llm_model = extractorCfg.llm_model;
return c.json({ success: true, config: daemonCfg });
}),
);
// ── t131 合併 AI 設定(Gemini API Key 同時設 chatextractorhas_claude 由 daemon 回報)─────
// KV key = {tenant}:portal:ai_config,存在 WEBHOOKS KV。
// KV key = {tenant}:portal:daemon_caps,存 daemon 回報的能力(TTL 7 天)。
interface AiConfig {
gemini_api_key?: string;
use_claude_for_extract?: boolean;
}
interface DaemonCapabilities {
has_claude: boolean;
daemon_version?: string;
os?: string;
}
function aiConfigKey(env: Bindings): string { return `${portalTenant(env)}:portal:ai_config`; }
function daemonCapsKey(env: Bindings): string { return `${portalTenant(env)}:portal:daemon_caps`; }
async function getAiConfig(env: Bindings): Promise<AiConfig | null> {
const raw = await env.WEBHOOKS.get(aiConfigKey(env), 'text');
if (!raw) return null;
try { return JSON.parse(raw) as AiConfig; } catch { return null; }
}
async function getDaemonCaps(env: Bindings): Promise<DaemonCapabilities | null> {
const raw = await env.WEBHOOKS.get(daemonCapsKey(env), 'text');
if (!raw) return null;
try { return JSON.parse(raw) as DaemonCapabilities; } catch { return null; }
}
// 將 ai_config 同步回 extractor_configdaemon/config 讀 extractor_config,保持相容)。
async function syncExtractorFromAiConfig(env: Bindings, cfg: AiConfig): Promise<void> {
const exCfg: ExtractorConfig = {
engine: cfg.use_claude_for_extract ? 'claude' : 'gemma',
};
if (!cfg.use_claude_for_extract && cfg.gemini_api_key) {
exCfg.gemini_api_key = cfg.gemini_api_key;
}
await env.WEBHOOKS.put(extractorConfigKey(env), JSON.stringify(exCfg));
}
// POST /portal/admin/ai — body {gemini_api_key?, use_claude_for_extract?}t131)。
// 同時設定 AI 問答金鑰(chat)與萃取引擎(extractor)。admin 閘。
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?: string; use_claude_for_extract?: boolean } | null;
const newKey = String(body?.gemini_api_key ?? '').trim();
const useClause = typeof body?.use_claude_for_extract === 'boolean' ? body.use_claude_for_extract : undefined;
// 讀現有設定做合併(留空欄位=不變更)
const existing = await getAiConfig(c.env) ?? {};
const merged: AiConfig = {
gemini_api_key: newKey || existing.gemini_api_key,
use_claude_for_extract: useClause !== undefined ? useClause : (existing.use_claude_for_extract ?? false),
};
if (!merged.gemini_api_key) return c.json({ error: '請貼上你的 Gemini API Key' }, 400);
// 更新 chatrag_chat workflow)——容忍 404workflow 未安裝時暫存,安裝後再寫入)
if (newKey) {
const tenant = portalTenant(c.env);
const kvKey = `${tenant}:wf:rag_chat`;
const raw = await c.env.WEBHOOKS.get(kvKey, 'text');
if (raw) {
try {
const record = JSON.parse(raw) as Record<string, unknown>;
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] = newKey; }
else visit(rec[k]);
}
}
};
visit(record['graph']);
visit(record['config']);
await c.env.WEBHOOKS.put(kvKey, JSON.stringify(record));
} catch { /* 工作流記錄損壞時靜默略過,金鑰仍存 ai_config */ }
}
// 若 rag_chat 不存在(raw===null),跳過,等 acr init 安裝後再用舊 chat-key 端點補入
}
// 存合併設定
await c.env.WEBHOOKS.put(aiConfigKey(c.env), JSON.stringify(merged));
// 同步回 extractor_configdaemon/config 走這個)
await syncExtractorFromAiConfig(c.env, merged);
return c.json({
success: true,
has_key: true,
use_claude_for_extract: merged.use_claude_for_extract ?? false,
});
}),
);
// GET /portal/admin/ai — 回 has_key/use_claude_for_extract/claude_availablet131)。
portalRouter.get('/portal/admin/ai', (c) =>
run(c, async () => {
const auth = await requirePortalAdmin(c);
if (!auth.ok) return auth.res;
const cfg = await getAiConfig(c.env);
const caps = await getDaemonCaps(c.env);
return c.json({
success: true,
has_key: !!(cfg?.gemini_api_key),
use_claude_for_extract: cfg?.use_claude_for_extract ?? false,
claude_available: caps?.has_claude ?? false,
});
}),
);
// POST /portal/daemon/report-capabilities — body {email, password, has_claude, daemon_version?, os?}t131)。
// daemon 連線成功後回報本機能力;認證同 /portal/daemon/config(帳密)。
// ⚠️ daemon 端改動屬 arcrun-rag repo,本端只做「收端點+存 KV+供 GET /portal/admin/ai 用」。
portalRouter.post('/portal/daemon/report-capabilities', (c) =>
run(c, async () => {
const body = (await c.req.json().catch(() => null)) as { email?: string; password?: string; has_claude?: boolean; daemon_version?: string; os?: 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) { 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 caps: DaemonCapabilities = {
has_claude: body?.has_claude === true,
...(body?.daemon_version ? { daemon_version: String(body.daemon_version) } : {}),
...(body?.os ? { os: String(body.os) } : {}),
};
const TTL_7D = 7 * 24 * 60 * 60;
await c.env.WEBHOOKS.put(daemonCapsKey(c.env), JSON.stringify(caps), { expirationTtl: TTL_7D });
return c.json({ success: true });
}),
);
// 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 });
}),
);
// POST /portal/admin/extractor — body {engine, gemini_api_key?, llm_model?}t122)。
// 保留舊端點相容(新 UI 走 /portal/admin/ai)。
// admin 閘(同 chat-key 等級)。金鑰不落 log;存 WEBHOOKS KV。
portalRouter.post('/portal/admin/extractor', (c) =>
run(c, async () => {
const auth = await requirePortalAdmin(c);
if (!auth.ok) return auth.res;
const body = (await c.req.json().catch(() => null)) as { engine?: string; gemini_api_key?: string; llm_model?: string } | null;
const engine = String(body?.engine ?? '').trim().toLowerCase();
if (engine !== 'gemma' && engine !== 'claude') {
return c.json({ error: 'engine 只能是 gemma 或 claude' }, 400);
}
const cfg: ExtractorConfig = { engine: engine as 'gemma' | 'claude' };
if (engine === 'gemma') {
const key = String(body?.gemini_api_key ?? '').trim();
if (key) cfg.gemini_api_key = key;
}
const model = String(body?.llm_model ?? '').trim();
if (model) cfg.llm_model = model;
await c.env.WEBHOOKS.put(extractorConfigKey(c.env), JSON.stringify(cfg));
return c.json({ success: true, engine: cfg.engine, has_key: engine === 'gemma' && !!cfg.gemini_api_key });
}),
);
// GET /portal/admin/extractor — 回 engine + has_key(不回金鑰明文)(t122)。
// 保留舊端點相容(新 UI 走 /portal/admin/ai)。
portalRouter.get('/portal/admin/extractor', (c) =>
run(c, async () => {
const auth = await requirePortalAdmin(c);
if (!auth.ok) return auth.res;
const cfg = await getExtractorConfig(c.env);
return c.json({
success: true,
engine: cfg?.engine ?? 'gemma',
has_key: cfg?.engine === 'gemma' && !!cfg?.gemini_api_key,
llm_model: cfg?.llm_model ?? null,
});
}),
);
// 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));
// 資料面實際出現的庫(來自 ingest 蓋章的 metadata.library
try {
const res = await kbdbFetch(c.env, `/entries/libraries?owner_id=${encodeURIComponent(portalTenant(c.env))}`);
if (res.ok) {
const body = (await res.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,
...(watching !== undefined ? { daemon_watching: watching } : {}),
});
}
}
} catch {
// 資料面查不到不擋登記簿(誠實降級:至少顯示已登記的庫)
}
return c.json({ success: true, libraries: out, count: out.length });
}),
);
// POST /portal/admin/libraries — 登記一個庫。body {name, display_name?, description?}。
portalRouter.post('/portal/admin/libraries', (c) =>
run(c, async () => {
const auth = await requirePortalAdmin(c);
if (!auth.ok) return auth.res;
const body = await c.req.json().catch(() => null);
const name = String(body?.name ?? '').trim();
if (!isValidLibraryName(name) || name === '*') {
return c.json({ error: '庫名限 A-Za-z0-9_-1-64 字元;"*" 是保留值不可登記)' }, 400);
}
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);
if (existing.some((l) => (l.values.name ?? '') === name)) {
return c.json({ error: `庫 ${name} 已登記` }, 409);
}
const ns = portalNamespace(c.env);
const res = await kbdbFetch(c.env, '/records', {
method: 'POST',
body: JSON.stringify({
template: LIBRARY_TEMPLATE,
owner_id: ns,
values: {
name,
display_name: String(body?.display_name ?? '').trim() || name,
description: String(body?.description ?? '').trim(),
status: 'active',
},
}),
});
if (!res.ok) throw new KbdbError(`POST /recordsportal_library)→ ${res.status}`);
const created = (await res.json()) as { record?: PortalRecord };
return c.json({ success: true, library: created.record ? toPublicLibrary(created.record) : { name } });
}),
);
// 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) });
}),
);
// 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} 筆資料不可搜)。資料保留可還原——重新同步時會再出現。`,
});
}),
);
// 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 ?? ''}」。資料仍在,重新同步會再出現。`,
});
}),
);