@@ -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 ) } ) ;