portal-auth P3(#24 #25):Portal UI+/portal/data/* server-side enforce(不 merge,待總管審) #52

Merged
Leo merged 5 commits from portal-auth-p3-portal-ui into main 2026-07-14 07:19:02 +00:00
13 changed files with 1481 additions and 29 deletions
+5 -1
View File
@@ -25,6 +25,8 @@ import { consoleRouter } from './routes/console';
import { consoleAuthRouter } from './routes/console-auth';
import { consoleDashboardRouter } from './routes/console-dashboard';
import { portalRouter } from './routes/portal';
import { portalDataRouter } from './routes/portal-data';
import { portalUiRouter } from './routes/portal-ui';
const app = new Hono<{ Bindings: Bindings }>();
@@ -57,7 +59,9 @@ app.route('/', kbdbProxyRouter); // kbdb-base 9.5KBDB 資料層 proxy(讓
app.route('/', consoleRouter); // Arcrun#3:搜尋/控制台頁 v0(單檔 HTML+原生 JS,薄殼)
app.route('/', consoleAuthRouter); // Arcrun#3 發現②:console 專用簡單 email+password 登入(單一管理員帳密,非多租戶)
app.route('/', consoleDashboardRouter); // T-cockpit ②:駕駛艙 dashboard(聚合 KBDB dash_* entries,無需登入唯讀)
app.route('/', portalRouter); // portal-auth P2#24/#25):RAG Portal 多人授權——用戶模型+認證 APIP3 UI 另一波)
app.route('/', portalRouter); // portal-auth P2#24/#25):RAG Portal 多人授權——用戶模型+認證 API
app.route('/', portalDataRouter); // portal-auth P3/portal/data/* server-side enforceowner_idlibrary 注入,安全核心)
app.route('/', portalUiRouter); // portal-auth P3GET /portal 單檔 HTML 殼(登入/搜尋/設定;獨立於 console)
// Worker 導出(fetch + scheduled
// scheduled handler 對應 wrangler.toml [triggers].crons,每分鐘 tick
+3 -1
View File
@@ -30,9 +30,11 @@ export const PORTAL_TEMPLATE_SEEDS: PortalTemplateSeed[] = [
{
// design §3.2:庫目錄(admin 頁列庫用)。庫本體=知識條目 metadata_json.$.library 標記,
// 這裡只是「有哪些庫」的登記簿。
// graph_sourcedesign D-4P3):'true'=此庫是知識圖譜的萃取來源——graph 粗閘按
// 「用戶是否擁有 graph 來源庫權限」放行。**沒有任何庫標記時預設視同 general**(D-4 定案)。
name: 'portal_library',
description: 'RAG Portal 庫目錄登記(portal-auth §3.2;庫=metadata_json.$.library 標記)',
slots: ['name', 'display_name', 'description', 'status'],
slots: ['name', 'display_name', 'description', 'status', 'graph_source'],
created_by: 'system',
},
];
+7 -5
View File
@@ -121,8 +121,10 @@ kbdbProxyRouter.get('/kbdb/records/:recordId', async (c) => {
// ── search(限本租戶範圍內)────────────────────────────────────────────────────
// GET /kbdb/search?q=&entry_type=&source=&mode= — entries 搜尋,限本租戶 owner_id。
// 透傳 entry_typebase 通用 filterworkflow-discovery Q4/ source / mode 給 KBDB /entries/search。
// GET /kbdb/search?q=&entry_type=&source=&library=&mode= — entries 搜尋,限本租戶 owner_id。
// 透傳 entry_typebase 通用 filterworkflow-discovery Q4/ source / library(多值逗號分隔,
// portal-auth P1 順延項——owner/admin 面自選庫過濾;portal 一般用戶不經這,走 /portal/data/*
// 的 server 注入)/ mode 給 KBDB /entries/search。
kbdbProxyRouter.get('/kbdb/search', async (c) => {
const owner = tenant(c);
if (!owner) return c.json(NEED_KEY, 401);
@@ -130,7 +132,7 @@ kbdbProxyRouter.get('/kbdb/search', async (c) => {
if (!q) return c.json({ error: 'q 必填' }, 400);
const { base, headers } = kbdbBase(c.env);
const params = new URLSearchParams({ q, owner_id: owner });
for (const k of ['entry_type', 'source', 'mode']) {
for (const k of ['entry_type', 'source', 'library', 'mode']) {
const v = c.req.query(k);
if (v) params.set(k, v);
}
@@ -160,7 +162,7 @@ kbdbProxyRouter.post('/kbdb/entries', async (c) => {
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
});
// GET /kbdb/entries — listfilters: entry_type / parent_id / page_name / source / q(search) / limit / offset)。
// GET /kbdb/entries — listfilters: entry_type / parent_id / page_name / source / library / q(search) / limit / offset)。
// owner_id 強制覆寫成本租戶(防跨租戶讀;caller 不能查別人的 owner_id)。
// Arcrun#3 發現①根因:本白名單原本沒有 q/searchcaller 帶 search= 會被這裡靜默丟棄,
// 打到 base 永遠是「無過濾 list」——不是 458K 筆搜不到,是這個 filter 從沒被轉發過。
@@ -171,7 +173,7 @@ kbdbProxyRouter.get('/kbdb/entries', async (c) => {
const { base, headers } = kbdbBase(c.env);
const params = new URLSearchParams();
params.set('owner_id', owner); // 強制本租戶,不接受 caller 覆寫
for (const k of ['entry_type', 'parent_id', 'page_name', 'source', 'limit', 'offset']) {
for (const k of ['entry_type', 'parent_id', 'page_name', 'source', 'library', 'limit', 'offset']) {
const v = c.req.query(k);
if (v) params.set(k, v);
}
+190
View File
@@ -0,0 +1,190 @@
/**
* RAG Portal 查詢面 — P3/portal/data/* server-side enforceportal-auth design §3.3/§3.4/§5
* Gitea #24/#25)。本檔是本 SDD 的**安全核心**。
*
* 安全模型(design §3.3,與 console 的關鍵差異):
* - console 登入後把 CONSOLE_TENANT 下發給前端直打 /kbdb/***portal 前端絕不持有租戶字串**,
* 只有 portal session token。portal_user 拿到租戶字串就能繞過庫 filter 直打 /kbdb/search
* 所以 enforce 全在 serversession → 回讀 user record(唯一真相源)→ 取 libraries →
* server 注入 owner_idlibrary 後轉發 KBDB。
* - caller 自帶的 owner_id / library query 參數**一律忽略**(不是拒絕——拒絕會變成
* 「試參數名」的 oracle;直接靜默覆蓋,怎麼傳都是自己的權限範圍)。
* - ["*"]=全庫:只注 owner_id、不注 librarydesign §3.3)。
*
* 不洩存在性(紅線):越庫的 entry(含根本不存在的 id、別的租戶的 id)一律回**同一句 404**,
* 不讓攻擊者從 403/404 差異推斷某 id / 某庫存在。唯一例外=graph 粗閘按 SDD 明定回 403D-4)。
*
* 薄殼(rule 07):本檔沒有新能力——搜尋/取條目能力真身在 KBDB baseP1 的 library filter),
* graph 真身在 kbdb-graph-plugin,工作流真身在 WEBHOOKS/ANALYTICS KV(與 /webhooks/named、
* /workflows/:name/executions 同一資料源)。這裡只做「權限注入+轉發/讀取」。
*
* log 紅線:本檔不 log 任何 token / 密碼 / 查詢內容。
*/
import { Hono } from 'hono';
import type { Context } from 'hono';
import type { Bindings } from '../types';
import { kbdbFetch, run, requirePortalUser, parseLibraries, portalTenant, hasGraphAccess, workflowsVisible } from './portal';
import { graphBase } from './kbdb-proxy';
export const portalDataRouter = new Hono<{ Bindings: Bindings }>();
/** 越庫/不存在 一律同一句 404(不洩存在性)。 */
function notFound(c: Context<{ Bindings: Bindings }>): Response {
return c.json({ error: '找不到這筆資料' }, 404);
}
/** entry 的庫歸屬:metadata_json.$.library,未標記 → 'general'design §3.2 fallback,與 KBDB P1 同語意)。 */
export function entryLibrary(entry: { metadata_json?: string | null }): string {
try {
const meta = JSON.parse(entry.metadata_json ?? 'null') as { library?: unknown } | null;
if (meta && typeof meta.library === 'string' && meta.library.trim()) return meta.library;
} catch {
/* metadata 壞掉 → 視同未標記 */
}
return 'general';
}
/** 用戶庫集合是否覆蓋某庫(["*"]=全庫)。 */
function canReadLibrary(userLibraries: string[], library: string): boolean {
return userLibraries.includes('*') || userLibraries.includes(library);
}
// GET /portal/data/search?q=&mode=&entry_type=&limit= — 三模式中的 keyword/semantic
//graph 走 /portal/data/graph/*)。server 注入 owner_idlibrary;回應照 KBDB 原形
//entries 含 metadata_json,前端自取 source 溯源;mode/capability_hint 誠實透傳——
// semantic 未開的降級行為沿 KBDB 既有,P1 已保 library 照 enforce)。
portalDataRouter.get('/portal/data/search', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const q = c.req.query('q');
if (!q) return c.json({ error: 'q 必填' }, 400);
const libraries = parseLibraries(auth.user.values.libraries);
if (libraries.length === 0) {
// 帳號沒被授權任何庫:誠實空結果(不打 KBDB——沒有可查範圍就沒有查詢)
return c.json({ success: true, entries: [], count: 0, mode: 'keyword', note: '此帳號尚未被授權任何知識庫,請聯絡管理員。' });
}
const params = new URLSearchParams({ q, owner_id: portalTenant(c.env) });
if (!libraries.includes('*')) params.set('library', libraries.join(','));
// 透傳的只有「在權限範圍內再收窄」的 filterowner_id/library 上面已由 server 定死,
// caller 傳什麼都不看(URLSearchParams 是新建的,蓋不掉)。
if (c.req.query('mode') === 'semantic') params.set('mode', 'semantic');
const entryType = c.req.query('entry_type');
if (entryType) params.set('entry_type', entryType);
const limit = c.req.query('limit');
if (limit && /^\d{1,3}$/.test(limit)) params.set('limit', limit);
const res = await kbdbFetch(c.env, `/entries/search?${params.toString()}`);
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
}),
);
// GET /portal/data/entries/:id — 卡片詳頁。**逐筆驗庫**design §5):
// ① entry 必須屬於本實例租戶(owner_id=CONSOLE_TENANT)——防拿別租戶 id 直讀;
// ② entry 的 libraryNULL→general)必須在用戶庫集合內——防拿越庫 id 直讀。
// 兩者不符與不存在同回 404(不洩存在性)。
portalDataRouter.get('/portal/data/entries/:id', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const libraries = parseLibraries(auth.user.values.libraries);
if (libraries.length === 0) return notFound(c);
const res = await kbdbFetch(c.env, `/entries/${encodeURIComponent(c.req.param('id'))}`);
if (res.status === 404) return notFound(c);
if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status}` }, 502);
const body = (await res.json()) as { entry?: { owner_id?: string | null; metadata_json?: string | null } };
const entry = body.entry;
if (!entry) return notFound(c);
if ((entry.owner_id ?? '') !== portalTenant(c.env)) return notFound(c);
if (!canReadLibrary(libraries, entryLibrary(entry))) return notFound(c);
return c.json({ success: true, entry });
}),
);
// GET /portal/data/graph/neighbors/:name — graph 模式(D-4 粗閘):
// 只對「擁有 graph 來源庫權限」的用戶開放;無權 → 403(SDD 明定,graph 粗閘是 404 紅線的例外)。
// 放行後純轉發 kbdb-graph-plugintoken 只在 server 側,同 kbdb-proxy 慣例)。
portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const libraries = parseLibraries(auth.user.values.libraries);
if (!(await hasGraphAccess(c.env, libraries))) {
return c.json({ error: '無知識圖譜檢視權限' }, 403);
}
const base = graphBase(c.env);
const headers: Record<string, string> = {};
if (c.env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
try {
const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(c.req.param('name'))}`, { headers });
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
} catch (e) {
// plugin 沒部署/不可達 → 誠實 502(前端顯示「關聯服務不可達」,不假裝無關聯)
return c.json({ error: `kbdb-graph-plugin 不可達:${e instanceof Error ? e.message : String(e)}` }, 502);
}
}),
);
// GET /portal/data/workflows — 工作流顯示(D-8):唯讀 list+每條的最近一次執行,**不開 trigger**
//trigger 是 owner/console 的事;回應也不含 webhook_url,不給可打的把手)。
// 可見性:PORTAL_SHOW_WORKFLOWS=admin(預設,role 閘 403/ all / off(整頁不存在 → 404)。
portalDataRouter.get('/portal/data/workflows', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const setting = (c.env.PORTAL_SHOW_WORKFLOWS ?? 'admin').toLowerCase();
if (setting === 'off') return notFound(c);
if (!workflowsVisible(c.env, auth.user.values.role ?? 'user')) {
return c.json({ error: '需要 admin 權限' }, 403);
}
// 資料源與 /webhooks/named /workflows/:name/executions 同一份(WEBHOOKS/ANALYTICS KV)。
// 不經 HTTP 打自己(global_fetch_strictly_public 下 fetch 自己 hostname 會 self-loop),
// 直讀同 worker 的 KV binding;欄位收斂成唯讀展示需要的最小集合。
const tenant = portalTenant(c.env);
const prefix = `${tenant}:wf:`;
const list = await c.env.WEBHOOKS.list({ prefix });
const workflows = await Promise.all(
list.keys.map(async (k) => {
const name = k.name.slice(prefix.length);
const raw = await c.env.WEBHOOKS.get(k.name, 'text');
let description = '';
let created_at = '';
let cron_expr: string | undefined;
if (raw) {
try {
const rec = JSON.parse(raw) as { description?: string; created_at?: string; cron_expr?: string };
description = rec.description ?? '';
created_at = rec.created_at ?? '';
cron_expr = rec.cron_expr;
} catch {
/* 壞 record 誠實留空 */
}
}
// 最近一次執行:ANALYTICS_KV stats:{name}:{unix_ms}——key 後綴定長毫秒 timestamp
// 字典序=時間序,取最後一把 key 即最新(同 /workflows/:name/executions 的排序邏輯)。
let last_execution: { timestamp: string; verdict?: string } | null = null;
const stats = await c.env.ANALYTICS_KV.list({ prefix: `stats:${name}:`, limit: 1000 });
if (stats.keys.length > 0) {
const latest = stats.keys.reduce((a, b) => (a.name > b.name ? a : b));
const ts = latest.name.split(':').pop() ?? '';
const rawStat = await c.env.ANALYTICS_KV.get(latest.name);
let verdict: string | undefined;
if (rawStat) {
try {
verdict = (JSON.parse(rawStat) as { verdict?: string }).verdict;
} catch {
/* 壞 record 誠實留空 */
}
}
last_execution = { timestamp: ts, verdict };
}
return { name, description, created_at, cron_expr, last_execution };
}),
);
return c.json({ success: true, workflows, total: workflows.length, read_only: true });
}),
);
+743
View File
@@ -0,0 +1,743 @@
/**
* RAG Portal 前端殼 — P3GET /portal 單檔 HTMLportal-auth design §1 D-1/§6Gitea #24/#25
*
* 形態(D-1):cypher-executor 的新路由、**獨立 HTML 殼**——不在 console 上加 if(「Admin
* Console 現狀不動」鐵律)。樣式重用 console 的設計語言(紙感/明體標題/琥珀強調/深淺主題),
* 取捨=**複製樣式子集而非抽共用模組**:抽共用要動 console.ts(違「console 不動」),且兩頁
* 受眾不同(owner vs 同仁)預期各自演化;代價=樣式雙份、改版要兩邊同步(PR 說明誠實記)。
*
* 頁面(design §6,leo 頁面級拍板):
* - 登入頁(brandCONSOLE_BRAND;未登入只見這個殼)
* - 搜尋頁(落地頁):keyword/semantic/graph 三模式+結果含 source 溯源+卡片詳頁
* - 設定頁:改自己密碼、看自己角色與可查庫、主題切換
* - 工作流頁:D-8 定案 admin 可見(PORTAL_SHOW_WORKFLOWS 可調 all/off);唯讀、不開 trigger
* - graph 模式按 D-4 粗閘顯示(/portal/session 的 graph_allowed
* - Mira 專屬頁(駕駛艙/分流台/憑證/專案)一律不進
*
* 安全(design §3.3 關鍵差異 vs console):
* - 本頁 JS **沒有任何租戶字串、沒有 X-Arcrun-API-Key**——只持 portal session token
* localStorage `arcrun_portal_session`),一切資料走 /portal/data/*server-side enforce)。
* - 前端的「藏」(graph 模式不顯示、工作流頁不顯示)只是 UX;真閘在 /portal/data/* 路由層
* 403/404),curl 直打也繞不過(#48 guard 精神)。
* - 所有動態內容經 esc() 跳脫(防 XSS,同 console 慣例);密碼欄位值不進 log、不進 URL。
*
* 薄殼(rule 07):零業務邏輯,只 fetch /portal/* 端點渲染。
*/
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { TAIPEI_CLIENT_JS } from '../lib/taipei-time';
export const portalUiRouter = new Hono<{ Bindings: Bindings }>();
function renderPortalHtml(brand: string): string {
return `<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${brand} Portal</title>
<script>
// 主題預載(防閃色):預設淺色,選擇存 localStorage(與 console 同設計語言、不同 key
document.documentElement.setAttribute('data-theme', (function () {
try { return localStorage.getItem('arcrun_portal_theme') === 'dark' ? 'dark' : 'light'; } catch (e) { return 'light'; }
})());
</script>
<style>
* { box-sizing: border-box; }
/* ── 主題色板(console 設計語言複製子集:淺=宣紙米白紙紋+墨字;深=紙感暖黑 2a)── */
:root {
--paper-a: #f4eddc; --paper-b: #f1e9d6;
--ink: #2f2a20; --ink-rgb: 30,24,14;
--amber: #8a5f1e; --amber-rgb: 138,95,30;
--ok: #1d7a48; --ok-rgb: 29,122,72; --ok-soft: #1d6b40;
--err: #b03a26; --err-rgb: 176,58,38;
--well: rgba(30,24,14,.06); --well2: rgba(30,24,14,.08);
--bar-bg: #efe7d3; --track: rgba(30,24,14,.12); --knob: #ffffff;
}
:root[data-theme="dark"] {
--paper-a: #191410; --paper-b: #1b1611;
--ink: #ede4d3; --ink-rgb: 237,228,211;
--amber: #e8b45a; --amber-rgb: 232,180,90;
--ok: #7fe0a8; --ok-rgb: 63,190,120; --ok-soft: #a8e8c4;
--err: #e58575; --err-rgb: 217,95,76;
--well: rgba(0,0,0,.25); --well2: rgba(0,0,0,.3);
--bar-bg: #17120e; --track: rgba(255,255,255,.08); --knob: #ede4d3;
}
html, body { margin: 0; background: repeating-linear-gradient(0deg,var(--paper-a) 0px,var(--paper-a) 3px,var(--paper-b) 3px,var(--paper-b) 4px); color: var(--ink);
font-family: -apple-system, "PingFang TC", "Microsoft JhengHei", system-ui, sans-serif; font-size: 16px; -webkit-font-smoothing: antialiased; }
input, textarea, button { font-family: inherit; }
::placeholder { color: rgba(var(--ink-rgb),.35); }
input:focus { outline: 2px solid rgba(var(--amber-rgb),.5); outline-offset: 1px; }
a { color: var(--amber); }
.serif { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; }
.mono { font-family: ui-monospace, Menlo, monospace; }
.muted { color: rgba(var(--ink-rgb),.55); }
.dim { color: rgba(var(--ink-rgb),.4); }
.err { color: var(--err); }
.ok { color: var(--ok); }
/* ── auth 全屏(.view.on 的 block 會蓋 flex → 補 .authwrap.view.onconsole 同修)── */
.authwrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; }
.authbox { width: 100%; max-width: 400px; display: flex; flex-direction: column; gap: 24px; text-align: center; }
.brand { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 44px; letter-spacing: .24em; color: var(--amber); }
.brand-sub { margin-top: 10px; font-size: 15px; color: rgba(var(--ink-rgb),.55); letter-spacing: .1em; }
/* ── 版面骨架 ── */
#shell { display: none; min-height: 100vh; }
#shell.on { display: flex; }
#sidenav { display: none; }
main { flex: 1; min-width: 0; padding-bottom: 96px; }
.page { max-width: 980px; margin: 0 auto; padding: 0 20px 40px; }
.page.narrow { max-width: 760px; }
.pagehead { padding: 22px 2px 14px; border-bottom: 2px solid rgba(var(--amber-rgb),.4); display: flex; justify-content: space-between; align-items: baseline; gap: 10px; }
.pagehead .t { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 23px; letter-spacing: .2em; white-space: nowrap; }
.pagehead .m { font-size: 13.5px; color: rgba(var(--ink-rgb),.45); }
#tabbar { position: fixed; left: 0; right: 0; bottom: 0; background: var(--bar-bg); border-top: 2px solid rgba(var(--amber-rgb),.4); display: none; z-index: 50; padding-bottom: env(safe-area-inset-bottom); }
#tabbar.on { display: flex; }
#tabbar .tab { flex: 1; text-align: center; padding: 15px 0 13px; font-size: 14.5px; cursor: pointer; min-height: 44px; color: rgba(var(--ink-rgb),.5); border-top: 2px solid transparent; margin-top: -2px; }
#tabbar .tab.on { color: var(--amber); font-weight: 600; border-top-color: var(--amber); }
#tabbar .tab.hide { display: none; }
@media (min-width: 1024px) {
#tabbar.on { display: none; }
main { padding-bottom: 20px; }
#sidenav { display: flex; width: 216px; flex: none; position: sticky; top: 0; height: 100vh; border-right: 2px solid rgba(var(--amber-rgb),.35); padding: 28px 0; flex-direction: column; gap: 4px; }
#sidenav .logo { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 26px; letter-spacing: .22em; color: var(--amber); padding: 0 24px 20px; }
#sidenav .nav { padding: 13px 24px; font-size: 16px; cursor: pointer; letter-spacing: .08em; color: rgba(var(--ink-rgb),.6); border-right: 2px solid transparent; }
#sidenav .nav.on { color: var(--amber); background: rgba(var(--amber-rgb),.08); border-right-color: var(--amber); font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-weight: 600; }
#sidenav .nav.hide { display: none; }
#sidenav .foot { margin-top: auto; padding: 0 24px; font-size: 12.5px; color: rgba(var(--ink-rgb),.35); line-height: 1.7; }
}
.view { display: none; }
.view.on { display: block; }
.authwrap.view.on { display: flex; }
/* ── 元件 ── */
.btn { padding: 13px; font-size: 16px; font-weight: 600; border-radius: 10px; border: none; background: linear-gradient(90deg,#b98330,#e8b45a); color: #241804; cursor: pointer; }
.btn:disabled { opacity: .45; cursor: default; }
.btn3 { padding: 11px 16px; font-size: 15px; border-radius: 10px; border: 1px solid rgba(var(--ink-rgb),.25); background: none; color: rgba(var(--ink-rgb),.7); cursor: pointer; }
.txt { width: 100%; padding: 13px 15px; font-size: 16px; border-radius: 10px; border: 1px solid rgba(var(--ink-rgb),.2); background: rgba(var(--ink-rgb),.05); color: var(--ink); }
.panel { padding: 18px; border-radius: 13px; background: rgba(var(--ink-rgb),.04); border: 1px solid rgba(var(--ink-rgb),.12); }
.tag { display: inline-block; padding: 3px 9px; border-radius: 5px; background: rgba(var(--amber-rgb),.12); color: var(--amber); font-size: 12.5px; }
.tag.green { background: rgba(var(--ok-rgb),.12); color: var(--ok); }
.tag.dim { background: rgba(var(--ink-rgb),.08); color: rgba(var(--ink-rgb),.55); }
.honest { padding: 22px; border-radius: 13px; border: 1px dashed rgba(var(--amber-rgb),.4); background: rgba(var(--amber-rgb),.06); text-align: center; }
.honest .h { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 19px; color: var(--amber); margin-bottom: 8px; }
.honest .b { font-size: 15px; color: rgba(var(--ink-rgb),.6); line-height: 1.7; }
#toast { position: fixed; left: 50%; bottom: 110px; transform: translateX(-50%); z-index: 99; padding: 13px 22px; border-radius: 11px; background: #e8b45a; color: #241804; font-size: 15.5px; font-weight: 600; box-shadow: 0 6px 24px rgba(0,0,0,.5); white-space: nowrap; display: none; }
/* ── 搜尋 ── */
.searchrow { display: flex; gap: 10px; align-items: stretch; }
.searchrow input { flex: 1; min-width: 0; padding: 16px 18px; font-size: 18px; border-radius: 13px; border: 1.5px solid rgba(var(--amber-rgb),.35); background: rgba(var(--ink-rgb),.05); color: var(--ink); }
.modebar { display: flex; gap: 8px; margin-top: 12px; }
.modebtn { flex: none; padding: 9px 18px; font-size: 14.5px; border-radius: 999px; cursor: pointer; border: 1px solid rgba(var(--ink-rgb),.18); background: none; color: rgba(var(--ink-rgb),.6); }
.modebtn.on { border-color: rgba(var(--amber-rgb),.6); background: rgba(var(--amber-rgb),.14); color: var(--amber); font-weight: 600; }
.modebtn.hide { display: none; }
.kgrid { display: grid; grid-template-columns: 1fr; gap: 12px; padding-bottom: 8px; }
@media (min-width: 700px) { .kgrid { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); } }
.kcard { padding: 18px; border-radius: 13px; background: rgba(var(--ink-rgb),.045); border: 1px solid rgba(var(--ink-rgb),.1); cursor: pointer; display: flex; flex-direction: column; gap: 9px; }
.kcard:hover { border-color: rgba(var(--amber-rgb),.45); background: rgba(var(--amber-rgb),.06); }
.kcard .kt { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 18.5px; font-weight: 600; line-height: 1.45; word-break: break-word; }
.kcard .ks { font-size: 15px; color: rgba(var(--ink-rgb),.65); line-height: 1.65; word-break: break-word; }
.kcard .km { display: flex; gap: 10px; align-items: center; margin-top: auto; font-size: 13px; flex-wrap: wrap; }
/* ── 卡片詳頁 ── */
.cardtitle { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 28px; font-weight: 600; line-height: 1.4; word-break: break-word; }
.md { margin-top: 18px; }
.md p { font-size: 16.5px; line-height: 1.85; color: rgba(var(--ink-rgb),.82); margin: 8px 0; word-break: break-word; }
.md h1, .md h2, .md h3, .md h4 { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-weight: 600; color: var(--amber); margin: 18px 0 6px; line-height: 1.4; }
.md h1 { font-size: 22px; } .md h2 { font-size: 20px; } .md h3 { font-size: 18px; } .md h4 { font-size: 17px; }
.md ul, .md ol { margin: 8px 0; padding-left: 24px; }
.md li { font-size: 16.5px; line-height: 1.8; color: rgba(var(--ink-rgb),.82); margin: 4px 0; word-break: break-word; }
.md blockquote { margin: 10px 0; padding: 12px 18px; border-left: 3px solid rgba(var(--amber-rgb),.5); font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 17px; line-height: 1.8; color: rgba(var(--ink-rgb),.7); }
.md code { font-family: ui-monospace, Menlo, monospace; font-size: 14.5px; background: var(--well2); border-radius: 4px; padding: 1px 6px; word-break: break-all; }
.md pre { background: var(--well2); border: 1px solid rgba(var(--ink-rgb),.1); border-radius: 10px; padding: 14px; overflow-x: auto; }
.md pre code { background: none; padding: 0; }
.srcline { margin-top: 24px; display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-radius: 11px; background: rgba(var(--ink-rgb),.045); border: 1px solid rgba(var(--ink-rgb),.12); }
/* ── graph 模式(console 關聯視圖同款)── */
.graphbox { position: relative; height: 340px; border-radius: 14px; background: var(--well); border: 1px solid rgba(var(--ink-rgb),.1); overflow: hidden; }
.gnode { position: absolute; transform: translate(-50%,-50%); max-width: 96px; padding: 8px 11px; border-radius: 999px; text-align: center; font-size: 12.5px; line-height: 1.3; cursor: pointer; word-break: break-word; background: rgba(var(--ok-rgb),.1); border: 1px solid rgba(var(--ok-rgb),.35); color: var(--ok-soft); }
.gcenter { position: absolute; left: 50%; top: 50%; transform: translate(-50%,-50%); width: 104px; height: 104px; border-radius: 50%; background: radial-gradient(circle at 36% 30%,rgba(242,209,148,.95),rgba(232,180,90,.9) 55%,rgba(138,95,30,.95)); display: grid; place-items: center; text-align: center; padding: 8px; box-shadow: 0 0 30px rgba(var(--amber-rgb),.3); }
.gcenter span { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 13.5px; font-weight: 600; line-height: 1.35; color: #241804; word-break: break-word; }
.nbrow { display: flex; align-items: center; gap: 11px; padding: 12px 14px; border-radius: 10px; cursor: pointer; background: rgba(var(--ink-rgb),.045); border: 1px solid rgba(var(--ink-rgb),.1); font-size: 15.5px; }
/* ── 清單 / 設定 ── */
.listcol { display: flex; flex-direction: column; gap: 10px; padding: 16px 0 8px; }
.card-item { border-radius: 13px; background: rgba(var(--ink-rgb),.045); border: 1px solid rgba(var(--ink-rgb),.1); padding: 16px 18px; }
.setcol { display: flex; flex-direction: column; gap: 14px; padding: 20px 0 40px; }
.switch { flex: none; margin-left: auto; width: 56px; height: 32px; border-radius: 999px; padding: 3px; background: rgba(var(--ink-rgb),.15); transition: background .25s; }
.switch.on { background: rgba(var(--ok-rgb),.5); }
.switch i { display: block; width: 26px; height: 26px; border-radius: 50%; background: var(--knob); box-shadow: 0 1px 3px rgba(0,0,0,.25); transition: transform .25s; }
.switch.on i { transform: translateX(24px); }
.kvline { display: flex; justify-content: space-between; gap: 12px; font-size: 15px; margin: 5px 0; }
</style>
</head>
<body>
<!-- 登入(未登入只見這個殼;本頁不含任何資料)-->
<div class="authwrap view" id="v-login">
<div class="authbox">
<div>
<div class="brand">${brand}</div>
<div class="brand-sub">知識入口・Portal</div>
</div>
<div style="display:flex;flex-direction:column;gap:12px;text-align:left">
<input type="email" id="login-email" class="txt" placeholder="Email" autocomplete="username" style="padding:15px 16px;font-size:17px;border-radius:11px">
<input type="password" id="login-password" class="txt" placeholder="密碼" autocomplete="current-password" style="padding:15px 16px;font-size:17px;border-radius:11px">
<button class="btn" id="login-submit" style="margin-top:6px;padding:15px;font-size:17px;letter-spacing:.2em">登入</button>
<div id="login-status" class="err" style="font-size:14px;min-height:1.2em"></div>
</div>
<div style="font-size:13.5px;color:rgba(var(--ink-rgb),.4);line-height:1.7">帳號由管理員發放。忘記密碼請聯絡管理員重設。</div>
<button class="btn3 themelabel" data-themetoggle style="align-self:center;padding:8px 16px;font-size:13.5px;border-radius:999px">☾ 切深色</button>
</div>
</div>
<!-- 主應用 -->
<div id="shell">
<div id="sidenav">
<div class="logo">${brand}</div>
<div class="nav" data-nav="search">搜尋</div>
<div class="nav hide" id="nav-workflows" data-nav="workflows">工作流</div>
<div class="nav" data-nav="settings">設定</div>
<div class="nav themelabel" data-themetoggle style="margin-top:8px;font-size:14.5px">☾ 切深色</div>
<div class="foot" id="side-foot"></div>
</div>
<main>
<!-- 搜尋(落地頁)-->
<div class="view page" id="v-search">
<div class="pagehead"><span class="t">搜尋</span><span class="m" id="se-scope"></span></div>
<div style="padding-top:20px">
<div class="searchrow">
<input id="se-q" placeholder="搜尋知識…" autocomplete="off">
<button class="btn" id="se-go" style="flex:none;padding:0 22px;letter-spacing:.2em">搜</button>
</div>
<div class="modebar">
<button class="modebtn on" data-mode="keyword">關鍵字</button>
<button class="modebtn" data-mode="semantic">語意</button>
<button class="modebtn hide" id="mode-graph" data-mode="graph">圖譜</button>
</div>
<div id="se-banner"></div>
<div id="se-count" style="margin:20px 0 12px;font-size:14px;color:rgba(var(--ink-rgb),.5)"></div>
<div class="kgrid" id="se-results"></div>
<div id="se-graphout" style="display:none">
<div id="gr-box"></div>
<div id="gr-edges" style="margin-top:16px;display:flex;flex-direction:column;gap:7px"></div>
</div>
</div>
</div>
<!-- 卡片詳頁 -->
<div class="view page" id="v-card">
<div class="pagehead" style="justify-content:flex-start;gap:14px">
<button class="btn3" style="border:none;padding:0;font-size:16px;color:var(--amber)" data-nav="search"> 搜尋</button>
<span class="serif" style="font-size:17px;letter-spacing:.14em;color:rgba(var(--ink-rgb),.55)">知識卡片</span>
</div>
<div id="cd-main" style="padding:24px 0 40px"><div class="muted">載入中…</div></div>
</div>
<!-- 工作流(D-8:預設 admin 才看得到;唯讀,不開 trigger-->
<div class="view page narrow" id="v-workflows">
<div class="pagehead"><span class="t">工作流</span><span class="m">唯讀・系統狀態</span></div>
<div style="margin-top:16px;padding:13px 16px;border-radius:11px;background:rgba(var(--amber-rgb),.06);border:1px dashed rgba(var(--amber-rgb),.35);font-size:14px;line-height:1.7;color:rgba(var(--ink-rgb),.65)">
此頁只顯示系統裡的工作流與最近執行狀態,<b style="color:var(--ink)">不能觸發執行</b>(觸發屬系統擁有者權限)。
</div>
<div class="listcol" id="wf-list"><div class="muted">載入中…</div></div>
</div>
<!-- 設定 -->
<div class="view page narrow" id="v-settings">
<div class="pagehead"><span class="t">設定</span></div>
<div class="setcol">
<div class="panel">
<div style="font-size:17px;font-weight:600;margin-bottom:10px">我的帳號</div>
<div id="st-me"><div class="muted">載入中…</div></div>
</div>
<div class="panel">
<div style="display:flex;align-items:center;gap:14px">
<div style="min-width:0">
<div style="font-size:17px;font-weight:600">深色模式</div>
<div style="margin-top:4px;font-size:14px;line-height:1.65;color:rgba(var(--ink-rgb),.55)">預設淺色(紙感)。切換立即生效,選擇記在這台裝置。</div>
</div>
<div class="switch" id="st-theme-switch" data-themetoggle style="cursor:pointer"><i></i></div>
</div>
</div>
<div class="panel">
<div style="font-size:17px;font-weight:600">更改密碼</div>
<div style="margin-top:4px;font-size:14px;color:rgba(var(--ink-rgb),.55)">需輸入舊密碼驗證身分;新密碼至少 8 碼</div>
<div style="margin-top:14px;display:flex;flex-direction:column;gap:10px">
<input type="password" id="st-pw-old" class="txt" placeholder="舊密碼" autocomplete="current-password">
<input type="password" id="st-pw-new" class="txt" placeholder="新密碼(至少 8 碼)" autocomplete="new-password">
<input type="password" id="st-pw-new2" class="txt" placeholder="再輸入一次新密碼" autocomplete="new-password">
<button class="btn" id="st-pw-save">更新密碼</button>
<div id="st-pw-status" style="font-size:14px;min-height:1.2em"></div>
</div>
</div>
<button class="btn3" id="st-logout" style="padding:14px;font-size:16px;border-radius:11px">登出</button>
</div>
</div>
</main>
</div>
<!-- 手機底部導覽 -->
<div id="tabbar">
<div class="tab" data-nav="search">搜尋</div>
<div class="tab hide" id="tab-workflows" data-nav="workflows">工作流</div>
<div class="tab" data-nav="settings">設定</div>
</div>
<div id="toast"></div>
<script>
(function () {
'use strict';
// 台北時間 helperlib/taipei-time.ts 注入,console 同套——顯示不隨裝置時區漂移)
${TAIPEI_CLIENT_JS}
var $ = function (id) { return document.getElementById(id); };
// ── 狀態:只有 session token**沒有租戶字串、沒有 API key**design §3.3)──
var S = {
token: localStorage.getItem('arcrun_portal_session') || '',
profile: null, // /portal/session 回應(display_name/role/libraries/graph_allowed/workflows_visible
mode: 'keyword',
cardId: ''
};
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function toast(msg) {
var el = $('toast');
el.textContent = msg;
el.style.display = 'block';
clearTimeout(toast._t);
toast._t = setTimeout(function () { el.style.display = 'none'; }, 2000);
}
function friendlyErr(e) {
var m = e && e.message ? String(e.message) : String(e);
if (/failed to fetch|load failed|networkerror|network request failed/i.test(m)) return '連線中斷——請檢查網路後重試';
return m;
}
function authHeaders() { return S.token ? { 'Authorization': 'Bearer ' + S.token } : {}; }
// ── 主題(console 同款,key 獨立)──
function getTheme() { return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light'; }
function setTheme(t) {
document.documentElement.setAttribute('data-theme', t);
try { localStorage.setItem('arcrun_portal_theme', t); } catch (e) { /* 私密模式存不了 */ }
syncThemeUI();
}
function syncThemeUI() {
var dark = getTheme() === 'dark';
document.querySelectorAll('.themelabel').forEach(function (el) { el.textContent = dark ? '☀ 切淺色' : '☾ 切深色'; });
var sw = $('st-theme-switch');
if (sw) sw.classList.toggle('on', dark);
}
document.addEventListener('click', function (ev) {
var t = ev.target.closest('[data-themetoggle]');
if (t) setTheme(getTheme() === 'dark' ? 'light' : 'dark');
});
syncThemeUI();
// created_at 容錯(epoch 秒/毫秒/ISO),顯示一律台北時間(console 同款)
function parseAtMs(v) {
if (v == null || v === '') return null;
if (typeof v === 'number') return v < 1e12 ? v * 1000 : v;
if (/^\\d+$/.test(v)) { var n = Number(v); return n < 1e12 ? n * 1000 : n; }
var s = /T/.test(v) ? v : String(v).replace(' ', 'T') + 'Z';
var ms = Date.parse(s);
return isNaN(ms) ? null : ms;
}
function fmtDate(v) { var ms = parseAtMs(v); return ms == null ? '' : taipeiDateStr(ms); }
function fmtDateTime(v) { var ms = parseAtMs(v); return ms == null ? '' : taipeiDateTimeStr(ms); }
// ── 極簡 Markdown 渲染(先全 escape 再上格式,防 XSSconsole 同款)──
function mdInline(s) {
return s
.replace(/\\*\\*([^*]+)\\*\\*/g, '<b>$1</b>')
.replace(/(^|[^\\\\])\\x60([^\\x60]+)\\x60/g, '$1<code>$2</code>');
}
function mdRender(src) {
var lines = String(src || '').split(/\\r?\\n/);
var out = [], inCode = false, listMode = '';
function closeList() { if (listMode) { out.push(listMode === 'ul' ? '</ul>' : '</ol>'); listMode = ''; } }
for (var i = 0; i < lines.length; i++) {
var raw = lines[i];
if (/^\\s*\\x60\\x60\\x60/.test(raw)) {
closeList();
if (inCode) { out.push('</code></pre>'); inCode = false; }
else { out.push('<pre><code>'); inCode = true; }
continue;
}
if (inCode) { out.push(esc(raw) + '\\n'); continue; }
var line = esc(raw);
var h = raw.match(/^(#{1,4})\\s+(.*)$/);
if (h) { closeList(); out.push('<h' + h[1].length + '>' + mdInline(esc(h[2])) + '</h' + h[1].length + '>'); continue; }
if (/^\\s*&gt;\\s?/.test(line)) { closeList(); out.push('<blockquote>' + mdInline(line.replace(/^\\s*&gt;\\s?/, '')) + '</blockquote>'); continue; }
var li = raw.match(/^\\s*[-*+]\\s+(.*)$/);
if (li) { if (listMode !== 'ul') { closeList(); out.push('<ul>'); listMode = 'ul'; } out.push('<li>' + mdInline(esc(li[1])) + '</li>'); continue; }
var oli = raw.match(/^\\s*\\d+[.)]\\s+(.*)$/);
if (oli) { if (listMode !== 'ol') { closeList(); out.push('<ol>'); listMode = 'ol'; } out.push('<li>' + mdInline(esc(oli[1])) + '</li>'); continue; }
if (/^\\s*$/.test(raw)) { closeList(); continue; }
closeList();
out.push('<p>' + mdInline(line) + '</p>');
}
closeList();
if (inCode) out.push('</code></pre>');
return out.join('');
}
// ── entry 顯示 helperssource/library 從 metadata_json 取——server 已 enforce 過權限)──
function entryMeta(e) {
try { var m = JSON.parse(e.metadata_json || 'null'); return m && typeof m === 'object' ? m : {}; } catch (er) { return {}; }
}
function entrySource(e) { var m = entryMeta(e); return typeof m.source === 'string' ? m.source : ''; }
function entryLib(e) { var m = entryMeta(e); return (typeof m.library === 'string' && m.library) ? m.library : 'general'; }
function entryTitle(e) {
if (e.page_name) return e.page_name;
var first = String(e.content || '').split(/\\r?\\n/).find(function (l) { return l.trim(); }) || '';
first = first.replace(/^#+\\s*/, '').replace(/^[-*>]\\s*/, '').trim();
if (first.length > 60) first = first.slice(0, 60) + '…';
return first || '(無標題・' + (e.entry_type || 'entry') + '';
}
function entrySnippet(e) {
var lines = String(e.content || '').split(/\\r?\\n/).filter(function (l) { return l.trim(); });
var body = lines.slice(1).join(' ').replace(/\\s+/g, ' ').trim();
if (!body) body = lines.join(' ');
if (body.length > 140) body = body.slice(0, 140) + '…';
return body;
}
// ── 路由 ──
var VIEWS = ['search', 'card', 'workflows', 'settings'];
var HOME = 'search';
function allowedViews() {
// 工作流頁按 session 能力顯示(真閘在 server/portal/data/workflows 403/404
return (S.profile && S.profile.workflows_visible) ? VIEWS : VIEWS.filter(function (v) { return v !== 'workflows'; });
}
function currentRoute() {
var h = location.hash.replace(/^#\\/?/, '');
var seg = h.split('/');
var ok = allowedViews();
return { raw: seg[0] || '', view: ok.indexOf(seg[0]) >= 0 ? seg[0] : HOME, arg: seg.slice(1).join('/') };
}
function nav(view, arg) {
location.hash = '#/' + view + (arg ? '/' + encodeURIComponent(arg) : '');
}
function showView(name) {
VIEWS.forEach(function (v) { $('v-' + v).classList.toggle('on', v === name); });
var activeKey = name === 'card' ? 'search' : name;
document.querySelectorAll('#sidenav .nav, #tabbar .tab').forEach(function (el) {
el.classList.toggle('on', el.getAttribute('data-nav') === activeKey);
});
window.scrollTo(0, 0);
}
var LOADERS = { card: loadCard, workflows: loadWorkflows, settings: loadSettings };
function route() {
var r = currentRoute();
if (r.raw && r.raw !== r.view) { location.hash = '#/' + r.view; return; }
if (r.view === 'card' && r.arg) S.cardId = decodeURIComponent(r.arg);
showView(r.view);
if (LOADERS[r.view]) LOADERS[r.view]();
}
window.addEventListener('hashchange', route);
document.addEventListener('click', function (ev) {
var t = ev.target.closest('[data-nav]');
if (t) nav(t.getAttribute('data-nav'));
});
// ── 認證流 ──
function showAuth() {
$('v-login').classList.add('on');
$('shell').classList.remove('on');
$('tabbar').classList.remove('on');
}
function showApp() {
$('v-login').classList.remove('on');
$('shell').classList.add('on');
$('tabbar').classList.add('on');
var p = S.profile || {};
$('side-foot').innerHTML = esc(p.display_name || '') + '<br>' + esc(location.host);
$('nav-workflows').classList.toggle('hide', !p.workflows_visible);
$('tab-workflows').classList.toggle('hide', !p.workflows_visible);
$('mode-graph').classList.toggle('hide', !p.graph_allowed);
var libs = p.libraries || [];
$('se-scope').textContent = libs.indexOf('*') >= 0 ? '範圍:全部知識庫' : ('範圍:' + libs.join('・'));
if (!location.hash) location.hash = '#/' + HOME;
route();
}
function dropSession() {
S.token = '';
S.profile = null;
try { localStorage.removeItem('arcrun_portal_session'); } catch (e) { /* noop */ }
showAuth();
}
function boot() {
if (!S.token) { showAuth(); return; }
fetch('/portal/session', { headers: authHeaders() })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
.then(function (x) {
if (!x.ok) { dropSession(); return; }
S.profile = x.d;
showApp();
})
.catch(function () {
// 斷網時不清 session(誠實:連不上 ≠ session 失效),顯示登入殼並提示
showAuth();
$('login-status').textContent = '連線中斷——請檢查網路後重新整理';
});
}
$('login-submit').addEventListener('click', doLogin);
$('login-password').addEventListener('keydown', function (ev) { if (ev.key === 'Enter') doLogin(); });
function doLogin() {
var email = $('login-email').value.trim();
var password = $('login-password').value;
if (!email || !password) { $('login-status').textContent = '請輸入 Email 與密碼'; return; }
$('login-submit').disabled = true;
$('login-status').textContent = '';
fetch('/portal/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email, password: password })
})
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
.then(function (x) {
$('login-submit').disabled = false;
if (!x.ok) { $('login-status').textContent = x.d.error || '登入失敗'; return; }
S.token = x.d.session_token;
try { localStorage.setItem('arcrun_portal_session', S.token); } catch (e) { /* 私密模式只活本次 */ }
$('login-password').value = '';
boot();
})
.catch(function (e) { $('login-submit').disabled = false; $('login-status').textContent = friendlyErr(e); });
}
$('st-logout').addEventListener('click', function () {
fetch('/portal/logout', { method: 'POST', headers: authHeaders() }).catch(function () { /* 盡力而為 */ });
dropSession();
});
// 任何 data 請求收到 401 → session 失效 → 回登入殼
function guard401(status) {
if (status === 401) { dropSession(); return true; }
return false;
}
// ── 搜尋 ──
document.querySelectorAll('.modebtn').forEach(function (b) {
b.addEventListener('click', function () {
S.mode = b.getAttribute('data-mode');
document.querySelectorAll('.modebtn').forEach(function (x) { x.classList.toggle('on', x === b); });
if ($('se-q').value.trim()) doSearch();
});
});
$('se-go').addEventListener('click', doSearch);
$('se-q').addEventListener('keydown', function (ev) { if (ev.key === 'Enter') doSearch(); });
function doSearch() {
var q = $('se-q').value.trim();
if (!q) { $('se-count').textContent = '請輸入查詢字'; return; }
$('se-banner').innerHTML = '';
if (S.mode === 'graph') { doGraphSearch(q); return; }
$('se-graphout').style.display = 'none';
$('se-results').style.display = '';
$('se-count').textContent = '查詢中…';
$('se-results').innerHTML = '';
var url = '/portal/data/search?q=' + encodeURIComponent(q) + '&mode=' + (S.mode === 'semantic' ? 'semantic' : 'keyword');
fetch(url, { headers: authHeaders() })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
if (guard401(x.status)) return;
if (!x.ok) { $('se-count').innerHTML = '<span class="err">' + esc(x.d.error || ('查詢失敗(HTTP ' + x.status + '')) + '</span>'; return; }
var d = x.d;
if (S.mode === 'semantic' && d.mode === 'keyword') {
$('se-banner').innerHTML = '<div class="honest" style="margin-top:18px"><div class="h">語意搜尋尚未啟用</div><div class="b">語意搜尋用「意思」找資料,不是字面比對。<br>' + esc(d.capability_hint || '系統尚未開啟語意索引——不會假裝有語意結果,以下是關鍵字結果。') + '</div></div>';
}
var entries = d.entries || [];
$('se-count').textContent = '命中 ' + entries.length + ' 筆・模式 ' + (d.mode || 'keyword') + (d.note ? '・' + d.note : '');
if (!entries.length) {
$('se-results').innerHTML = '<div class="muted" style="padding:30px 10px;text-align:center;grid-column:1/-1">找不到「' + esc(q) + '」——換個關鍵字試試。</div>';
return;
}
$('se-results').innerHTML = entries.map(function (e) {
var src = entrySource(e);
return '<div class="kcard" data-card="' + esc(e.id) + '">' +
'<div class="kt">' + esc(entryTitle(e)) + '</div>' +
'<div class="ks">' + esc(entrySnippet(e)) + '</div>' +
'<div class="km">' +
'<span class="tag">' + esc(e.entry_type || 'entry') + '</span>' +
'<span class="tag green">' + esc(entryLib(e)) + '</span>' +
(src ? '<span class="dim" style="word-break:break-all">' + esc(src) + '</span>' : '') +
'<span class="dim mono" style="margin-left:auto">' + esc(fmtDate(e.created_at)) + '</span></div></div>';
}).join('');
})
.catch(function (e) { $('se-count').innerHTML = '<span class="err">請求失敗:' + esc(friendlyErr(e)) + '</span>'; });
}
$('se-results').addEventListener('click', function (ev) {
var t = ev.target.closest('[data-card]');
if (t) nav('card', t.getAttribute('data-card'));
});
// ── graph 模式(D-4 粗閘:無權者按鈕根本不顯示;就算 curl 直打 API 也是 403)──
function doGraphSearch(name) {
$('se-results').style.display = 'none';
$('se-graphout').style.display = '';
$('se-count').textContent = '查詢關聯中…';
renderGraphEmpty('查詢關聯中…');
fetch('/portal/data/graph/neighbors/' + encodeURIComponent(name), { headers: authHeaders() })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
if (guard401(x.status)) return;
if (!x.ok) { $('se-count').innerHTML = '<span class="err">' + esc(x.d.error || ('關聯查詢失敗(HTTP ' + x.status + '')) + '</span>'; renderGraphEmpty(x.d.error || '關聯查詢失敗'); return; }
var neighbors = x.d.neighbors || [];
var edges = x.d.edges || [];
$('se-count').textContent = '節點「' + name + '」・鄰居 ' + neighbors.length + '・關聯 ' + edges.length;
if (!neighbors.length) { renderGraphEmpty('尚無關聯資料——這個名字還沒有連進知識圖譜。'); return; }
renderGraph(name, neighbors.slice(0, 10));
$('gr-edges').innerHTML = edges.slice(0, 12).map(function (t) {
var other = t.subject === name ? t.object : t.subject;
return '<div class="nbrow" data-gnode="' + esc(other) + '">' +
'<span class="tag green" style="flex:none">' + esc(t.predicate || '關聯') + '</span>' +
'<span style="word-break:break-word">' + esc(other) + '</span>' +
'<span style="margin-left:auto;color:rgba(var(--amber-rgb),.6);flex:none"></span></div>';
}).join('');
})
.catch(function (e) { $('se-count').innerHTML = '<span class="err">關聯服務不可達:' + esc(friendlyErr(e)) + '</span>'; renderGraphEmpty('關聯服務不可達'); });
}
function renderGraphEmpty(msg) {
$('gr-box').innerHTML = '<div class="graphbox" style="display:grid;place-items:center;height:160px"><span class="muted" style="font-size:14px;padding:0 20px;text-align:center">' + esc(msg) + '</span></div>';
$('gr-edges').innerHTML = '';
}
function renderGraph(center, nodes) {
var n = nodes.length || 1;
var lines = '', chips = '';
for (var i = 0; i < n; i++) {
var a = (-90 + i * (360 / n)) * Math.PI / 180;
var x = 50 + 39 * Math.cos(a), y = 50 + 37 * Math.sin(a);
lines += '<line x1="50" y1="50" x2="' + x.toFixed(2) + '" y2="' + y.toFixed(2) + '" stroke="rgba(var(--ok-rgb),.3)" stroke-width="1" vector-effect="non-scaling-stroke" stroke-dasharray="4 4"/>';
chips += '<div class="gnode" style="left:' + x.toFixed(2) + '%;top:' + y.toFixed(2) + '%" data-gnode="' + esc(nodes[i]) + '">' + esc(nodes[i]) + '</div>';
}
var shortCenter = center.length > 16 ? center.slice(0, 16) + '…' : center;
$('gr-box').innerHTML = '<div class="graphbox">' +
'<svg viewBox="0 0 100 100" preserveAspectRatio="none" style="position:absolute;inset:0;width:100%;height:100%">' + lines + '</svg>' +
'<div class="gcenter"><span>' + esc(shortCenter) + '</span></div>' + chips +
'<div style="position:absolute;right:12px;bottom:10px;font-size:11.5px;color:rgba(var(--ink-rgb),.35)">╌ 知識圖譜鄰居(點節點展開)</div></div>';
}
document.addEventListener('click', function (ev) {
var t = ev.target.closest('[data-gnode]');
if (!t) return;
var name = t.getAttribute('data-gnode');
$('se-q').value = name;
doGraphSearch(name);
});
// ── 卡片詳頁 ──
function loadCard() {
if (!S.cardId) { $('cd-main').innerHTML = '<div class="muted">沒有指定卡片——從「搜尋」點一張進來。</div>'; return; }
$('cd-main').innerHTML = '<div class="muted">載入中…</div>';
fetch('/portal/data/entries/' + encodeURIComponent(S.cardId), { headers: authHeaders() })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
if (guard401(x.status)) return;
if (!x.ok || !x.d.entry) { $('cd-main').innerHTML = '<div class="err">' + esc(x.d.error || '讀不到這張卡片') + '</div>'; return; }
var e = x.d.entry;
var src = entrySource(e);
$('cd-main').innerHTML = '<div class="cardtitle">' + esc(entryTitle(e)) + '</div>' +
'<div style="display:flex;gap:10px;align-items:center;margin-top:12px;font-size:13.5px;flex-wrap:wrap">' +
'<span class="tag">' + esc(e.entry_type || 'entry') + '</span>' +
'<span class="tag green">' + esc(entryLib(e)) + '</span>' +
(e.page_name ? '<span class="tag dim">' + esc(e.page_name) + '</span>' : '') +
'<span class="muted mono">' + esc(fmtDateTime(e.created_at)) + '</span></div>' +
'<div class="md">' + mdRender(e.content || '(此條目沒有內文)') + '</div>' +
(src
? '<div class="srcline"><span style="font-size:13px;letter-spacing:.14em;color:rgba(var(--ink-rgb),.5);flex:none">來源回溯</span><span class="mono" style="font-size:14px;color:var(--amber);word-break:break-all">' + esc(src) + '</span></div>'
: '');
})
.catch(function (er) { $('cd-main').innerHTML = '<div class="err">請求失敗:' + esc(friendlyErr(er)) + '</div>'; });
}
// ── 工作流(唯讀)──
function loadWorkflows() {
$('wf-list').innerHTML = '<div class="muted">載入中…</div>';
fetch('/portal/data/workflows', { headers: authHeaders() })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
if (guard401(x.status)) return;
if (!x.ok) { $('wf-list').innerHTML = '<div class="err">' + esc(x.d.error || ('讀取失敗(HTTP ' + x.status + '')) + '</div>'; return; }
var wfs = x.d.workflows || [];
if (!wfs.length) { $('wf-list').innerHTML = '<div class="muted" style="padding:20px 4px">目前沒有任何工作流。</div>'; return; }
$('wf-list').innerHTML = wfs.map(function (w) {
var lastHtml = '<span class="dim">尚無執行紀錄</span>';
if (w.last_execution) {
var v = w.last_execution.verdict || '';
var cls = v === 'success' ? 'ok' : (v === 'failed' ? 'err' : 'muted');
lastHtml = '<span class="' + cls + '">' + esc(v || '(無 verdict') + '</span> <span class="dim mono">' + esc(fmtDateTime(Number(w.last_execution.timestamp))) + '</span>';
}
return '<div class="card-item">' +
'<div style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap">' +
'<span class="serif" style="font-size:17px;font-weight:600">' + esc(w.name) + '</span>' +
(w.cron_expr ? '<span class="tag dim mono">' + esc(w.cron_expr) + '</span>' : '') +
'<span style="margin-left:auto;font-size:13.5px">' + lastHtml + '</span></div>' +
(w.description ? '<div style="margin-top:8px;font-size:14.5px;line-height:1.65;color:rgba(var(--ink-rgb),.65)">' + esc(w.description) + '</div>' : '<div class="dim" style="margin-top:8px;font-size:13.5px">(沒有描述)</div>') +
'</div>';
}).join('');
})
.catch(function (e) { $('wf-list').innerHTML = '<div class="err">請求失敗:' + esc(friendlyErr(e)) + '</div>'; });
}
// ── 設定 ──
function loadSettings() {
var p = S.profile;
if (!p) { $('st-me').innerHTML = '<div class="muted">載入中…</div>'; return; }
var libs = p.libraries || [];
var libHtml = libs.indexOf('*') >= 0
? '<span class="tag green">全部知識庫</span>'
: (libs.length ? libs.map(function (l) { return '<span class="tag green">' + esc(l) + '</span>'; }).join(' ') : '<span class="tag dim">尚未授權任何庫</span>');
$('st-me').innerHTML =
'<div class="kvline"><span class="muted">名稱</span><span>' + esc(p.display_name || '') + '</span></div>' +
'<div class="kvline"><span class="muted">角色</span><span>' + esc(p.role === 'admin' ? '管理員' : '一般用戶') + '</span></div>' +
'<div class="kvline" style="align-items:flex-start"><span class="muted" style="flex:none">可查庫</span><span style="text-align:right;display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end">' + libHtml + '</span></div>';
}
$('st-pw-save').addEventListener('click', function () {
var oldPw = $('st-pw-old').value;
var newPw = $('st-pw-new').value;
var newPw2 = $('st-pw-new2').value;
var st = $('st-pw-status');
st.className = 'err';
if (!oldPw || !newPw) { st.textContent = '請填舊密碼與新密碼'; return; }
if (newPw.length < 8) { st.textContent = '新密碼至少 8 碼'; return; }
if (newPw !== newPw2) { st.textContent = '兩次輸入的新密碼不一致'; return; }
$('st-pw-save').disabled = true;
st.textContent = '';
fetch('/portal/me/password', {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
body: JSON.stringify({ current: oldPw, 'new': newPw })
})
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
$('st-pw-save').disabled = false;
if (guard401(x.status)) return;
if (!x.ok) { st.textContent = x.d.error || '更新失敗'; return; }
st.className = 'ok';
st.textContent = '密碼已更新';
$('st-pw-old').value = ''; $('st-pw-new').value = ''; $('st-pw-new2').value = '';
toast('密碼已更新');
})
.catch(function (e) { $('st-pw-save').disabled = false; st.textContent = friendlyErr(e); });
});
boot();
})();
</script>
</body>
</html>`;
}
// GET /portal — Portal HTML 殼(無 auth:回的是純殼,資料全在 /portal/data/* session 閘後)。
// 未 bootstrap 任何 portal_user 時登入必失敗(「尚未啟用」的誠實形態),不影響 console。
portalUiRouter.get('/portal', (c) => {
const brand = c.env.CONSOLE_BRAND || 'Arcrun';
return c.html(renderPortalHtml(brand));
});
+93 -14
View File
@@ -39,9 +39,14 @@ const LIBRARY_TEMPLATE = 'portal_library';
// ── 基礎 helpers ────────────────────────────────────────────────────────────
/** 帳號子 namespacedesign D-2)。tenant 預設沿 console-auth 同款 'leo'。 */
/** 租戶字串(=知識資料的 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 `${env.CONSOLE_TENANT || 'leo'}::portal`;
return `${portalTenant(env)}::portal`;
}
function sessionTtl(env: Bindings): number {
@@ -56,9 +61,9 @@ function bearerToken(c: Context<{ Bindings: Bindings }>): string | null {
}
/** KBDB 不可達/回錯時拋這個 → 各 route 統一 502 誠實回報(不假綠、不偽裝成 401)。 */
class KbdbError extends Error {}
export class KbdbError extends Error {}
async function kbdbFetch(env: Bindings, path: string, init?: RequestInit): Promise<Response> {
export async function kbdbFetch(env: Bindings, path: string, init?: RequestInit): Promise<Response> {
const { base, headers } = kbdbBase(env);
let res: Response;
try {
@@ -70,7 +75,7 @@ async function kbdbFetch(env: Bindings, path: string, init?: RequestInit): Promi
}
/** route handler 包一層:KbdbError → 502(誠實),其餘照拋。 */
async function run(c: Context<{ Bindings: Bindings }>, fn: () => Promise<Response>): Promise<Response> {
export async function run(c: Context<{ Bindings: Bindings }>, fn: () => Promise<Response>): Promise<Response> {
try {
return await fn();
} catch (e) {
@@ -81,7 +86,7 @@ async function run(c: Context<{ Bindings: Bindings }>, fn: () => Promise<Respons
// ── KBDB 資料層 helpers(全走 base HTTP API,零 SQL)────────────────────────────
interface PortalRecord {
export interface PortalRecord {
record_id: string;
template_id: string;
values: Record<string, string>;
@@ -98,6 +103,30 @@ export async function ensurePortalTemplates(
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;
}
@@ -155,7 +184,7 @@ async function patchRecordValues(env: Bindings, recordId: string, values: Record
return body.record;
}
async function listRecordsByTemplate(env: Bindings, template: string): Promise<PortalRecord[]> {
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}`);
@@ -213,7 +242,7 @@ async function createPortalUser(env: Bindings, input: CreateUserInput): Promise<
// ── user 值域 helpers ──────────────────────────────────────────────────────
function parseLibraries(raw: string | undefined): string[] {
export function parseLibraries(raw: string | undefined): string[] {
if (!raw) return [];
try {
const arr = JSON.parse(raw);
@@ -254,14 +283,14 @@ function toPublicUser(rec: PortalRecord) {
// ── session 閘 ────────────────────────────────────────────────────────────
type AuthedUser = { token: string; recordId: string; values: Record<string, string> };
type AuthResult = { ok: true; user: AuthedUser } | { ok: false; res: Response };
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,正確性不依賴它)。
*/
async function requirePortalUser(c: Context<{ Bindings: Bindings }>): Promise<AuthResult> {
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}`);
@@ -297,6 +326,40 @@ async function requirePortalAdmin(c: Context<{ Bindings: Bindings }>): Promise<A
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';
}
/**
* admin 操作目標 record 的成員資格驗證:record 的 email head entry(子 namespace 內)
* 必須指回同一 record_id——同時證明「是 portal_user」且「在本實例的 {tenant}::portal 下」,
@@ -402,16 +465,23 @@ portalRouter.post('/portal/logout', async (c) => {
// 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 ?? '',
role: v.role ?? 'user',
libraries: parseLibraries(v.libraries),
role,
libraries,
graph_allowed: await hasGraphAccess(c.env, libraries),
workflows_visible: workflowsVisible(c.env, role),
});
}),
);
@@ -597,6 +667,8 @@ function toPublicLibrary(rec: PortalRecord) {
display_name: v.display_name ?? '',
description: v.description ?? '',
status: v.status ?? '',
// D-4:此庫是否為知識圖譜萃取來源(graph 粗閘按這個判定;全都沒標 → 預設 general)
graph_source: (v.graph_source ?? '') === 'true',
};
}
@@ -669,8 +741,15 @@ portalRouter.patch('/portal/admin/libraries/:id', (c) =>
}
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' }, 400);
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) });
+4
View File
@@ -106,6 +106,10 @@ export type Bindings = {
// expirationTtl。未設 → 6048007 天,design §4.3——issue 要求短效,比 console 30 天緊)。
// 只影響新發的 session;權限/停用的即時性不靠 TTL(每請求回讀 user record)。
PORTAL_SESSION_TTL?: string;
// Portal 工作流頁可見性(portal-auth P3design D-8 定案,非機密):admin(預設)/ all / off。
// 路由層 enforce 在 /portal/data/workflows(無權 403、off 404),前端只照 /portal/session
// 的 workflows_visible 顯示或隱藏 nav 項。壞值退回 admin(不因 typo 意外全開)。
PORTAL_SHOW_WORKFLOWS?: string;
// kbdb-graph-plugin worker base URL(可選)。未設 → 用 WORKER_SUBDOMAIN 現算
// https://kbdb-graph-plugin.<subdomain>.workers.dev(該 repo wrangler.toml name 固定)。
// console 卡片詳頁「關聯視圖」經 cypher proxy 打它(kbdb-proxy.ts /kbdb/graph/neighbors/:name)。
@@ -264,12 +264,17 @@ describe('GET /portal/session', () => {
it('有效 session → 回 display_name/role/libraries,無租戶字串', async () => {
await seedPortalSession('tok-1', 'rec_1');
mockGetRecord('rec_1', activeUserValues());
// P3session 多回 graph_allowedD-4)——非 ["*"] 用戶要查庫目錄算 graph 來源庫
mockListByTemplate('portal_library', []);
const res = await json('GET', '/portal/session', undefined, { Authorization: 'Bearer tok-1' });
expect(res.status).toBe(200);
const data = (await res.json()) as Record<string, unknown>;
expect(data.valid).toBe(true);
expect(data.libraries).toEqual(['general', 'finance']);
expect('tenant' in data).toBe(false);
// P3 能力欄位:來源庫預設 general、本用戶有 general → graph 放行;workflows 預設 admin-only
expect(data.graph_allowed).toBe(true);
expect(data.workflows_visible).toBe(false);
});
it('帳號被停用 → 既有 session 立即失效(403)且 KV session 被清', async () => {
+357
View File
@@ -0,0 +1,357 @@
/**
* portal-auth P3 測試(design §1/§3.3/§3.4/§5/§6Gitea #24/#25
*
* 覆蓋(=tasks.md P3 測試項+#24 驗收 3 的 server-side 證明):
* 1. /portal HTML 殼:200、brand、**零租戶字串/零 X-Arcrun-API-Key/零 Mira 字樣**
* 2. /portal/data/search enforceserver 注入 owner_idlibrarycaller 自帶
* owner_id/library 參數被靜默覆蓋(filter 繞不過的機械證明);["*"]=不注 library
* 空集合=誠實空結果不打 KBDB
* 3. /portal/data/entries/:id 逐筆驗庫:越庫 404、跨租戶 404、不存在 404(同一句,
* 不洩存在性)、NULL library→general fallback
* 4. graph D-4 粗閘:無來源庫權限 403(不打 plugin);["*"]/有權 → 轉發
* 5. workflows D-8:非 admin 403admin 唯讀 list+最近執行、回應無 webhook_url
* workflowsVisible 單元(admin/all/off/壞值)
* 6. /portal/session 能力欄位:graph_allowed / workflows_visible
*
* KBDBgraph-plugin 都打 fetchMock 假 hostwrangler.test.toml KBDB_BASE_URL=
* https://kbdb.test、KBDB_GRAPH_URL=https://graph.test)+disableNetConnect——絕不外連。
*/
import { SELF, env, fetchMock } from 'cloudflare:test';
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
import { workflowsVisible } from '../src/routes/portal';
import { entryLibrary } from '../src/routes/portal-data';
import type { Bindings } from '../src/types';
const KBDB = 'https://kbdb.test';
const GRAPH = 'https://graph.test';
const TENANT = 'leo'; // wrangler.test.toml CONSOLE_TENANT(只在 server 側;下面驗它不出現在前端)
beforeAll(() => {
fetchMock.activate();
fetchMock.disableNetConnect();
});
afterEach(() => fetchMock.assertNoPendingInterceptors());
function get(path: string, headers: Record<string, string> = {}) {
return SELF.fetch(`http://localhost${path}`, { headers });
}
async function seedSession(token: string, recordId: string) {
await env.SESSIONS_KV.put(`portal_sess:${token}`, JSON.stringify({ record_id: recordId }));
}
function mockGetRecord(recordId: string, values: Record<string, string>) {
fetchMock
.get(KBDB)
.intercept({ path: `/records/${recordId}`, method: 'GET' })
.reply(200, { success: true, record: { record_id: recordId, template_id: 'tpl_pu', values } });
}
function mockLibraryList(records: { record_id: string; values: Record<string, string> }[]) {
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/by-template/portal_library'), method: 'GET' })
.reply(200, { success: true, records: records.map((r) => ({ ...r, template_id: 'tpl_pl' })), count: records.length });
}
function userValues(overrides: Record<string, string> = {}): Record<string, string> {
return {
email: 'user@example.com',
display_name: '測試同仁',
status: 'active',
role: 'user',
password_hash: 'pbkdf2-sha256$600000$AA$BB',
libraries: '["finance"]',
created_at: '2026-07-14T00:00:00.000Z',
updated_at: '2026-07-14T00:00:00.000Z',
...overrides,
};
}
/** 攔 KBDB /entries/search 並回收實際轉發的 queryenforce 的機械證據)。 */
function captureSearch(reply: unknown = { success: true, entries: [], count: 0, mode: 'keyword' }): { url: () => string } {
let captured = '';
fetchMock
.get(KBDB)
.intercept({
path: (p: string) => {
if (!p.startsWith('/entries/search?')) return false;
captured = p;
return true;
},
method: 'GET',
})
.reply(200, reply as Record<string, unknown>);
return { url: () => captured };
}
// ═══════════════ 1. /portal HTML 殼 ═══════════════
describe('GET /portalHTML 殼)', () => {
it('200brand 出現;**前端零租戶字串、零 X-Arcrun-API-Key、零 Mira**', async () => {
const res = await get('/portal');
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain('Arcrun Portal'); // CONSOLE_BRAND 未設 → Arcrun(引擎共用件不寫死產品名)
expect(html).toContain('/portal/data/search'); // 資料只走 enforce 面
// design §3.3 關鍵差異的機械斷言:前端不持租戶字串、不打 /kbdb/*
expect(html).not.toContain('X-Arcrun-API-Key');
expect(html).not.toMatch(/['"]leo['"]/); // 租戶字串值不得出現在頁面
expect(html).not.toContain('/kbdb/'); // 不直打 kbdb proxy(那要 API key=租戶字串)
expect(html).not.toContain('Mira'); // 零 Mira 字樣(tasks.md P3
expect(html).not.toContain('CONSOLE_TENANT');
});
});
// ═══════════════ 2. /portal/data/search enforce ═══════════════
describe('GET /portal/data/search', () => {
it('未登入 → 401,不碰 KBDB', async () => {
const res = await get('/portal/data/search?q=hello');
expect(res.status).toBe(401);
});
it('server 注入 owner_idlibrarycaller 自帶 owner_id/library 被靜默覆蓋(繞不過)', async () => {
await seedSession('tok-s1', 'rec_1');
mockGetRecord('rec_1', userValues()); // libraries=["finance"]
const cap = captureSearch();
// 攻擊嘗試:自帶 library=hr + owner_id=evil → 應完全被 server 值取代
const res = await get('/portal/data/search?q=報告&library=hr&owner_id=evil', {
Authorization: 'Bearer tok-s1',
});
expect(res.status).toBe(200);
const sent = new URLSearchParams(cap.url().split('?')[1]);
expect(sent.get('owner_id')).toBe(TENANT); // server 注入的租戶
expect(sent.get('library')).toBe('finance'); // server 注入的用戶庫集合
expect(cap.url()).not.toContain('hr'); // caller 的越權參數完全沒被轉發
expect(cap.url()).not.toContain('evil');
});
it('多庫用戶 → library=逗號集合;mode=semantic 透傳', async () => {
await seedSession('tok-s2', 'rec_2');
mockGetRecord('rec_2', userValues({ libraries: '["general","finance"]' }));
const cap = captureSearch({ success: true, entries: [], count: 0, mode: 'semantic' });
const res = await get('/portal/data/search?q=q1&mode=semantic', { Authorization: 'Bearer tok-s2' });
expect(res.status).toBe(200);
const sent = new URLSearchParams(cap.url().split('?')[1]);
expect(sent.get('library')).toBe('general,finance');
expect(sent.get('mode')).toBe('semantic');
});
it('["*"](全庫)→ 只注 owner_id、不注 librarydesign §3.3', async () => {
await seedSession('tok-s3', 'rec_3');
mockGetRecord('rec_3', userValues({ libraries: '["*"]', role: 'admin' }));
const cap = captureSearch();
const res = await get('/portal/data/search?q=q2', { Authorization: 'Bearer tok-s3' });
expect(res.status).toBe(200);
const sent = new URLSearchParams(cap.url().split('?')[1]);
expect(sent.get('owner_id')).toBe(TENANT);
expect(sent.has('library')).toBe(false);
});
it('庫集合為空 → 誠實空結果,不打 KBDB search', async () => {
await seedSession('tok-s4', 'rec_4');
mockGetRecord('rec_4', userValues({ libraries: '[]' }));
const res = await get('/portal/data/search?q=q3', { Authorization: 'Bearer tok-s4' });
expect(res.status).toBe(200);
const data = (await res.json()) as { entries: unknown[]; note?: string };
expect(data.entries).toEqual([]);
expect(data.note).toContain('尚未被授權'); // 無 pending interceptor=真沒打 KBDB
});
});
// ═══════════════ 3. /portal/data/entries/:id 逐筆驗庫 ═══════════════
function mockGetEntry(id: string, entry: Record<string, unknown> | null) {
fetchMock
.get(KBDB)
.intercept({ path: `/entries/${id}`, method: 'GET' })
.reply(entry ? 200 : 404, entry ? { success: true, entry } : { success: false, error: 'not found' });
}
describe('GET /portal/data/entries/:id(逐筆驗庫)', () => {
it('越庫 id 直讀(hr entry、用戶只有 finance)→ 404', async () => {
await seedSession('tok-e1', 'rec_1');
mockGetRecord('rec_1', userValues());
mockGetEntry('e_hr', { id: 'e_hr', owner_id: TENANT, metadata_json: '{"library":"hr"}', content: '機密' });
const res = await get('/portal/data/entries/e_hr', { Authorization: 'Bearer tok-e1' });
expect(res.status).toBe(404);
const data = (await res.json()) as { error: string };
expect(data.error).toBe('找不到這筆資料'); // 與不存在同一句(不洩存在性)
expect(JSON.stringify(data)).not.toContain('hr'); // 不洩庫名
});
it('有權庫(finance)→ 200 回 entry', async () => {
await seedSession('tok-e2', 'rec_1');
mockGetRecord('rec_1', userValues());
mockGetEntry('e_fin', { id: 'e_fin', owner_id: TENANT, metadata_json: '{"library":"finance","source":"logseq://x.md"}', content: '財務' });
const res = await get('/portal/data/entries/e_fin', { Authorization: 'Bearer tok-e2' });
expect(res.status).toBe(200);
const data = (await res.json()) as { entry: { id: string } };
expect(data.entry.id).toBe('e_fin');
});
it('跨租戶 entryowner_id 不是本實例租戶)→ 404 同一句', async () => {
await seedSession('tok-e3', 'rec_1');
mockGetRecord('rec_1', userValues({ libraries: '["*"]' })); // 就算全庫也擋跨租戶
mockGetEntry('e_other', { id: 'e_other', owner_id: 'other-tenant', metadata_json: '{"library":"finance"}' });
const res = await get('/portal/data/entries/e_other', { Authorization: 'Bearer tok-e3' });
expect(res.status).toBe(404);
expect(((await res.json()) as { error: string }).error).toBe('找不到這筆資料');
});
it('不存在的 id → 404 同一句', async () => {
await seedSession('tok-e4', 'rec_1');
mockGetRecord('rec_1', userValues());
mockGetEntry('e_ghost', null);
const res = await get('/portal/data/entries/e_ghost', { Authorization: 'Bearer tok-e4' });
expect(res.status).toBe(404);
expect(((await res.json()) as { error: string }).error).toBe('找不到這筆資料');
});
it('未標記 libraryNULL metadata)→ 歸 general:有 general 者 200、無者 404', async () => {
await seedSession('tok-e5', 'rec_5');
mockGetRecord('rec_5', userValues({ libraries: '["general"]' }));
mockGetEntry('e_old', { id: 'e_old', owner_id: TENANT, metadata_json: null, content: '舊資料' });
const ok = await get('/portal/data/entries/e_old', { Authorization: 'Bearer tok-e5' });
expect(ok.status).toBe(200);
await seedSession('tok-e6', 'rec_6');
mockGetRecord('rec_6', userValues({ libraries: '["finance"]' })); // 沒 general
mockGetEntry('e_old', { id: 'e_old', owner_id: TENANT, metadata_json: null, content: '舊資料' });
const no = await get('/portal/data/entries/e_old', { Authorization: 'Bearer tok-e6' });
expect(no.status).toBe(404);
});
it('entryLibrary 單元:壞 metadata/缺欄位 → general;有 library → 原值', () => {
expect(entryLibrary({ metadata_json: null })).toBe('general');
expect(entryLibrary({ metadata_json: 'not-json{{' })).toBe('general');
expect(entryLibrary({ metadata_json: '{"source":"x"}' })).toBe('general');
expect(entryLibrary({ metadata_json: '{"library":""}' })).toBe('general');
expect(entryLibrary({ metadata_json: '{"library":"hr"}' })).toBe('hr');
});
});
// ═══════════════ 4. graph D-4 粗閘 ═══════════════
describe('GET /portal/data/graph/neighbors/:nameD-4 粗閘)', () => {
it('無 graph 來源庫權限(來源庫預設 general、用戶只有 finance)→ 403,不打 plugin', async () => {
await seedSession('tok-g1', 'rec_1');
mockGetRecord('rec_1', userValues()); // finance only
mockLibraryList([]); // 沒有任何庫標 graph_source → 來源預設 ['general']
const res = await get('/portal/data/graph/neighbors/某節點', { Authorization: 'Bearer tok-g1' });
expect(res.status).toBe(403);
// 無 pending interceptorafterEach 驗)=graph plugin 完全沒被打
});
it('["*"] 全庫 → 放行並轉發 plugin(不需查庫目錄)', async () => {
await seedSession('tok-g2', 'rec_2');
mockGetRecord('rec_2', userValues({ libraries: '["*"]', role: 'admin' }));
fetchMock
.get(GRAPH)
.intercept({ path: (p: string) => p.startsWith('/graph/neighbors/'), method: 'GET' })
.reply(200, { node: 'n', edges: [], neighbors: [], edgeCount: 0, neighborCount: 0 });
const res = await get('/portal/data/graph/neighbors/n', { Authorization: 'Bearer tok-g2' });
expect(res.status).toBe(200);
});
it('庫目錄標 finance 為 graph_source → finance 用戶放行', async () => {
await seedSession('tok-g3', 'rec_1');
mockGetRecord('rec_1', userValues()); // finance
mockLibraryList([
{ record_id: 'lib_fin', values: { name: 'finance', status: 'active', graph_source: 'true' } },
]);
fetchMock
.get(GRAPH)
.intercept({ path: (p: string) => p.startsWith('/graph/neighbors/'), method: 'GET' })
.reply(200, { node: 'n', edges: [], neighbors: [] });
const res = await get('/portal/data/graph/neighbors/n', { Authorization: 'Bearer tok-g3' });
expect(res.status).toBe(200);
});
it('停用的 graph_source 庫不算來源(disabled 排除 → 回到預設 general → finance 用戶 403', async () => {
await seedSession('tok-g4', 'rec_1');
mockGetRecord('rec_1', userValues());
mockLibraryList([
{ record_id: 'lib_fin', values: { name: 'finance', status: 'disabled', graph_source: 'true' } },
]);
const res = await get('/portal/data/graph/neighbors/n', { Authorization: 'Bearer tok-g4' });
expect(res.status).toBe(403);
});
});
// ═══════════════ 5. workflows D-8 ═══════════════
describe('GET /portal/data/workflowsD-8admin 唯讀)', () => {
it('非 admin(預設 PORTAL_SHOW_WORKFLOWS=admin)→ 403', async () => {
await seedSession('tok-w1', 'rec_1');
mockGetRecord('rec_1', userValues({ role: 'user' }));
const res = await get('/portal/data/workflows', { Authorization: 'Bearer tok-w1' });
expect(res.status).toBe(403);
});
it('admin → 200 唯讀 list+最近執行;**回應無 webhook_urltrigger 把手**', async () => {
await seedSession('tok-w2', 'rec_a');
mockGetRecord('rec_a', userValues({ role: 'admin', libraries: '["*"]' }));
await env.WEBHOOKS.put(
`${TENANT}:wf:daily_report`,
JSON.stringify({ description: '每日彙整', created_at: '2026-07-14T00:00:00Z', cron_expr: '0 9 * * *' }),
);
await env.ANALYTICS_KV.put('stats:daily_report:1783500000000', JSON.stringify({ verdict: 'success' }));
await env.ANALYTICS_KV.put('stats:daily_report:1783400000000', JSON.stringify({ verdict: 'failed' }));
const res = await get('/portal/data/workflows', { Authorization: 'Bearer tok-w2' });
expect(res.status).toBe(200);
const data = (await res.json()) as {
workflows: { name: string; description: string; last_execution: { verdict?: string; timestamp: string } | null }[];
read_only: boolean;
};
expect(data.read_only).toBe(true);
const wf = data.workflows.find((w) => w.name === 'daily_report');
expect(wf).toBeTruthy();
expect(wf!.description).toBe('每日彙整');
expect(wf!.last_execution?.verdict).toBe('success'); // 取到「最新」那筆(timestamp 較大者)
expect(JSON.stringify(data)).not.toContain('webhook_url');
expect(JSON.stringify(data)).not.toContain('/trigger');
// 清場(KV 是 suite 共用實例,避免污染其他測試)
await env.WEBHOOKS.delete(`${TENANT}:wf:daily_report`);
await env.ANALYTICS_KV.delete('stats:daily_report:1783500000000');
await env.ANALYTICS_KV.delete('stats:daily_report:1783400000000');
});
it('workflowsVisible 單元:admin(預設/壞值)/ all / off', () => {
const mk = (v?: string) => ({ PORTAL_SHOW_WORKFLOWS: v }) as unknown as Bindings;
expect(workflowsVisible(mk(undefined), 'admin')).toBe(true);
expect(workflowsVisible(mk(undefined), 'user')).toBe(false);
expect(workflowsVisible(mk('all'), 'user')).toBe(true);
expect(workflowsVisible(mk('off'), 'admin')).toBe(false);
expect(workflowsVisible(mk('typo!!'), 'user')).toBe(false); // 壞值退回 admin-only,不意外全開
expect(workflowsVisible(mk('typo!!'), 'admin')).toBe(true);
});
});
// ═══════════════ 6. /portal/session 能力欄位 ═══════════════
describe('GET /portal/sessionP3 能力欄位)', () => {
it('一般 userfinance,無 graph 來源權限)→ graph_allowed=false、workflows_visible=false;仍無租戶字串', async () => {
await seedSession('tok-p1', 'rec_1');
mockGetRecord('rec_1', userValues());
mockLibraryList([]);
const res = await get('/portal/session', { Authorization: 'Bearer tok-p1' });
expect(res.status).toBe(200);
const data = (await res.json()) as Record<string, unknown>;
expect(data.graph_allowed).toBe(false);
expect(data.workflows_visible).toBe(false);
expect('tenant' in data).toBe(false);
expect(JSON.stringify(data)).not.toContain('"leo"');
});
it('admin ["*"] → graph_allowed=true(免查庫目錄)、workflows_visible=true', async () => {
await seedSession('tok-p2', 'rec_a');
mockGetRecord('rec_a', userValues({ role: 'admin', libraries: '["*"]' }));
const res = await get('/portal/session', { Authorization: 'Bearer tok-p2' });
expect(res.status).toBe(200);
const data = (await res.json()) as Record<string, unknown>;
expect(data.graph_allowed).toBe(true);
expect(data.workflows_visible).toBe(true);
});
});
+7
View File
@@ -29,6 +29,11 @@ id = "test-recipes"
binding = "SESSIONS_KV"
id = "test-sessions-kv"
# portal-auth P3/portal/data/workflows 讀最近執行(stats:{name}:{ts}
[[kv_namespaces]]
binding = "ANALYTICS_KV"
id = "test-analytics-kv"
# credential-store-migration T8/T9 測試用 D1 mockMiniflare 本地 SQLite,非真實 leo21c D1
# schema 由 tests/setup.ts 在測試啟動時建表,不用 migrations_dir——0002_credentials.sql 就一張表,
# 直接 exec 比接 migrations 機制簡單)
@@ -43,3 +48,5 @@ ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd
# 分流台勾掉 route 測試:KBDB 指到假 hostfetchMock 攔截,絕不外連——尤其不打官方 uncle6 fallback
KBDB_BASE_URL = "https://kbdb.test"
CONSOLE_TENANT = "leo"
# portal-auth P3graph 粗閘放行後的轉發目標也指假 host(fetchMock 攔截,絕不外連)
KBDB_GRAPH_URL = "https://graph.test"
+4
View File
@@ -142,6 +142,10 @@ CONSOLE_TENANT = "leo"
# issue 要求比 console 30 天短效。停用帳號的即時性不靠這個 TTL(每請求回讀 user record)。
PORTAL_SESSION_TTL = "604800"
# Portal 工作流頁可見性(portal-auth P3design D-8 定案):admin(預設)/ all / off。
# 唯讀 list+最近執行、不開 triggerenforce 在 /portal/data/workflows 路由層。
PORTAL_SHOW_WORKFLOWS = "admin"
# 駕駛艙「等你的事」活資料源(fix/console-dashboard-live-data2026-07-07):
# InkStoneCo sprint 檔「## 等 leo 清單」(progress-guard 每日維護)——KBDB dash_wait 沒有
# 活的維護管線(07-04 之後沒人寫、銷案不同步),不再當首選資料源。
+29 -7
View File
@@ -30,9 +30,10 @@
3. 驗收抽查:semantic 帶 `library=` 過濾命中/不帶行為不變
4. 掛號:`cli/src/lib/deploy.ts` `ensureVectorizeMetadataIndexes()` 待補 `library`cli/ 屬 P1
派工範圍外「只動 kbdb」,隨 P3 或部署 PR 補——kbdb/wrangler.toml 註解已標)
- [ ] cypher `kbdb-proxy` 透傳 `library` 參數(供 owner/admin 面用;portal 面走 P3 的注入,不經這)
- [x] cypher `kbdb-proxy` 透傳 `library` 參數(供 owner/admin 面用;portal 面走 P3 的注入,不經這)
- ⚠️ 範圍註(2026-07-14):P1 派工紅線「只動 kbdb、不碰 cypher-executor」與本項矛盾——照窄範圍
執行,本項順延(一行 query 透傳,隨 P3 動 cypher 時一併)。
- ✅ 已隨 P3 補上(2026-07-14 P3 PR):`/kbdb/search``/kbdb/entries` 透傳白名單各加 `library`
- [x] 測試:D1 filter 單元測、NULL fallback、多值、semantic filtermock VECTORIZE
- `kbdb/tests/library-filter.test.ts` 11 項(SQL 形狀/route 解析/向後相容/降級仍 enforce/$in 構造/
寫入端正規化)+既有 6 項全綠(17/17);tsc exit 0。
@@ -69,13 +70,34 @@
## P3 — `/portal` UI:登入殼+搜尋頁+設定頁+scope enforcedesign §1/§3.3/§5/§6)|觸碰:`cypher-executor/`
- [ ] `/portal` HTML 殼(重用 console 樣式/搜尋 view 抽共用 helper`CONSOLE_BRAND` 品牌;零 Mira 字樣)
- [ ] 未登入只見登入殼;登入後兩頁:搜尋(keyword/semantic/graph 三模式+source 溯源+卡片詳頁)+設定(改密碼/看自己權限/主題)
- [ ] `/portal/data/*` server-side enforcesession→record→注入 `owner_id``library`**前端絕不下發租戶字串**
- [ ] 卡片詳頁逐筆驗 library(越庫 id 直讀 → 404
- [ ] graph 粗閘(D-4:無 graph 來源庫權限 → 模式不顯示+API 403
- [ ] 測試:curl 帶 user session 直打 data API 驗 filter 繞不過(=#24 驗收 3 的 server-side 證明)
- [x] `/portal` HTML 殼(重用 console 樣式/搜尋 view 抽共用 helper`CONSOLE_BRAND` 品牌;零 Mira 字樣)
- 實作註(2026-07-14):`routes/portal-ui.ts` 單檔 HTML+原生 JSconsole 同形態)。**UI 重用取捨=
複製 console 樣式子集而非抽共用模組**——抽共用要動 console.ts(違「console 現狀不動」鐵律),
代價=樣式雙份、改版要兩邊同步(PR 說明誠實記)。零 Mira 字樣由測試機械斷言。
- [x] 未登入只見登入殼;登入後兩頁:搜尋(keyword/semantic/graph 三模式+source 溯源+卡片詳頁)+設定(改密碼/看自己權限/主題
- graph 模式按 session `graph_allowed` 顯示(D-4);工作流頁 admin 額外可見(D-8,
`PORTAL_SHOW_WORKFLOWS` 預設 admin,唯讀不開 trigger)——前端藏只是 UX,真閘在路由層。
- [x] `/portal/data/*` server-side enforcesession→record→注入 `owner_id``library`**前端絕不下發租戶字串**
- `routes/portal-data.ts`(安全核心):caller 自帶 owner_id/library 一律被 server 值靜默覆蓋
e2e 攻擊實測);`["*"]`=不注 library;HTML 殼經測試機械斷言零租戶字串/零
X-Arcrun-API-Key/不打 /kbdb/*。
- [x] 卡片詳頁逐筆驗 library(越庫 id 直讀 → 404
- 雙重驗證:owner_id=CONSOLE_TENANT(防拿跨租戶 id 直讀)+library∈用戶集合(NULL→general);
越庫/跨租戶/不存在**同一句 404**(不洩存在性,e2e 逐字比對)。
- [x] graph 粗閘(D-4:無 graph 來源庫權限 → 模式不顯示+API 403)
- 來源庫=portal_library `graph_source='true'` 集合(seed 補 slotensurePortalTemplates 冪等
grow 舊 template 缺的 slotsadmin libraries PATCH 可標記/取消),**沒有任何標記→預設
['general']**D-4 定案);無權 403、前端模式鈕不顯示;停用庫不算來源。
- [x] 測試:curl 帶 user session 直打 data API 驗 filter 繞不過(=#24 驗收 3 的 server-side 證明)
- 單元:`cypher-executor/tests/portal-data.test.ts` 21 項(fetchMock 假 host,絕不外連);
cypher 全套 154/155(唯一失敗=executor「不存在的零件」pre-existingstash 複驗);
kbdb 20/20;兩包 tsc exit 0。端到端:本機隔離雙 worker9790/9791demo 8787/8788 不碰)
**47/47 全綠**——含越庫 id 直讀 404、攻擊參數被覆蓋、graph 403→授 general 後放行、
admin 全庫+跨租戶不可見、權限 PATCH 即時生效、HTML 殼斷言+inline JS node --check、
改密碼全鏈。`wrangler deploy --dry-run` 打包過。
- **驗收**=#24 驗收):leo21c `/portal` 可開;未登入只見登入殼;三模式+溯源可用;A 用戶(僅 general)搜不到 finance 內容——UI 與 curl 雙驗
- 本機端到端已全數驗過(上項證據);**leo21c 線上面=部署後驗**gated wrangler 直推,B 類流程;
semantic 線上路徑仍依 P1 部署清單的 Vectorize indexreindex)。
- **工程量**:大(1–1.5 個 CC 工作天,UI 是最大件)
## P4 — admin 頁+工作流顯示(design §6)|觸碰:`cypher-executor/`
+34 -1
View File
@@ -3,7 +3,7 @@ name: status
description: 當前進度、進行中 Phase、已知問題、下一步(動態文件,每 session 更新)
metadata:
type: project
last_updated: 2026-07-02
last_updated: 2026-07-14
---
# 當前進度(動態)
@@ -15,6 +15,39 @@ metadata:
## 📍 當前位置
> **2026-07-14 本 sessionportal-auth P3Portal UI+查詢 enforce——本 SDD 最大件,PR 待總管審不 merge)**:
> - **實作(分支 `portal-auth-p3-portal-ui`**:① `routes/portal-data.ts`(安全核心)——`/portal/data/*`
> server-side enforcesession→回讀 user record→server 注入 `owner_id=CONSOLE_TENANT``library=<集合>`
> 轉發 KBDB;**前端絕不下發租戶字串**vs console 把 tenant 交給前端直打 /kbdb/*);caller 自帶
> owner_id/library 被靜默覆蓋;entries/:id 逐筆驗租戶+庫(越庫/跨租戶/不存在同一句 404 不洩存在性);
> graph D-4 粗閘(來源庫=portal_library `graph_source` 標記、無標記預設 general;無權 403);workflows
> D-8`PORTAL_SHOW_WORKFLOWS` 預設 admin;唯讀+最近執行,無 webhook_url/trigger 把手)。
> ② `routes/portal-ui.ts`——`/portal` 單檔 HTML 殼(登入/搜尋三模式+source 溯源+卡片詳頁/設定改
> 密碼看權限主題;graph 模式與工作流頁按 `/portal/session` 新能力欄位 `graph_allowed`/`workflows_visible`
> 顯示,真閘在路由層)。③ P1 順延項補上:kbdb-proxy `/kbdb/search``/kbdb/entries` 透傳 `library`。
> ④ portal_library seed 加 `graph_source` slotensurePortalTemplates 冪等 grow 舊 template 缺 slot
> admin libraries PATCH 可標記。
> - **UI 重用取捨**:複製 console 樣式子集(紙感/明體/琥珀/深淺主題)而非抽共用模組——抽共用要動
> console.ts(違「console 現狀不動」鐵律);代價=樣式雙份、改版要兩邊同步(誠實記)。
> - **驗證**cypher 154/155(唯一失敗=executor「不存在的零件」pre-existingstash 複驗)+新增
> `tests/portal-data.test.ts` 21 項;kbdb 20/20;兩包 tsc 0`wrangler deploy --dry-run` 打包過。
> **端到端隔離雙 worker9790/9791demo 8787/8788 不碰)47/47 全綠**:越庫 id 直讀 404(含跨租戶
> id)、攻擊參數 `library=hr&owner_id=other-tenant` 被 server 覆蓋、graph 無權 403→授 general 後放行、
> admin 全庫 3 筆+跨租戶第 4 筆不可見、權限 PATCH 即時生效(同一 session)、HTML 殼零租戶字串/零
> X-Arcrun-API-Key/零 Mirainline JS node --check、semantic 誠實降級仍 enforce、改密碼全鏈。
> - **不 merge 待總管審**B 類流程);部署後 leo21c 線上驗(semantic 線上路徑仍依 P1 部署清單
> Vectorize `library` indexreindex backfill)。下一棒 P4admin 帳號管理頁)。
> - **附帶**P2 舊 session 測試補 P3 能力欄位 mockwrangler.test.toml 補 ANALYTICS_KVKBDB_GRAPH_URL
> 假 host。本 session 同時補記 #47-#51 五件的 wiki 斷層(見下段)。
>
> **2026-07-13/14 補記(RAG Portal 線五連發——前五個改動當時漏寫 wiki,leo 點破接關斷層,此段補上)**:
> - **PR #47**commit 2e1005e):console rebrand Mira→Arcrun`CONSOLE_BRAND` var 可覆蓋品牌字樣(Gitea #21)——console 是引擎共用件不寫死產品名。
> - **PR #48**commit cbebb36):`CONSOLE_PROFILE=rag` 頁面裁剪(企業產品樣:搜尋落地、只留搜尋/工作流/設定;未設=full,Mira 實例零影響)——是 config 裁剪不是 auth。
> - **PR #49**commit 18c0846):**portal-auth SDD 立卷**`system-dev/docs/3-specs/portal-auth/`Gitea #24 Portal 拆分+#25 多用戶登入);純設計文件。總管審過+leo 裁 D-4=Agraph 粗閘)/D-8=admin(工作流頁)。
> - **PR #50**commit 7f40964):portal-auth **P1**——KBDB「庫」filter 地基(`metadata_json.$.library`D1 COALESCE→general fallbackVectorize `$in` 主路徑、寫入端正規化;kbdb 17/17 測試綠)。
> - **PR #51**commit 1260d8c):portal-auth **P2**——portal_user 模型+認證 APIKBDB 萬用表零新表、`{tenant}::portal` 子 namespace、PBKDF2-SHA256 600k、bootstrap 走 console owner session、admin CRUD+節流;cypher 20 項單元+雙 worker e2e 33/33)。
> - 以上均**不 merge 待總管審**的 PR 正規流程(#47/#48 已 merge 進 main#49/#50/#51 已 merge——見 git log——部署另走 gated leo21c wrangler 直推)。下一棒=P3 Portal UI+查詢 enforce(本 SDD 最大件)、P4 admin 頁。
> **2026-07-04 本 sessionMira Console 完整版 UIArcrun#3 console 系,總管派工)**
> - **實作(commit f9e44ab**`/console` 依 claude design 定稿(紙感暖黑「2a」,`system-dev/docs/6-user/Mira Console 設計規劃/` 於頂層 repo)+ brief 8 頁資訊架構全面重寫成單檔 SPA(hash routing、零外部資源、手機優先+≥1024px 側欄+底部五 tab)。`/console/dashboard` 免登入獨立頁同步換視覺(呼吸球「安/趕/滯」)。
> - **後端小補(全唯讀/薄殼)**:① `GET /credentials/catalog`D1 目錄 metadata list,絕不回密文,D19)② `GET /console/inbox-data`inbox 清單,session 鎖——訊息原文屬機敏,計數才免登入)③ `GET /kbdb/graph/neighbors/:name`cypher 代轉 kbdb-graph-plugintoken 只在 server 側;plugin base = `KBDB_GRAPH_URL` 或 `kbdb-graph-plugin.<subdomain>.workers.dev` 現算)④ types 加 `KBDB_GRAPH_URL?`。