portal-auth P3 前置:graph 粗閘 helpers(D-4)+session 能力欄位+kbdb-proxy 透傳 library(P1 順延)

- 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 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-07-14 12:59:41 +08:00
parent d4611950c0
commit 5668d48d37
6 changed files with 117 additions and 26 deletions
+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);
}
+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)。
+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 之後沒人寫、銷案不同步),不再當首選資料源。
+6 -6
View File
@@ -69,12 +69,12 @@
## 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 證明)
- [🔄] `/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 證明)
- **驗收**=#24 驗收):leo21c `/portal` 可開;未登入只見登入殼;三模式+溯源可用;A 用戶(僅 general)搜不到 finance 內容——UI 與 curl 雙驗
- **工程量**:大(1–1.5 個 CC 工作天,UI 是最大件)