9d38d580f2
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=總管)
1233 lines
56 KiB
TypeScript
1233 lines
56 KiB
TypeScript
/**
|
||
* RAG Portal 多人授權 — P2:用戶模型+認證 API(portal-auth design §2/§4/§5,Gitea #24/#25)
|
||
*
|
||
* 架構(design D-1):Portal=cypher-executor 的 /portal 路由(非獨立 worker)。
|
||
* 本檔只做 P2 的 auth/admin API;P3(/portal HTML 殼+ /portal/data/* enforce)另一波。
|
||
*
|
||
* 鐵律對照:
|
||
* - rule 2.1/2.2:這是 UI session 登入(console-auth 同類先例),非 workflow credential
|
||
* 原語。KDF 在 lib/portal-auth.ts(WebCrypto PBKDF2),本檔無解密/簽章/template 展開。
|
||
* - 零新表(design §2):portal_user / portal_library 都是 KBDB 萬用表 template;
|
||
* 資料經 KBDB base HTTP API 寫入(kbdbBase 慣例,不直連 D1、不寫 SQL)。
|
||
* - 子 namespace(design D-2):一切帳號資料 owner_id=`{CONSOLE_TENANT}::portal`。
|
||
* 既有租戶查詢面(/kbdb/*、MCP)都以 CONSOLE_TENANT 過濾 → 物理上搜不到帳號資料
|
||
* (email/password_hash 不會出現在知識搜尋結果)。
|
||
* - session(design §4.3):KV `portal_sess:{token}` 只存 record_id(TTL 暫存=合規),
|
||
* **每個請求回讀 user record 當唯一真相源** → 停用/改權限即時生效,不靠 session 反向索引。
|
||
* - 絕不下發租戶字串(design §3.3 關鍵差異 vs console):/portal/session 只回
|
||
* display_name/role/libraries。
|
||
* - 密碼永不明碼儲存、永不進 log(本檔不 log 任何 body)。
|
||
*/
|
||
import { Hono } from 'hono';
|
||
import type { Context } from 'hono';
|
||
import type { Bindings } from '../types';
|
||
import { kbdbBase } from './kbdb-proxy';
|
||
import { validateConsoleSession } from './console-auth';
|
||
import { hashPassword, verifyPassword, randomHex, generatePassword } from '../lib/portal-auth';
|
||
import { PORTAL_TEMPLATE_SEEDS } from '../lib/portal-seeds';
|
||
|
||
export const portalRouter = new Hono<{ Bindings: Bindings }>();
|
||
|
||
const SESSION_PREFIX = 'portal_sess:';
|
||
const LOCKFAIL_PREFIX = 'portal_lockfail:';
|
||
const LOCK_LIMIT = 5; // design §4.3:5 次失敗
|
||
const LOCK_TTL_SECONDS = 15 * 60; // 鎖 15 分鐘(KV TTL 自然過期)
|
||
const DEFAULT_SESSION_TTL = 604800; // 7 天(design §4.3,比 console 30 天緊)
|
||
|
||
const USER_TEMPLATE = 'portal_user';
|
||
const LIBRARY_TEMPLATE = 'portal_library';
|
||
|
||
// ── 基礎 helpers ────────────────────────────────────────────────────────────
|
||
|
||
/** 租戶字串(=知識資料的 owner_id)。預設沿 console-auth 同款 'leo'。**只在 server 側使用,永不下發前端**。 */
|
||
export function portalTenant(env: Bindings): string {
|
||
return env.CONSOLE_TENANT || 'leo';
|
||
}
|
||
|
||
/** 帳號子 namespace(design D-2)。 */
|
||
function portalNamespace(env: Bindings): string {
|
||
return `${portalTenant(env)}::portal`;
|
||
}
|
||
|
||
function sessionTtl(env: Bindings): number {
|
||
const n = Number.parseInt(env.PORTAL_SESSION_TTL ?? '', 10);
|
||
// KV expirationTtl 下限 60 秒;壞值誠實退回預設而非炸掉
|
||
return Number.isFinite(n) && n >= 60 ? n : DEFAULT_SESSION_TTL;
|
||
}
|
||
|
||
function bearerToken(c: Context<{ Bindings: Bindings }>): string | null {
|
||
const auth = c.req.header('authorization') ?? '';
|
||
return auth.match(/^Bearer\s+(\S+)/i)?.[1] ?? null;
|
||
}
|
||
|
||
/** KBDB 不可達/回錯時拋這個 → 各 route 統一 502 誠實回報(不假綠、不偽裝成 401)。 */
|
||
export class KbdbError extends Error {}
|
||
|
||
export async function kbdbFetch(env: Bindings, path: string, init?: RequestInit): Promise<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_id(design §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 names(t135 daemon hint)。 */
|
||
function daemonActiveKey(env: Bindings): string {
|
||
return `${portalTenant(env)}:portal:daemon_active_libs`;
|
||
}
|
||
|
||
export async function listRecordsByTemplate(env: Bindings, template: string): Promise<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 /records(portal_user)→ ${res.status}`);
|
||
const body = (await res.json()) as { record?: { record_id: string } };
|
||
const recordId = body.record?.record_id;
|
||
if (!recordId) throw new KbdbError('POST /records 回應缺 record_id');
|
||
|
||
// head entry:page_name=email(indexed)→ content=record_id,O(1) 登入查找
|
||
const head = await kbdbFetch(env, '/entries', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
entry_type: USER_TEMPLATE,
|
||
page_name: input.email,
|
||
content: recordId,
|
||
owner_id: ns,
|
||
}),
|
||
});
|
||
if (!head.ok) throw new KbdbError(`head entry 建立失敗(record ${recordId} 已建,需人工收拾)→ ${head.status}`);
|
||
return recordId;
|
||
}
|
||
|
||
// ── user 值域 helpers ──────────────────────────────────────────────────────
|
||
|
||
export function parseLibraries(raw: string | undefined): string[] {
|
||
if (!raw) return [];
|
||
try {
|
||
const arr = JSON.parse(raw);
|
||
if (Array.isArray(arr) && arr.every((x) => typeof x === 'string')) return arr;
|
||
} catch {
|
||
/* fallthrough */
|
||
}
|
||
return [];
|
||
}
|
||
|
||
function isValidEmail(email: string): boolean {
|
||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) && email.length <= 254;
|
||
}
|
||
|
||
/** 庫名進 metadata/query(逗號分隔參數),故禁逗號/空白/怪字元。 */
|
||
function isValidLibraryName(name: string): boolean {
|
||
return /^(\*|[A-Za-z0-9_-]{1,64})$/.test(name);
|
||
}
|
||
|
||
function validLibrariesInput(libs: unknown): libs is string[] {
|
||
return Array.isArray(libs) && libs.length > 0 && libs.every((x) => typeof x === 'string' && isValidLibraryName(x));
|
||
}
|
||
|
||
/** admin 面向的公開 user 形狀:**絕不含 password_hash**。 */
|
||
function toPublicUser(rec: PortalRecord) {
|
||
const v = rec.values;
|
||
return {
|
||
record_id: rec.record_id,
|
||
email: v.email ?? '',
|
||
display_name: v.display_name ?? '',
|
||
status: v.status ?? '',
|
||
role: v.role ?? '',
|
||
libraries: parseLibraries(v.libraries),
|
||
created_at: v.created_at ?? '',
|
||
updated_at: v.updated_at ?? '',
|
||
};
|
||
}
|
||
|
||
// ── session 閘 ────────────────────────────────────────────────────────────
|
||
|
||
export type AuthedUser = { token: string; recordId: string; values: Record<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 順手刪 KV(best-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_id(design §4.3)——權限/狀態每請求回讀 record,不快取進 session
|
||
await c.env.SESSIONS_KV.put(`${SESSION_PREFIX}${token}`, JSON.stringify({ record_id: recordId }), {
|
||
expirationTtl: sessionTtl(c.env),
|
||
});
|
||
return c.json({
|
||
success: true,
|
||
session_token: token,
|
||
display_name: rec.values.display_name ?? '',
|
||
role: rec.values.role ?? 'user',
|
||
libraries: parseLibraries(rec.values.libraries),
|
||
// 絕不回租戶字串(design §3.3:portal_user 拿到租戶字串就能繞過庫 filter 直打 /kbdb/*)
|
||
});
|
||
}),
|
||
);
|
||
|
||
// POST /portal/logout
|
||
portalRouter.post('/portal/logout', async (c) => {
|
||
const token = bearerToken(c);
|
||
if (token) await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`);
|
||
return c.json({ success: true });
|
||
});
|
||
|
||
// GET /portal/session — 每請求回讀 user record(真相源);回 display_name/role/libraries,
|
||
// **絕不回租戶字串**(design §5)。
|
||
// P3 補能力欄位(前端據此渲染,design §6/D-4/D-8):graph_allowed(graph 模式要不要顯示)、
|
||
// workflows_visible(工作流頁要不要顯示)。**這兩個只是顯示提示——真正的擋在
|
||
// /portal/data/* 路由層**(無權 403/404),前端藏不藏都繞不過。
|
||
portalRouter.get('/portal/session', (c) =>
|
||
run(c, async () => {
|
||
const auth = await requirePortalUser(c);
|
||
if (!auth.ok) return auth.res;
|
||
const v = auth.user.values;
|
||
const role = v.role ?? 'user';
|
||
const libraries = parseLibraries(v.libraries);
|
||
return c.json({
|
||
valid: true,
|
||
display_name: v.display_name ?? '',
|
||
email: v.email ?? '', // t53:完成安裝清單在站內生 daemon config.json 要用(身分顯示欄)
|
||
role,
|
||
libraries,
|
||
graph_allowed: await hasGraphAccess(c.env, libraries),
|
||
workflows_visible: workflowsVisible(c.env, role),
|
||
// portal-demo-suite:上傳頁能力(bindings 齊全才 true;同上,只是顯示提示,真閘在路由層)
|
||
upload_enabled: uploadEnabled(c.env),
|
||
});
|
||
}),
|
||
);
|
||
|
||
// POST /portal/me/password — body {current, new}。驗舊密改新密。
|
||
portalRouter.post('/portal/me/password', (c) =>
|
||
run(c, async () => {
|
||
const auth = await requirePortalUser(c);
|
||
if (!auth.ok) return auth.res;
|
||
const body = await c.req.json().catch(() => null);
|
||
const current = String(body?.current ?? '');
|
||
const next = String(body?.new ?? '');
|
||
if (!current || !next) return c.json({ error: 'current 與 new 必填' }, 400);
|
||
if (next.length < 8) return c.json({ error: '新密碼至少 8 碼' }, 400);
|
||
|
||
const ok = await verifyPassword(current, auth.user.values.password_hash ?? '');
|
||
if (!ok) return c.json({ error: '舊密碼不正確' }, 401);
|
||
|
||
const newHash = await hashPassword(next);
|
||
await patchRecordValues(c.env, auth.user.recordId, {
|
||
password_hash: newHash,
|
||
updated_at: new Date().toISOString(),
|
||
});
|
||
return c.json({ success: true });
|
||
}),
|
||
);
|
||
|
||
// ═══════════════════════════════ admin 端點 ══════════════════════════════════
|
||
|
||
// POST /portal/admin/bootstrap — 需 **console owner session**(design D-7:owner secret 是
|
||
// 安裝期人閘,不引入新 secret、不開放無閘註冊)。建第一個 role=admin 的 portal_user;
|
||
// 已有 admin → 409 拒絕重複 bootstrap。順手冪等確保 templates(seed 是 API 行為)。
|
||
portalRouter.post('/portal/admin/bootstrap', (c) =>
|
||
run(c, async () => {
|
||
const consoleOk = await validateConsoleSession(c.env, c.req.header('authorization'));
|
||
if (!consoleOk) return c.json({ error: '需要 console owner session(先登入 /console)' }, 401);
|
||
|
||
const seeded = await ensurePortalTemplates(c.env);
|
||
if (seeded.errors.length > 0) {
|
||
return c.json({ error: `portal templates seed 失敗:${seeded.errors.join('; ')}` }, 502);
|
||
}
|
||
|
||
const users = await listRecordsByTemplate(c.env, USER_TEMPLATE);
|
||
if (users.some((u) => (u.values.role ?? '') === 'admin')) {
|
||
return c.json({ error: '已有 admin,bootstrap 只能執行一次;後續帳號請用 /portal/admin/users' }, 409);
|
||
}
|
||
|
||
const body = await c.req.json().catch(() => null);
|
||
const email = String(body?.email ?? '').trim().toLowerCase();
|
||
const password = String(body?.password ?? '');
|
||
const displayName = String(body?.display_name ?? '').trim() || email;
|
||
if (!isValidEmail(email)) return c.json({ error: 'email 格式不正確' }, 400);
|
||
if (password.length < 8) return c.json({ error: '密碼至少 8 碼' }, 400);
|
||
if (await findUserRecordId(c.env, email)) return c.json({ error: '此 email 已存在' }, 409);
|
||
|
||
const recordId = await createPortalUser(c.env, {
|
||
email,
|
||
display_name: displayName,
|
||
role: 'admin',
|
||
libraries: ['*'], // bootstrap admin 預設全庫(design §3.3:["*"]=不注 library filter)
|
||
password_hash: await hashPassword(password),
|
||
});
|
||
return c.json({ success: true, record_id: recordId, email, role: 'admin' });
|
||
}),
|
||
);
|
||
|
||
// GET /portal/admin/users — 同仁列表(role=admin 閘)。**回應剝除 password_hash**。
|
||
portalRouter.get('/portal/admin/users', (c) =>
|
||
run(c, async () => {
|
||
const auth = await requirePortalAdmin(c);
|
||
if (!auth.ok) return auth.res;
|
||
const users = await listRecordsByTemplate(c.env, USER_TEMPLATE);
|
||
return c.json({ success: true, users: users.map(toPublicUser), count: users.length });
|
||
}),
|
||
);
|
||
|
||
// POST /portal/admin/users — 新增同仁。body {email, display_name?, role?, libraries?, password?}。
|
||
// 未帶 password → server 產一次性密碼隨回應回傳一次(不落地明碼,design §4.3 簡化版)。
|
||
portalRouter.post('/portal/admin/users', (c) =>
|
||
run(c, async () => {
|
||
const auth = await requirePortalAdmin(c);
|
||
if (!auth.ok) return auth.res;
|
||
const body = await c.req.json().catch(() => null);
|
||
const email = String(body?.email ?? '').trim().toLowerCase();
|
||
const displayName = String(body?.display_name ?? '').trim() || email;
|
||
const role = body?.role === 'admin' ? 'admin' : 'user';
|
||
const libraries: string[] = validLibrariesInput(body?.libraries) ? (body.libraries as string[]) : ['general'];
|
||
if (!isValidEmail(email)) return c.json({ error: 'email 格式不正確' }, 400);
|
||
if (body?.libraries !== undefined && !validLibrariesInput(body?.libraries)) {
|
||
return c.json({ error: 'libraries 須為非空字串陣列(庫名限 A-Za-z0-9_- 或 "*")' }, 400);
|
||
}
|
||
if (await findUserRecordId(c.env, email)) return c.json({ error: '此 email 已存在' }, 409);
|
||
|
||
let password = body?.password !== undefined ? String(body.password) : '';
|
||
let generated: string | undefined;
|
||
if (password) {
|
||
if (password.length < 8) return c.json({ error: '密碼至少 8 碼' }, 400);
|
||
} else {
|
||
generated = generatePassword();
|
||
password = generated;
|
||
}
|
||
|
||
const recordId = await createPortalUser(c.env, {
|
||
email,
|
||
display_name: displayName,
|
||
role,
|
||
libraries,
|
||
password_hash: await hashPassword(password),
|
||
});
|
||
const rec = await getRecordById(c.env, recordId);
|
||
return c.json({
|
||
success: true,
|
||
user: rec ? toPublicUser(rec) : { record_id: recordId, email },
|
||
// 一次性回傳(不儲存明碼);admin 口頭轉交同仁後即失效於 server 側
|
||
...(generated ? { generated_password: generated } : {}),
|
||
});
|
||
}),
|
||
);
|
||
|
||
// PATCH /portal/admin/users/:id — 改 status/role/libraries(design §5)。
|
||
// 停用即時生效機制=每請求回讀 record(§4.3),不依賴刪 session。
|
||
portalRouter.patch('/portal/admin/users/:id', (c) =>
|
||
run(c, async () => {
|
||
const auth = await requirePortalAdmin(c);
|
||
if (!auth.ok) return auth.res;
|
||
const recordId = c.req.param('id');
|
||
const rec = await assertPortalUserRecord(c.env, recordId);
|
||
if (!rec) return c.json({ error: '用戶不存在' }, 404);
|
||
|
||
const body = await c.req.json().catch(() => null);
|
||
if (!body) return c.json({ error: 'body 必須是 JSON' }, 400);
|
||
const patch: Record<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?}]}。
|
||
// t52(leo 2026-07-26:「用戶可以看到我有 2 個庫,地端雲端都是 2 個,如果只有一個一定被罵」):
|
||
// 小幫手回報它看守的資料夾各自對應的庫,雲端**自動登記**——庫目錄與地端資料夾一比一。
|
||
// 認證=同 /portal/daemon/config(用戶帳密)。已存在的庫略過(冪等),不覆寫顯示名。
|
||
portalRouter.post('/portal/daemon/libraries', (c) =>
|
||
run(c, async () => {
|
||
const body = (await c.req.json().catch(() => null)) as
|
||
| { email?: string; password?: string; libraries?: { name?: string; display_name?: string }[] }
|
||
| null;
|
||
const email = String(body?.email ?? '').trim().toLowerCase();
|
||
const password = String(body?.password ?? '');
|
||
if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400);
|
||
if (await isLocked(c.env, email)) return c.json({ error: '登入失敗次數過多,請稍後再試' }, 429);
|
||
const recordId = await findUserRecordId(c.env, email);
|
||
const rec = recordId ? await getRecordById(c.env, recordId) : null;
|
||
if (!rec || (rec.values.status ?? '') !== 'active'
|
||
|| !(await verifyPassword(password, rec.values.password_hash ?? ''))) {
|
||
await recordLoginFail(c.env, email);
|
||
return c.json({ error: 'email 或密碼錯誤' }, 401);
|
||
}
|
||
await clearLoginFail(c.env, email);
|
||
|
||
const wanted = Array.isArray(body?.libraries) ? body!.libraries! : [];
|
||
const seeded = await ensurePortalTemplates(c.env);
|
||
if (seeded.errors.length > 0) {
|
||
return c.json({ error: `portal templates seed 失敗:${seeded.errors.join('; ')}` }, 502);
|
||
}
|
||
const existing = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE);
|
||
const have = new Set(existing.map((l) => String(l.values.name ?? '')));
|
||
const ns = portalNamespace(c.env);
|
||
const created: string[] = [];
|
||
for (const item of wanted) {
|
||
const name = String(item?.name ?? '').trim();
|
||
if (!isValidLibraryName(name) || name === '*' || have.has(name)) continue;
|
||
const res = await kbdbFetch(c.env, '/records', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
template: LIBRARY_TEMPLATE,
|
||
owner_id: ns,
|
||
values: {
|
||
name,
|
||
display_name: String(item?.display_name ?? '').trim() || name,
|
||
description: '同步小幫手看守的資料夾',
|
||
status: 'active',
|
||
},
|
||
}),
|
||
});
|
||
if (!res.ok) throw new KbdbError(`POST /records(portal_library)→ ${res.status}`);
|
||
have.add(name);
|
||
created.push(name);
|
||
}
|
||
const after = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE);
|
||
// t135:記下本次 daemon 回報的所有庫名(48h TTL)供 GET /portal/admin/libraries 顯示「未同步」提示。
|
||
const activeNames = wanted.map((item) => String(item?.name ?? '').trim()).filter(Boolean);
|
||
if (activeNames.length > 0) {
|
||
await c.env.WEBHOOKS.put(daemonActiveKey(c.env), JSON.stringify(activeNames), { expirationTtl: 172800 });
|
||
}
|
||
return c.json({ success: true, created, libraries: after.map(toPublicLibrary) });
|
||
}),
|
||
);
|
||
|
||
// ── t122 萃取引擎設定(daemon 萃取用;與 chat-key AI 問答金鑰獨立管理)──────────────
|
||
// KV key = {tenant}:portal:extractor_config,存在 WEBHOOKS KV(同 chat-key 手法)。
|
||
// 金鑰不落 log;GET 只回 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 同一把(同樣吃節流與停用檢查);回傳只含連線設定,不含任何知識內容。
|
||
// t122:extractor 改讀雲端設定(未設→預設 gemma;gemma+金鑰→一併下發金鑰)。
|
||
portalRouter.post('/portal/daemon/config', (c) =>
|
||
run(c, async () => {
|
||
const body = (await c.req.json().catch(() => null)) as { email?: string; password?: string } | null;
|
||
const email = String(body?.email ?? '').trim().toLowerCase();
|
||
const password = String(body?.password ?? '');
|
||
if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400);
|
||
if (await isLocked(c.env, email)) {
|
||
return c.json({ error: '登入失敗次數過多,已暫時鎖定,請 15 分鐘後再試' }, 429);
|
||
}
|
||
const recordId = await findUserRecordId(c.env, email);
|
||
const rec = recordId ? await getRecordById(c.env, recordId) : null;
|
||
if (!rec) {
|
||
await recordLoginFail(c.env, email);
|
||
return c.json({ error: 'email 或密碼錯誤' }, 401);
|
||
}
|
||
if ((rec.values.status ?? '') !== 'active') return c.json({ error: '帳號已停用' }, 403);
|
||
if (!(await verifyPassword(password, rec.values.password_hash ?? ''))) {
|
||
await recordLoginFail(c.env, email);
|
||
return c.json({ error: 'email 或密碼錯誤' }, 401);
|
||
}
|
||
await clearLoginFail(c.env, email);
|
||
const tenant = portalTenant(c.env);
|
||
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 同時設 chat+extractor;has_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_config(daemon/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);
|
||
|
||
// 更新 chat(rag_chat workflow)——容忍 404(workflow 未安裝時暫存,安裝後再寫入)
|
||
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_config(daemon/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_available(t131)。
|
||
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 — 庫目錄列表。
|
||
// t52(leo 2026-07-26:「地端 2 個資料夾、雲端就要 2 個庫,只有一個一定被罵」):
|
||
// 除了登記簿裡的庫,**也把資料裡實際蓋過章的庫一併列出**(標 auto:true)——
|
||
// 蓋章即現身,用戶不必先去登記;登記簿只負責顯示名/圖譜來源這些額外設定。
|
||
// t135:讀 daemon 最近回報的 active libs(KV TTL 48h),已登記的庫若不在其中標 daemon_watching:false。
|
||
portalRouter.get('/portal/admin/libraries', (c) =>
|
||
run(c, async () => {
|
||
const auth = await requirePortalAdmin(c);
|
||
if (!auth.ok) return auth.res;
|
||
const libs = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE);
|
||
// 讀 daemon 最近回報的 active lib names(若 KV 不存在 = daemon 從未回報,不標 hint)
|
||
let daemonActive: Set<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 /records(portal_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 ?? ''}」。資料仍在,重新同步會再出現。`,
|
||
});
|
||
}),
|
||
);
|