portal-auth P3(#24 #25):Portal UI+/portal/data/* server-side enforce(不 merge,待總管審) (#52)
This commit was merged in pull request #52.
This commit is contained in:
@@ -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) });
|
||||
|
||||
Reference in New Issue
Block a user