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 是最大件)