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:
@@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<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 順手刪 KV(best-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_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) });
|
||||
|
||||
@@ -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.<subdomain>.workers.dev(該 repo wrangler.toml name 固定)。
|
||||
// console 卡片詳頁「關聯視圖」經 cypher proxy 打它(kbdb-proxy.ts /kbdb/graph/neighbors/:name)。
|
||||
|
||||
@@ -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 之後沒人寫、銷案不同步),不再當首選資料源。
|
||||
|
||||
@@ -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 是最大件)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user