From d4611950c02eaf48dc12cddaa7d34d7a72f6d403 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Tue, 14 Jul 2026 12:28:15 +0800 Subject: [PATCH 1/5] =?UTF-8?q?wiki:=20=E8=A3=9C=E8=A8=98=20#47-#51=20?= =?UTF-8?q?=E4=BA=94=E4=BB=B6=EF=BC=88rebrand/profile/portal-auth=20SDD/P1?= =?UTF-8?q?/P2=EF=BC=89=E2=80=94=E2=80=94=E6=8E=A5=E9=97=9C=E6=96=B7?= =?UTF-8?q?=E5=B1=A4=E8=A3=9C=E9=BD=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- system-dev/wiki/status.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/system-dev/wiki/status.md b/system-dev/wiki/status.md index 7f214eb..19f72bf 100644 --- a/system-dev/wiki/status.md +++ b/system-dev/wiki/status.md @@ -15,6 +15,14 @@ metadata: ## 📍 當前位置 +> **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=A(graph 粗閘)/D-8=admin(工作流頁)。 +> - **PR #50**(commit 7f40964):portal-auth **P1**——KBDB「庫」filter 地基(`metadata_json.$.library`,D1 COALESCE→general fallback+Vectorize `$in` 主路徑、寫入端正規化;kbdb 17/17 測試綠)。 +> - **PR #51**(commit 1260d8c):portal-auth **P2**——portal_user 模型+認證 API(KBDB 萬用表零新表、`{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 本 session(Mira Console 完整版 UI,Arcrun#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-plugin,token 只在 server 側;plugin base = `KBDB_GRAPH_URL` 或 `kbdb-graph-plugin..workers.dev` 現算)④ types 加 `KBDB_GRAPH_URL?`。 -- 2.52.0 From 5668d48d3718a9604c2eae31010c76ba28a33ee3 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Tue, 14 Jul 2026 12:59:41 +0800 Subject: [PATCH 2/5] =?UTF-8?q?portal-auth=20P3=20=E5=89=8D=E7=BD=AE?= =?UTF-8?q?=EF=BC=9Agraph=20=E7=B2=97=E9=96=98=20helpers=EF=BC=88D-4?= =?UTF-8?q?=EF=BC=89=EF=BC=8Bsession=20=E8=83=BD=E5=8A=9B=E6=AC=84?= =?UTF-8?q?=E4=BD=8D=EF=BC=8Bkbdb-proxy=20=E9=80=8F=E5=82=B3=20library?= =?UTF-8?q?=EF=BC=88P1=20=E9=A0=86=E5=BB=B6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - portal.ts:export 共用 helpers(requirePortalUser/parseLibraries/kbdbFetch/run…) +graphSourceLibraries/hasGraphAccess/workflowsVisible;/portal/session 補 graph_allowed、workflows_visible(顯示提示,真閘在 /portal/data/* 路由層) - portal_library seed 加 graph_source slot;ensurePortalTemplates 冪等補舊 template 缺的 slots(updateRecord 對 template 外 slot 會 reject,不補就寫不進標記) - admin libraries PATCH 收 graph_source(boolean);toPublicLibrary 回 graph_source - kbdb-proxy /kbdb/search 與 /kbdb/entries 白名單加 library(tasks.md P1 順延一行) - PORTAL_SHOW_WORKFLOWS var(D-8,預設 admin) Co-Authored-By: Claude Fable 5 --- cypher-executor/src/lib/portal-seeds.ts | 4 +- cypher-executor/src/routes/kbdb-proxy.ts | 12 ++- cypher-executor/src/routes/portal.ts | 107 ++++++++++++++++--- cypher-executor/src/types.ts | 4 + cypher-executor/wrangler.toml | 4 + system-dev/docs/3-specs/portal-auth/tasks.md | 12 +-- 6 files changed, 117 insertions(+), 26 deletions(-) diff --git a/cypher-executor/src/lib/portal-seeds.ts b/cypher-executor/src/lib/portal-seeds.ts index 8416b26..003c922 100644 --- a/cypher-executor/src/lib/portal-seeds.ts +++ b/cypher-executor/src/lib/portal-seeds.ts @@ -30,9 +30,11 @@ export const PORTAL_TEMPLATE_SEEDS: PortalTemplateSeed[] = [ { // design §3.2:庫目錄(admin 頁列庫用)。庫本體=知識條目 metadata_json.$.library 標記, // 這裡只是「有哪些庫」的登記簿。 + // graph_source(design D-4,P3):'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', }, ]; diff --git a/cypher-executor/src/routes/kbdb-proxy.ts b/cypher-executor/src/routes/kbdb-proxy.ts index 14a0781..c83dae0 100644 --- a/cypher-executor/src/routes/kbdb-proxy.ts +++ b/cypher-executor/src/routes/kbdb-proxy.ts @@ -121,8 +121,10 @@ kbdbProxyRouter.get('/kbdb/records/:recordId', async (c) => { // ── search(限本租戶範圍內)──────────────────────────────────────────────────── -// GET /kbdb/search?q=&entry_type=&source=&mode= — entries 搜尋,限本租戶 owner_id。 -// 透傳 entry_type(base 通用 filter,workflow-discovery Q4)/ source / mode 給 KBDB /entries/search。 +// GET /kbdb/search?q=&entry_type=&source=&library=&mode= — entries 搜尋,限本租戶 owner_id。 +// 透傳 entry_type(base 通用 filter,workflow-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 — list(filters: entry_type / parent_id / page_name / source / q(search) / limit / offset)。 +// GET /kbdb/entries — list(filters: entry_type / parent_id / page_name / source / library / q(search) / limit / offset)。 // owner_id 強制覆寫成本租戶(防跨租戶讀;caller 不能查別人的 owner_id)。 // Arcrun#3 發現①根因:本白名單原本沒有 q/search,caller 帶 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); } diff --git a/cypher-executor/src/routes/portal.ts b/cypher-executor/src/routes/portal.ts index a36c3a8..93f935e 100644 --- a/cypher-executor/src/routes/portal.ts +++ b/cypher-executor/src/routes/portal.ts @@ -39,9 +39,14 @@ const LIBRARY_TEMPLATE = 'portal_library'; // ── 基礎 helpers ──────────────────────────────────────────────────────────── -/** 帳號子 namespace(design D-2)。tenant 預設沿 console-auth 同款 'leo'。 */ +/** 租戶字串(=知識資料的 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 `${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 { +export async function kbdbFetch(env: Bindings, path: string, init?: RequestInit): Promise { 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): Promise { +export async function run(c: Context<{ Bindings: Bindings }>, fn: () => Promise): Promise { try { return await fn(); } catch (e) { @@ -81,7 +86,7 @@ async function run(c: Context<{ Bindings: Bindings }>, fn: () => Promise; @@ -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 { +export async function listRecordsByTemplate(env: Bindings, template: string): Promise { const ns = portalNamespace(env); const res = await kbdbFetch(env, `/records/by-template/${encodeURIComponent(template)}?owner_id=${encodeURIComponent(ns)}`); if (!res.ok) throw new KbdbError(`GET /records/by-template/${template} → ${res.status}`); @@ -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 }; -type AuthResult = { ok: true; user: AuthedUser } | { ok: false; res: Response }; +export type AuthedUser = { token: string; recordId: string; values: Record }; +export type AuthResult = { ok: true; user: AuthedUser } | { ok: false; res: Response }; /** * portal session 閘:token → KV → record_id → **回讀 record**(唯一真相源)→ status=active。 * 停用即時生效(design §4.3);停用/孤兒 session 順手刪 KV(best-effort,正確性不依賴它)。 */ -async function requirePortalUser(c: Context<{ Bindings: Bindings }>): Promise { +export async function requirePortalUser(c: Context<{ Bindings: Bindings }>): Promise { const token = bearerToken(c); if (!token) return { ok: false, res: c.json({ error: '未登入' }, 401) }; const sess = await c.env.SESSIONS_KV.get(`${SESSION_PREFIX}${token}`); @@ -297,6 +326,40 @@ async function requirePortalAdmin(c: Context<{ Bindings: Bindings }>): Promise { + const libs = await listRecordsByTemplate(env, LIBRARY_TEMPLATE); + const marked = libs + .filter((l) => (l.values.graph_source ?? '') === 'true' && (l.values.status ?? 'active') !== 'disabled') + .map((l) => l.values.name ?? '') + .filter(Boolean); + return marked.length > 0 ? marked : ['general']; +} + +/** graph 粗閘判定:擁有任一 graph 來源庫的權限(或 ["*"] 全庫)才放行。 */ +export async function hasGraphAccess(env: Bindings, userLibraries: string[]): Promise { + if (userLibraries.includes('*')) return true; // 全庫 → 必含來源庫,省一次 KBDB 呼叫 + if (userLibraries.length === 0) return false; + const sources = await graphSourceLibraries(env); + return sources.some((s) => userLibraries.includes(s)); +} + +/** + * 工作流頁可見性(design D-8 定案:admin):PORTAL_SHOW_WORKFLOWS = admin(預設)/ all / off。 + * 壞值誠實退回預設 admin(不因 typo 意外全開)。 + */ +export function workflowsVisible(env: Bindings, role: string): boolean { + const setting = (env.PORTAL_SHOW_WORKFLOWS ?? 'admin').toLowerCase(); + if (setting === 'off') return false; + if (setting === 'all') return true; + return role === 'admin'; +} + /** * 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_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 ?? '', - 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) }); diff --git a/cypher-executor/src/types.ts b/cypher-executor/src/types.ts index 59292c0..89abcbe 100644 --- a/cypher-executor/src/types.ts +++ b/cypher-executor/src/types.ts @@ -106,6 +106,10 @@ export type Bindings = { // expirationTtl。未設 → 604800(7 天,design §4.3——issue 要求短效,比 console 30 天緊)。 // 只影響新發的 session;權限/停用的即時性不靠 TTL(每請求回讀 user record)。 PORTAL_SESSION_TTL?: string; + // Portal 工作流頁可見性(portal-auth P3,design 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..workers.dev(該 repo wrangler.toml name 固定)。 // console 卡片詳頁「關聯視圖」經 cypher proxy 打它(kbdb-proxy.ts /kbdb/graph/neighbors/:name)。 diff --git a/cypher-executor/wrangler.toml b/cypher-executor/wrangler.toml index bbcff6f..231e776 100644 --- a/cypher-executor/wrangler.toml +++ b/cypher-executor/wrangler.toml @@ -142,6 +142,10 @@ CONSOLE_TENANT = "leo" # issue 要求比 console 30 天短效。停用帳號的即時性不靠這個 TTL(每請求回讀 user record)。 PORTAL_SESSION_TTL = "604800" +# Portal 工作流頁可見性(portal-auth P3,design D-8 定案):admin(預設)/ all / off。 +# 唯讀 list+最近執行、不開 trigger;enforce 在 /portal/data/workflows 路由層。 +PORTAL_SHOW_WORKFLOWS = "admin" + # 駕駛艙「等你的事」活資料源(fix/console-dashboard-live-data,2026-07-07): # InkStoneCo sprint 檔「## 等 leo 清單」(progress-guard 每日維護)——KBDB dash_wait 沒有 # 活的維護管線(07-04 之後沒人寫、銷案不同步),不再當首選資料源。 diff --git a/system-dev/docs/3-specs/portal-auth/tasks.md b/system-dev/docs/3-specs/portal-auth/tasks.md index 0538667..3b67101 100644 --- a/system-dev/docs/3-specs/portal-auth/tasks.md +++ b/system-dev/docs/3-specs/portal-auth/tasks.md @@ -69,12 +69,12 @@ ## P3 — `/portal` UI:登入殼+搜尋頁+設定頁+scope enforce(design §1/§3.3/§5/§6)|觸碰:`cypher-executor/` -- [ ] `/portal` HTML 殼(重用 console 樣式/搜尋 view 抽共用 helper;`CONSOLE_BRAND` 品牌;零 Mira 字樣) -- [ ] 未登入只見登入殼;登入後兩頁:搜尋(keyword/semantic/graph 三模式+source 溯源+卡片詳頁)+設定(改密碼/看自己權限/主題) -- [ ] `/portal/data/*` server-side enforce:session→record→注入 `owner_id`+`library`;**前端絕不下發租戶字串** -- [ ] 卡片詳頁逐筆驗 library(越庫 id 直讀 → 404) -- [ ] graph 粗閘(D-4:無 graph 來源庫權限 → 模式不顯示+API 403) -- [ ] 測試:curl 帶 user session 直打 data API 驗 filter 繞不過(=#24 驗收 3 的 server-side 證明) +- [🔄] `/portal` HTML 殼(重用 console 樣式/搜尋 view 抽共用 helper;`CONSOLE_BRAND` 品牌;零 Mira 字樣) +- [🔄] 未登入只見登入殼;登入後兩頁:搜尋(keyword/semantic/graph 三模式+source 溯源+卡片詳頁)+設定(改密碼/看自己權限/主題) +- [🔄] `/portal/data/*` server-side enforce:session→record→注入 `owner_id`+`library`;**前端絕不下發租戶字串** +- [🔄] 卡片詳頁逐筆驗 library(越庫 id 直讀 → 404) +- [🔄] graph 粗閘(D-4:無 graph 來源庫權限 → 模式不顯示+API 403) +- [🔄] 測試:curl 帶 user session 直打 data API 驗 filter 繞不過(=#24 驗收 3 的 server-side 證明) - **驗收**(=#24 驗收):leo21c `/portal` 可開;未登入只見登入殼;三模式+溯源可用;A 用戶(僅 general)搜不到 finance 內容——UI 與 curl 雙驗 - **工程量**:大(1–1.5 個 CC 工作天,UI 是最大件) -- 2.52.0 From a3f5c77332363aed7f54033695d8a1e15452f3f2 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Tue, 14 Jul 2026 13:05:23 +0800 Subject: [PATCH 3/5] =?UTF-8?q?portal-auth=20P3=EF=BC=9A/portal/data/*=20s?= =?UTF-8?q?erver-side=20enforce=EF=BC=8B/portal=20=E5=96=AE=E6=AA=94=20HTM?= =?UTF-8?q?L=20=E6=AE=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - portal-data.ts(安全核心):search(server 注入 owner_id+library,caller 的 owner_id/library 參數一律被覆蓋);entries/:id 逐筆驗租戶+庫(越庫/不存在同一句 404 不洩存在性);graph D-4 粗閘(無權 403,SDD 明定例外);workflows D-8 (admin/all/off,唯讀+最近執行,無 trigger、無 webhook_url) - portal-ui.ts:獨立 HTML 殼(不動 console;樣式複製 console 設計語言子集)—— 登入/搜尋(三模式+source 溯源+卡片詳頁)/設定(改密碼/看權限/主題)/工作流 (admin);前端零租戶字串、零 X-Arcrun-API-Key,只持 portal session token - index.ts 掛載兩個新 router;tsc exit 0 Co-Authored-By: Claude Fable 5 --- cypher-executor/src/index.ts | 6 +- cypher-executor/src/routes/portal-data.ts | 190 ++++++ cypher-executor/src/routes/portal-ui.ts | 743 ++++++++++++++++++++++ 3 files changed, 938 insertions(+), 1 deletion(-) create mode 100644 cypher-executor/src/routes/portal-data.ts create mode 100644 cypher-executor/src/routes/portal-ui.ts diff --git a/cypher-executor/src/index.ts b/cypher-executor/src/index.ts index e1478df..efe485c 100644 --- a/cypher-executor/src/index.ts +++ b/cypher-executor/src/index.ts @@ -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.5:KBDB 資料層 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 多人授權——用戶模型+認證 API(P3 UI 另一波) +app.route('/', portalRouter); // portal-auth P2(#24/#25):RAG Portal 多人授權——用戶模型+認證 API +app.route('/', portalDataRouter); // portal-auth P3:/portal/data/* server-side enforce(owner_id+library 注入,安全核心) +app.route('/', portalUiRouter); // portal-auth P3:GET /portal 單檔 HTML 殼(登入/搜尋/設定;獨立於 console) // Worker 導出(fetch + scheduled) // scheduled handler 對應 wrangler.toml [triggers].crons,每分鐘 tick; diff --git a/cypher-executor/src/routes/portal-data.ts b/cypher-executor/src/routes/portal-data.ts new file mode 100644 index 0000000..7093885 --- /dev/null +++ b/cypher-executor/src/routes/portal-data.ts @@ -0,0 +1,190 @@ +/** + * RAG Portal 查詢面 — P3:/portal/data/* server-side enforce(portal-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 全在 server:session → 回讀 user record(唯一真相源)→ 取 libraries → + * server 注入 owner_id+library 後轉發 KBDB。 + * - caller 自帶的 owner_id / library query 參數**一律忽略**(不是拒絕——拒絕會變成 + * 「試參數名」的 oracle;直接靜默覆蓋,怎麼傳都是自己的權限範圍)。 + * - ["*"]=全庫:只注 owner_id、不注 library(design §3.3)。 + * + * 不洩存在性(紅線):越庫的 entry(含根本不存在的 id、別的租戶的 id)一律回**同一句 404**, + * 不讓攻擊者從 403/404 差異推斷某 id / 某庫存在。唯一例外=graph 粗閘按 SDD 明定回 403(D-4)。 + * + * 薄殼(rule 07):本檔沒有新能力——搜尋/取條目能力真身在 KBDB base(P1 的 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_id+library;回應照 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(',')); + // 透傳的只有「在權限範圍內再收窄」的 filter;owner_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 的 library(NULL→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-plugin(token 只在 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 = {}; + 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 }); + }), +); diff --git a/cypher-executor/src/routes/portal-ui.ts b/cypher-executor/src/routes/portal-ui.ts new file mode 100644 index 0000000..a6f8687 --- /dev/null +++ b/cypher-executor/src/routes/portal-ui.ts @@ -0,0 +1,743 @@ +/** + * RAG Portal 前端殼 — P3:GET /portal 單檔 HTML(portal-auth design §1 D-1/§6,Gitea #24/#25) + * + * 形態(D-1):cypher-executor 的新路由、**獨立 HTML 殼**——不在 console 上加 if(「Admin + * Console 現狀不動」鐵律)。樣式重用 console 的設計語言(紙感/明體標題/琥珀強調/深淺主題), + * 取捨=**複製樣式子集而非抽共用模組**:抽共用要動 console.ts(違「console 不動」),且兩頁 + * 受眾不同(owner vs 同仁)預期各自演化;代價=樣式雙份、改版要兩邊同步(PR 說明誠實記)。 + * + * 頁面(design §6,leo 頁面級拍板): + * - 登入頁(brand=CONSOLE_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 ` + + + + +${brand} Portal + + + + + + +
+
+
+
${brand}
+
知識入口・Portal
+
+
+ + + +
+
+
帳號由管理員發放。忘記密碼請聯絡管理員重設。
+ +
+
+ + +
+
+ + + + + +
+
+
+ + + + + +
+
+ + 知識卡片 +
+
載入中…
+
+ + +
+
工作流唯讀・系統狀態
+
+ 此頁只顯示系統裡的工作流與最近執行狀態,不能觸發執行(觸發屬系統擁有者權限)。 +
+
載入中…
+
+ + +
+
設定
+
+
+
我的帳號
+
載入中…
+
+
+
+
+
深色模式
+
預設淺色(紙感)。切換立即生效,選擇記在這台裝置。
+
+
+
+
+
+
更改密碼
+
需輸入舊密碼驗證身分;新密碼至少 8 碼
+
+ + + + +
+
+
+ +
+
+ +
+
+ + +
+
搜尋
+
工作流
+
設定
+
+ +
+ + + +`; +} + +// 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)); +}); -- 2.52.0 From 35a769fc885ebedb1f81eb4d1ffa272dccc87da5 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Tue, 14 Jul 2026 13:10:19 +0800 Subject: [PATCH 4/5] =?UTF-8?q?portal-auth=20P3=20=E6=B8=AC=E8=A9=A6?= =?UTF-8?q?=EF=BC=9A21=20=E9=A0=85=EF=BC=88HTML=20=E9=9B=B6=E7=A7=9F?= =?UTF-8?q?=E6=88=B6=E5=AD=97=E4=B8=B2/enforce=20=E7=B9=9E=E4=B8=8D?= =?UTF-8?q?=E9=81=8E/=E8=B6=8A=E5=BA=AB=20404/graph=20=E7=B2=97=E9=96=98/w?= =?UTF-8?q?orkflows=20=E5=94=AF=E8=AE=80=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - portal-data.test.ts:/portal HTML 殼機械斷言(無 'leo'/X-Arcrun-API-Key//kbdb//Mira); search 注入證明(caller 帶 library=hr&owner_id=evil 被靜默覆蓋);entries/:id 越庫/ 跨租戶/不存在同一句 404+NULL→general;graph 403 不打 plugin/放行轉發/disabled 來源排除;workflows 非 admin 403、admin 唯讀無 webhook_url、workflowsVisible 單元 - portal-auth.test.ts session 測試補 P3 能力欄位(graph_allowed/workflows_visible) - wrangler.test.toml 補 ANALYTICS_KV mock+KBDB_GRAPH_URL 假 host(絕不外連) - cypher 154/155(唯一失敗=executor 不存在零件 pre-existing,git stash 複驗); kbdb 20/20;兩包 tsc exit 0 Co-Authored-By: Claude Fable 5 --- cypher-executor/tests/portal-auth.test.ts | 5 + cypher-executor/tests/portal-data.test.ts | 357 ++++++++++++++++++++++ cypher-executor/wrangler.test.toml | 7 + 3 files changed, 369 insertions(+) create mode 100644 cypher-executor/tests/portal-data.test.ts diff --git a/cypher-executor/tests/portal-auth.test.ts b/cypher-executor/tests/portal-auth.test.ts index f6e8716..227bd86 100644 --- a/cypher-executor/tests/portal-auth.test.ts +++ b/cypher-executor/tests/portal-auth.test.ts @@ -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()); + // P3:session 多回 graph_allowed(D-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; 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 () => { diff --git a/cypher-executor/tests/portal-data.test.ts b/cypher-executor/tests/portal-data.test.ts new file mode 100644 index 0000000..53f2735 --- /dev/null +++ b/cypher-executor/tests/portal-data.test.ts @@ -0,0 +1,357 @@ +/** + * portal-auth P3 測試(design §1/§3.3/§3.4/§5/§6,Gitea #24/#25) + * + * 覆蓋(=tasks.md P3 測試項+#24 驗收 3 的 server-side 證明): + * 1. /portal HTML 殼:200、brand、**零租戶字串/零 X-Arcrun-API-Key/零 Mira 字樣** + * 2. /portal/data/search enforce:server 注入 owner_id+library;caller 自帶 + * 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 403;admin 唯讀 list+最近執行、回應無 webhook_url; + * workflowsVisible 單元(admin/all/off/壞值) + * 6. /portal/session 能力欄位:graph_allowed / workflows_visible + * + * KBDB/graph-plugin 都打 fetchMock 假 host(wrangler.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 = {}) { + 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) { + 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 }[]) { + 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 = {}): Record { + 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 並回收實際轉發的 query(enforce 的機械證據)。 */ +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); + return { url: () => captured }; +} + +// ═══════════════ 1. /portal HTML 殼 ═══════════════ + +describe('GET /portal(HTML 殼)', () => { + it('200;brand 出現;**前端零租戶字串、零 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_id+library;caller 自帶 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、不注 library(design §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 | 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('跨租戶 entry(owner_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('未標記 library(NULL 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/:name(D-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 interceptor(afterEach 驗)=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/workflows(D-8:admin 唯讀)', () => { + 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_url/trigger 把手**', 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/session(P3 能力欄位)', () => { + it('一般 user(finance,無 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; + 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; + expect(data.graph_allowed).toBe(true); + expect(data.workflows_visible).toBe(true); + }); +}); diff --git a/cypher-executor/wrangler.test.toml b/cypher-executor/wrangler.test.toml index 3fdaa34..928ed13 100644 --- a/cypher-executor/wrangler.test.toml +++ b/cypher-executor/wrangler.test.toml @@ -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 mock(Miniflare 本地 SQLite,非真實 leo21c D1; # schema 由 tests/setup.ts 在測試啟動時建表,不用 migrations_dir——0002_credentials.sql 就一張表, # 直接 exec 比接 migrations 機制簡單) @@ -43,3 +48,5 @@ ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd # 分流台勾掉 route 測試:KBDB 指到假 host(fetchMock 攔截,絕不外連——尤其不打官方 uncle6 fallback) KBDB_BASE_URL = "https://kbdb.test" CONSOLE_TENANT = "leo" +# portal-auth P3:graph 粗閘放行後的轉發目標也指假 host(fetchMock 攔截,絕不外連) +KBDB_GRAPH_URL = "https://graph.test" -- 2.52.0 From ef0eb0860378b771e3ee46020361488370f079bf Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Tue, 14 Jul 2026 13:22:23 +0800 Subject: [PATCH 5/5] =?UTF-8?q?portal-auth=20P3=20=E6=94=B6=E5=B0=BE?= =?UTF-8?q?=EF=BC=9Atasks.md=20=E5=85=AD=E9=A0=85=EF=BC=8BP1=20=E9=A0=86?= =?UTF-8?q?=E5=BB=B6=E9=A0=85=E5=8B=BE=E6=8E=89=E3=80=81wiki=20status=20?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=EF=BC=88=E5=90=AB=2047/47=20e2e=20=E8=AD=89?= =?UTF-8?q?=E6=93=9A=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- system-dev/docs/3-specs/portal-auth/tasks.md | 36 ++++++++++++++++---- system-dev/wiki/status.md | 27 ++++++++++++++- 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/system-dev/docs/3-specs/portal-auth/tasks.md b/system-dev/docs/3-specs/portal-auth/tasks.md index 3b67101..e7b66fe 100644 --- a/system-dev/docs/3-specs/portal-auth/tasks.md +++ b/system-dev/docs/3-specs/portal-auth/tasks.md @@ -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 filter(mock 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 enforce(design §1/§3.3/§5/§6)|觸碰:`cypher-executor/` -- [🔄] `/portal` HTML 殼(重用 console 樣式/搜尋 view 抽共用 helper;`CONSOLE_BRAND` 品牌;零 Mira 字樣) -- [🔄] 未登入只見登入殼;登入後兩頁:搜尋(keyword/semantic/graph 三模式+source 溯源+卡片詳頁)+設定(改密碼/看自己權限/主題) -- [🔄] `/portal/data/*` server-side enforce:session→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+原生 JS(console 同形態)。**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 enforce:session→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 補 slot+ensurePortalTemplates 冪等 + grow 舊 template 缺的 slots;admin 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-existing,stash 複驗); + kbdb 20/20;兩包 tsc exit 0。端到端:本機隔離雙 worker(9790/9791,demo 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 index+reindex)。 - **工程量**:大(1–1.5 個 CC 工作天,UI 是最大件) ## P4 — admin 頁+工作流顯示(design §6)|觸碰:`cypher-executor/` diff --git a/system-dev/wiki/status.md b/system-dev/wiki/status.md index 19f72bf..002ebd3 100644 --- a/system-dev/wiki/status.md +++ b/system-dev/wiki/status.md @@ -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,31 @@ metadata: ## 📍 當前位置 +> **2026-07-14 本 session(portal-auth P3:Portal UI+查詢 enforce——本 SDD 最大件,PR 待總管審不 merge)**: +> - **實作(分支 `portal-auth-p3-portal-ui`)**:① `routes/portal-data.ts`(安全核心)——`/portal/data/*` +> server-side enforce:session→回讀 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` slot+ensurePortalTemplates 冪等 grow 舊 template 缺 slot+ +> admin libraries PATCH 可標記。 +> - **UI 重用取捨**:複製 console 樣式子集(紙感/明體/琥珀/深淺主題)而非抽共用模組——抽共用要動 +> console.ts(違「console 現狀不動」鐵律);代價=樣式雙份、改版要兩邊同步(誠實記)。 +> - **驗證**:cypher 154/155(唯一失敗=executor「不存在的零件」pre-existing,stash 複驗)+新增 +> `tests/portal-data.test.ts` 21 項;kbdb 20/20;兩包 tsc 0;`wrangler deploy --dry-run` 打包過。 +> **端到端隔離雙 worker(9790/9791,demo 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/零 Mira+inline JS node --check、semantic 誠實降級仍 enforce、改密碼全鏈。 +> - **不 merge 待總管審**(B 類流程);部署後 leo21c 線上驗(semantic 線上路徑仍依 P1 部署清單 +> Vectorize `library` index+reindex backfill)。下一棒 P4(admin 帳號管理頁)。 +> - **附帶**:P2 舊 session 測試補 P3 能力欄位 mock;wrangler.test.toml 補 ANALYTICS_KV+KBDB_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。 -- 2.52.0