@@ -0,0 +1,678 @@
/**
* RAG Portal 多人授權 — P2:用戶模型+認證 API( portal-auth design §2/§4/§5, Gitea #24/#25)
*
* 架構(design D-1):Portal= cypher-executor 的 /portal 路由(非獨立 worker)。
* 本檔只做 P2 的 auth/admin API; P3( /portal HTML 殼+ /portal/data/* enforce)另一波。
*
* 鐵律對照:
* - rule 2.1/2.2:這是 UI session 登入(console-auth 同類先例),非 workflow credential
* 原語。KDF 在 lib/portal-auth.ts( WebCrypto PBKDF2),本檔無解密/簽章/template 展開。
* - 零新表(design §2):portal_user / portal_library 都是 KBDB 萬用表 template;
* 資料經 KBDB base HTTP API 寫入(kbdbBase 慣例,不直連 D1、不寫 SQL)。
* - 子 namespace( design D-2):一切帳號資料 owner_id= `{CONSOLE_TENANT}::portal`。
* 既有租戶查詢面(/kbdb/*、MCP)都以 CONSOLE_TENANT 過濾 → 物理上搜不到帳號資料
* ( email/password_hash 不會出現在知識搜尋結果)。
* - session( design §4.3):KV `portal_sess:{token}` 只存 record_id( TTL 暫存=合規),
* **每個請求回讀 user record 當唯一真相源** → 停用/改權限即時生效,不靠 session 反向索引。
* - 絕不下發租戶字串(design §3.3 關鍵差異 vs console):/portal/session 只回
* display_name/role/libraries。
* - 密碼永不明碼儲存、永不進 log(本檔不 log 任何 body)。
*/
import { Hono } from 'hono' ;
import type { Context } from 'hono' ;
import type { Bindings } from '../types' ;
import { kbdbBase } from './kbdb-proxy' ;
import { validateConsoleSession } from './console-auth' ;
import { hashPassword , verifyPassword , randomHex , generatePassword } from '../lib/portal-auth' ;
import { PORTAL_TEMPLATE_SEEDS } from '../lib/portal-seeds' ;
export const portalRouter = new Hono < { Bindings : Bindings } > ( ) ;
const SESSION_PREFIX = 'portal_sess:' ;
const LOCKFAIL_PREFIX = 'portal_lockfail:' ;
const LOCK_LIMIT = 5 ; // design §4.3: 5 次失敗
const LOCK_TTL_SECONDS = 15 * 60 ; // 鎖 15 分鐘(KV TTL 自然過期)
const DEFAULT_SESSION_TTL = 604800 ; // 7 天(design §4.3,比 console 30 天緊)
const USER_TEMPLATE = 'portal_user' ;
const LIBRARY_TEMPLATE = 'portal_library' ;
// ── 基礎 helpers ────────────────────────────────────────────────────────────
/** 帳號子 namespace( design D-2)。tenant 預設沿 console-auth 同款 'leo'。 */
function portalNamespace ( env : Bindings ) : string {
return ` ${ env . CONSOLE_TENANT || 'leo' } ::portal ` ;
}
function sessionTtl ( env : Bindings ) : number {
const n = Number . parseInt ( env . PORTAL_SESSION_TTL ? ? '' , 10 ) ;
// KV expirationTtl 下限 60 秒;壞值誠實退回預設而非炸掉
return Number . isFinite ( n ) && n >= 60 ? n : DEFAULT_SESSION_TTL ;
}
function bearerToken ( c : Context < { Bindings : Bindings } > ) : string | null {
const auth = c . req . header ( 'authorization' ) ? ? '' ;
return auth . match ( /^Bearer\s+(\S+)/i ) ? . [ 1 ] ? ? null ;
}
/** KBDB 不可達/回錯時拋這個 → 各 route 統一 502 誠實回報(不假綠、不偽裝成 401)。 */
class KbdbError extends Error { }
async function kbdbFetch ( env : Bindings , path : string , init? : RequestInit ) : Promise < Response > {
const { base , headers } = kbdbBase ( env ) ;
let res : Response ;
try {
res = await fetch ( ` ${ base } ${ path } ` , { . . . init , headers : { . . . headers , . . . ( init ? . headers as Record < string , string > | undefined ) } } ) ;
} catch ( e ) {
throw new KbdbError ( ` fetch ${ path } 失敗: ${ e instanceof Error ? e.message : String ( e ) } ` ) ;
}
return res ;
}
/** route handler 包一層:KbdbError → 502(誠實),其餘照拋。 */
async function run ( c : Context < { Bindings : Bindings } > , fn : ( ) = > Promise < Response > ) : Promise < Response > {
try {
return await fn ( ) ;
} catch ( e ) {
if ( e instanceof KbdbError ) return c . json ( { error : ` KBDB 不可達或回錯: ${ e . message } ` } , 502 ) ;
throw e ;
}
}
// ── KBDB 資料層 helpers(全走 base HTTP API,零 SQL)────────────────────────────
interface PortalRecord {
record_id : string ;
template_id : string ;
values : Record < string , string > ;
}
/** 冪等確保 portal templates 存在(seed 是 API 行為,rule 07; /init/seed 與 bootstrap 共用)。 */
export async function ensurePortalTemplates (
env : Bindings ,
) : Promise < { created : string [ ] ; existing : string [ ] ; errors : string [ ] } > {
const created : string [ ] = [ ] ;
const existing : string [ ] = [ ] ;
const errors : string [ ] = [ ] ;
for ( const seed of PORTAL_TEMPLATE_SEEDS ) {
try {
const got = await kbdbFetch ( env , ` /templates/ ${ encodeURIComponent ( seed . name ) } ` ) ;
if ( got . ok ) {
existing . push ( seed . name ) ;
continue ;
}
if ( got . status !== 404 ) throw new KbdbError ( ` GET /templates/ ${ seed . name } → ${ got . status } ` ) ;
const res = await kbdbFetch ( env , '/templates' , {
method : 'POST' ,
body : JSON.stringify ( {
name : seed.name ,
slots : seed.slots ,
description : seed.description ,
created_by : seed.created_by ,
} ) ,
} ) ;
if ( ! res . ok ) throw new KbdbError ( ` POST /templates ${ seed . name } → ${ res . status } ` ) ;
created . push ( seed . name ) ;
} catch ( e ) {
errors . push ( ` ${ seed . name } : ${ e instanceof Error ? e.message : String ( e ) } ` ) ;
}
}
return { created , existing , errors } ;
}
/** email → user record_id( design §2.3 head entry O(1) 查找:page_name=email 走 index)。 */
async function findUserRecordId ( env : Bindings , email : string ) : Promise < string | null > {
const ns = portalNamespace ( env ) ;
const params = new URLSearchParams ( {
page_name : email ,
entry_type : USER_TEMPLATE ,
owner_id : ns ,
limit : '1' ,
} ) ;
const res = await kbdbFetch ( env , ` /entries? ${ params . toString ( ) } ` ) ;
if ( ! res . ok ) throw new KbdbError ( ` head entry 查找 → ${ res . status } ` ) ;
const body = ( await res . json ( ) ) as { entries ? : { content : string | null } [ ] } ;
const content = body . entries ? . [ 0 ] ? . content ;
return content ? ? null ;
}
async function getRecordById ( env : Bindings , recordId : string ) : Promise < PortalRecord | null > {
const res = await kbdbFetch ( env , ` /records/ ${ encodeURIComponent ( recordId ) } ` ) ;
if ( res . status === 404 ) return null ;
if ( ! res . ok ) throw new KbdbError ( ` GET /records/ ${ recordId } → ${ res . status } ` ) ;
const body = ( await res . json ( ) ) as { record? : PortalRecord } ;
return body . record ? ? null ;
}
async function patchRecordValues ( env : Bindings , recordId : string , values : Record < string , string > ) : Promise < PortalRecord > {
const res = await kbdbFetch ( env , ` /records/ ${ encodeURIComponent ( recordId ) } ` , {
method : 'PATCH' ,
body : JSON.stringify ( { values } ) ,
} ) ;
if ( ! res . ok ) throw new KbdbError ( ` PATCH /records/ ${ recordId } → ${ res . status } ` ) ;
const body = ( await res . json ( ) ) as { record? : PortalRecord } ;
if ( ! body . record ) throw new KbdbError ( ` PATCH /records/ ${ recordId } 回應缺 record ` ) ;
return body . record ;
}
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 } ` ) ;
const body = ( await res . json ( ) ) as { records? : PortalRecord [ ] } ;
return body . records ? ? [ ] ;
}
interface CreateUserInput {
email : string ;
display_name : string ;
role : 'user' | 'admin' ;
libraries : string [ ] ;
password_hash : string ;
}
/** 建 portal_user record(子 namespace)+ email head entry(§2.3)。 */
async function createPortalUser ( env : Bindings , input : CreateUserInput ) : Promise < string > {
const ns = portalNamespace ( env ) ;
const now = new Date ( ) . toISOString ( ) ;
const res = await kbdbFetch ( env , '/records' , {
method : 'POST' ,
body : JSON.stringify ( {
template : USER_TEMPLATE ,
owner_id : ns ,
values : {
email : input.email ,
display_name : input.display_name ,
status : 'active' ,
role : input.role ,
password_hash : input.password_hash ,
libraries : JSON.stringify ( input . libraries ) ,
created_at : now ,
updated_at : now ,
} ,
} ) ,
} ) ;
if ( ! res . ok ) throw new KbdbError ( ` POST /records( portal_user)→ ${ res . status } ` ) ;
const body = ( await res . json ( ) ) as { record ? : { record_id : string } } ;
const recordId = body . record ? . record_id ;
if ( ! recordId ) throw new KbdbError ( 'POST /records 回應缺 record_id' ) ;
// head entry: page_name=email( indexed)→ content=record_id, O(1) 登入查找
const head = await kbdbFetch ( env , '/entries' , {
method : 'POST' ,
body : JSON.stringify ( {
entry_type : USER_TEMPLATE ,
page_name : input.email ,
content : recordId ,
owner_id : ns ,
} ) ,
} ) ;
if ( ! head . ok ) throw new KbdbError ( ` head entry 建立失敗(record ${ recordId } 已建,需人工收拾)→ ${ head . status } ` ) ;
return recordId ;
}
// ── user 值域 helpers ──────────────────────────────────────────────────────
function parseLibraries ( raw : string | undefined ) : string [ ] {
if ( ! raw ) return [ ] ;
try {
const arr = JSON . parse ( raw ) ;
if ( Array . isArray ( arr ) && arr . every ( ( x ) = > typeof x === 'string' ) ) return arr ;
} catch {
/* fallthrough */
}
return [ ] ;
}
function isValidEmail ( email : string ) : boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/ . test ( email ) && email . length <= 254 ;
}
/** 庫名進 metadata/query(逗號分隔參數),故禁逗號/空白/怪字元。 */
function isValidLibraryName ( name : string ) : boolean {
return /^(\*|[A-Za-z0-9_-]{1,64})$/ . test ( name ) ;
}
function validLibrariesInput ( libs : unknown ) : libs is string [ ] {
return Array . isArray ( libs ) && libs . length > 0 && libs . every ( ( x ) = > typeof x === 'string' && isValidLibraryName ( x ) ) ;
}
/** admin 面向的公開 user 形狀:**絕不含 password_hash**。 */
function toPublicUser ( rec : PortalRecord ) {
const v = rec . values ;
return {
record_id : rec.record_id ,
email : v.email ? ? '' ,
display_name : v.display_name ? ? '' ,
status : v.status ? ? '' ,
role : v.role ? ? '' ,
libraries : parseLibraries ( v . libraries ) ,
created_at : v.created_at ? ? '' ,
updated_at : v.updated_at ? ? '' ,
} ;
}
// ── session 閘 ────────────────────────────────────────────────────────────
type AuthedUser = { token : string ; recordId : string ; values : Record < string , string > } ;
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 > {
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 } ` ) ;
if ( ! sess ) return { ok : false , res : c.json ( { error : 'session 無效或已過期' } , 401 ) } ;
let recordId : string | undefined ;
try {
recordId = ( JSON . parse ( sess ) as { record_id? : string } ) . record_id ;
} catch {
/* fallthrough */
}
if ( ! recordId ) {
await c . env . SESSIONS_KV . delete ( ` ${ SESSION_PREFIX } ${ token } ` ) ;
return { ok : false , res : c.json ( { error : 'session 無效或已過期' } , 401 ) } ;
}
const rec = await getRecordById ( c . env , recordId ) ;
if ( ! rec ) {
await c . env . SESSIONS_KV . delete ( ` ${ SESSION_PREFIX } ${ token } ` ) ;
return { ok : false , res : c.json ( { error : 'session 無效或已過期' } , 401 ) } ;
}
if ( ( rec . values . status ? ? '' ) !== 'active' ) {
await c . env . SESSIONS_KV . delete ( ` ${ SESSION_PREFIX } ${ token } ` ) ;
return { ok : false , res : c.json ( { error : '帳號已停用' } , 403 ) } ;
}
return { ok : true , user : { token , recordId , values : rec.values } } ;
}
async function requirePortalAdmin ( c : Context < { Bindings : Bindings } > ) : Promise < AuthResult > {
const auth = await requirePortalUser ( c ) ;
if ( ! auth . ok ) return auth ;
if ( ( auth . user . values . role ? ? '' ) !== 'admin' ) {
return { ok : false , res : c.json ( { error : '需要 admin 權限' } , 403 ) } ;
}
return auth ;
}
/**
* admin 操作目標 record 的成員資格驗證:record 的 email head entry(子 namespace 內)
* 必須指回同一 record_id——同時證明「是 portal_user」且「在本實例的 {tenant}::portal 下」,
* 防 admin 拿任意 record_id 改到不相干的 KBDB record。
*/
async function assertPortalUserRecord ( env : Bindings , recordId : string ) : Promise < PortalRecord | null > {
const rec = await getRecordById ( env , recordId ) ;
if ( ! rec ) return null ;
const email = rec . values . email ;
if ( ! email ) return null ;
const headRecordId = await findUserRecordId ( env , email ) ;
if ( headRecordId !== recordId ) return null ;
return rec ;
}
// ── 登入節流(design §4.3:KV 計數,TTL 自然過期)──────────────────────────────
async function isLocked ( env : Bindings , email : string ) : Promise < boolean > {
const raw = await env . SESSIONS_KV . get ( ` ${ LOCKFAIL_PREFIX } ${ email } ` ) ;
if ( ! raw ) return false ;
try {
return ( ( JSON . parse ( raw ) as { count? : number } ) . count ? ? 0 ) >= LOCK_LIMIT ;
} catch {
return false ;
}
}
async function recordLoginFail ( env : Bindings , email : string ) : Promise < void > {
const key = ` ${ LOCKFAIL_PREFIX } ${ email } ` ;
const raw = await env . SESSIONS_KV . get ( key ) ;
let count = 0 ;
if ( raw ) {
try {
count = ( JSON . parse ( raw ) as { count? : number } ) . count ? ? 0 ;
} catch {
count = 0 ;
}
}
await env . SESSIONS_KV . put ( key , JSON . stringify ( { count : count + 1 } ) , { expirationTtl : LOCK_TTL_SECONDS } ) ;
}
async function clearLoginFail ( env : Bindings , email : string ) : Promise < void > {
await env . SESSIONS_KV . delete ( ` ${ LOCKFAIL_PREFIX } ${ email } ` ) ;
}
// ═══════════════════════════════ 認證端點 ═══════════════════════════════════
// POST /portal/login — body {email, password}。成功發 portal session token。
// 錯誤訊息刻意不分「帳號不存在 vs 密碼錯」(不洩帳號存在性);停用帳號誠實回 403。
portalRouter . post ( '/portal/login' , ( c ) = >
run ( c , async ( ) = > {
const body = await c . req . json ( ) . catch ( ( ) = > null ) ;
const email = String ( body ? . email ? ? '' ) . trim ( ) . toLowerCase ( ) ;
const password = String ( body ? . password ? ? '' ) ;
if ( ! email || ! password ) return c . json ( { error : 'email 與 password 必填' } , 400 ) ;
if ( await isLocked ( c . env , email ) ) {
return c . json ( { error : '登入失敗次數過多,已暫時鎖定,請 15 分鐘後再試' } , 429 ) ;
}
const recordId = await findUserRecordId ( c . env , email ) ;
if ( ! recordId ) {
await recordLoginFail ( c . env , email ) ;
return c . json ( { error : 'email 或密碼錯誤' } , 401 ) ;
}
const rec = await getRecordById ( c . env , recordId ) ;
if ( ! rec ) {
await recordLoginFail ( c . env , email ) ;
return c . json ( { error : 'email 或密碼錯誤' } , 401 ) ;
}
if ( ( rec . values . status ? ? '' ) !== 'active' ) {
return c . json ( { error : '帳號已停用' } , 403 ) ;
}
const ok = await verifyPassword ( password , rec . values . password_hash ? ? '' ) ;
if ( ! ok ) {
await recordLoginFail ( c . env , email ) ;
return c . json ( { error : 'email 或密碼錯誤' } , 401 ) ;
}
await clearLoginFail ( c . env , email ) ;
const token = randomHex ( 32 ) ;
// session 值只存 record_id( design §4.3)——權限/狀態每請求回讀 record,不快取進 session
await c . env . SESSIONS_KV . put ( ` ${ SESSION_PREFIX } ${ token } ` , JSON . stringify ( { record_id : recordId } ) , {
expirationTtl : sessionTtl ( c . env ) ,
} ) ;
return c . json ( {
success : true ,
session_token : token ,
display_name : rec.values.display_name ? ? '' ,
role : rec.values.role ? ? 'user' ,
libraries : parseLibraries ( rec . values . libraries ) ,
// 絕不回租戶字串(design §3.3: portal_user 拿到租戶字串就能繞過庫 filter 直打 /kbdb/*)
} ) ;
} ) ,
) ;
// POST /portal/logout
portalRouter . post ( '/portal/logout' , async ( c ) = > {
const token = bearerToken ( c ) ;
if ( token ) await c . env . SESSIONS_KV . delete ( ` ${ SESSION_PREFIX } ${ token } ` ) ;
return c . json ( { success : true } ) ;
} ) ;
// GET /portal/session — 每請求回讀 user record(真相源);回 display_name/role/libraries,
// **絕不回租戶字串**( design §5)。
portalRouter . get ( '/portal/session' , ( c ) = >
run ( c , async ( ) = > {
const auth = await requirePortalUser ( c ) ;
if ( ! auth . ok ) return auth . res ;
const v = auth . user . values ;
return c . json ( {
valid : true ,
display_name : v.display_name ? ? '' ,
role : v.role ? ? 'user' ,
libraries : parseLibraries ( v . libraries ) ,
} ) ;
} ) ,
) ;
// POST /portal/me/password — body {current, new}。驗舊密改新密。
portalRouter . post ( '/portal/me/password' , ( c ) = >
run ( c , async ( ) = > {
const auth = await requirePortalUser ( c ) ;
if ( ! auth . ok ) return auth . res ;
const body = await c . req . json ( ) . catch ( ( ) = > null ) ;
const current = String ( body ? . current ? ? '' ) ;
const next = String ( body ? . new ? ? '' ) ;
if ( ! current || ! next ) return c . json ( { error : 'current 與 new 必填' } , 400 ) ;
if ( next . length < 8 ) return c . json ( { error : '新密碼至少 8 碼' } , 400 ) ;
const ok = await verifyPassword ( current , auth . user . values . password_hash ? ? '' ) ;
if ( ! ok ) return c . json ( { error : '舊密碼不正確' } , 401 ) ;
const newHash = await hashPassword ( next ) ;
await patchRecordValues ( c . env , auth . user . recordId , {
password_hash : newHash ,
updated_at : new Date ( ) . toISOString ( ) ,
} ) ;
return c . json ( { success : true } ) ;
} ) ,
) ;
// ═══════════════════════════════ admin 端點 ══════════════════════════════════
// POST /portal/admin/bootstrap — 需 **console owner session**( design D-7: owner secret 是
// 安裝期人閘,不引入新 secret、不開放無閘註冊)。建第一個 role=admin 的 portal_user;
// 已有 admin → 409 拒絕重複 bootstrap。順手冪等確保 templates( seed 是 API 行為)。
portalRouter . post ( '/portal/admin/bootstrap' , ( c ) = >
run ( c , async ( ) = > {
const consoleOk = await validateConsoleSession ( c . env , c . req . header ( 'authorization' ) ) ;
if ( ! consoleOk ) return c . json ( { error : '需要 console owner session(先登入 /console) ' } , 401 ) ;
const seeded = await ensurePortalTemplates ( c . env ) ;
if ( seeded . errors . length > 0 ) {
return c . json ( { error : ` portal templates seed 失敗: ${ seeded . errors . join ( '; ' ) } ` } , 502 ) ;
}
const users = await listRecordsByTemplate ( c . env , USER_TEMPLATE ) ;
if ( users . some ( ( u ) = > ( u . values . role ? ? '' ) === 'admin' ) ) {
return c . json ( { error : '已有 admin, bootstrap 只能執行一次;後續帳號請用 /portal/admin/users' } , 409 ) ;
}
const body = await c . req . json ( ) . catch ( ( ) = > null ) ;
const email = String ( body ? . email ? ? '' ) . trim ( ) . toLowerCase ( ) ;
const password = String ( body ? . password ? ? '' ) ;
const displayName = String ( body ? . display_name ? ? '' ) . trim ( ) || email ;
if ( ! isValidEmail ( email ) ) return c . json ( { error : 'email 格式不正確' } , 400 ) ;
if ( password . length < 8 ) return c . json ( { error : '密碼至少 8 碼' } , 400 ) ;
if ( await findUserRecordId ( c . env , email ) ) return c . json ( { error : '此 email 已存在' } , 409 ) ;
const recordId = await createPortalUser ( c . env , {
email ,
display_name : displayName ,
role : 'admin' ,
libraries : [ '*' ] , // bootstrap admin 預設全庫(design §3.3: ["*"]=不注 library filter)
password_hash : await hashPassword ( password ) ,
} ) ;
return c . json ( { success : true , record_id : recordId , email , role : 'admin' } ) ;
} ) ,
) ;
// GET /portal/admin/users — 同仁列表(role=admin 閘)。**回應剝除 password_hash**。
portalRouter . get ( '/portal/admin/users' , ( c ) = >
run ( c , async ( ) = > {
const auth = await requirePortalAdmin ( c ) ;
if ( ! auth . ok ) return auth . res ;
const users = await listRecordsByTemplate ( c . env , USER_TEMPLATE ) ;
return c . json ( { success : true , users : users.map ( toPublicUser ) , count : users.length } ) ;
} ) ,
) ;
// POST /portal/admin/users — 新增同仁。body {email, display_name?, role?, libraries?, password?}。
// 未帶 password → server 產一次性密碼隨回應回傳一次(不落地明碼,design §4.3 簡化版)。
portalRouter . post ( '/portal/admin/users' , ( c ) = >
run ( c , async ( ) = > {
const auth = await requirePortalAdmin ( c ) ;
if ( ! auth . ok ) return auth . res ;
const body = await c . req . json ( ) . catch ( ( ) = > null ) ;
const email = String ( body ? . email ? ? '' ) . trim ( ) . toLowerCase ( ) ;
const displayName = String ( body ? . display_name ? ? '' ) . trim ( ) || email ;
const role = body ? . role === 'admin' ? 'admin' : 'user' ;
const libraries : string [ ] = validLibrariesInput ( body ? . libraries ) ? ( body . libraries as string [ ] ) : [ 'general' ] ;
if ( ! isValidEmail ( email ) ) return c . json ( { error : 'email 格式不正確' } , 400 ) ;
if ( body ? . libraries !== undefined && ! validLibrariesInput ( body ? . libraries ) ) {
return c . json ( { error : 'libraries 須為非空字串陣列(庫名限 A-Za-z0-9_- 或 "*") ' } , 400 ) ;
}
if ( await findUserRecordId ( c . env , email ) ) return c . json ( { error : '此 email 已存在' } , 409 ) ;
let password = body ? . password !== undefined ? String ( body . password ) : '' ;
let generated : string | undefined ;
if ( password ) {
if ( password . length < 8 ) return c . json ( { error : '密碼至少 8 碼' } , 400 ) ;
} else {
generated = generatePassword ( ) ;
password = generated ;
}
const recordId = await createPortalUser ( c . env , {
email ,
display_name : displayName ,
role ,
libraries ,
password_hash : await hashPassword ( password ) ,
} ) ;
const rec = await getRecordById ( c . env , recordId ) ;
return c . json ( {
success : true ,
user : rec ? toPublicUser ( rec ) : { record_id : recordId , email } ,
// 一次性回傳(不儲存明碼);admin 口頭轉交同仁後即失效於 server 側
. . . ( generated ? { generated_password : generated } : { } ) ,
} ) ;
} ) ,
) ;
// PATCH /portal/admin/users/:id — 改 status/role/libraries( design §5)。
// 停用即時生效機制=每請求回讀 record(§4.3),不依賴刪 session。
portalRouter . patch ( '/portal/admin/users/:id' , ( c ) = >
run ( c , async ( ) = > {
const auth = await requirePortalAdmin ( c ) ;
if ( ! auth . ok ) return auth . res ;
const recordId = c . req . param ( 'id' ) ;
const rec = await assertPortalUserRecord ( c . env , recordId ) ;
if ( ! rec ) return c . json ( { error : '用戶不存在' } , 404 ) ;
const body = await c . req . json ( ) . catch ( ( ) = > null ) ;
if ( ! body ) return c . json ( { error : 'body 必須是 JSON' } , 400 ) ;
const patch : Record < string , string > = { } ;
if ( body . status !== undefined ) {
if ( body . status !== 'active' && body . status !== 'disabled' ) {
return c . json ( { error : 'status 只能是 active / disabled' } , 400 ) ;
}
patch . status = body . status ;
}
if ( body . role !== undefined ) {
if ( body . role !== 'user' && body . role !== 'admin' ) return c . json ( { error : 'role 只能是 user / admin' } , 400 ) ;
patch . role = body . role ;
}
if ( body . libraries !== undefined ) {
if ( ! validLibrariesInput ( body . libraries ) ) {
return c . json ( { error : 'libraries 須為非空字串陣列(庫名限 A-Za-z0-9_- 或 "*") ' } , 400 ) ;
}
patch . libraries = JSON . stringify ( body . libraries ) ;
}
if ( Object . keys ( patch ) . length === 0 ) return c . json ( { error : '沒有可更新的欄位(status/role/libraries) ' } , 400 ) ;
patch . updated_at = new Date ( ) . toISOString ( ) ;
const updated = await patchRecordValues ( c . env , recordId , patch ) ;
return c . json ( { success : true , user : toPublicUser ( updated ) } ) ;
} ) ,
) ;
// POST /portal/admin/users/:id/reset-password — 產一次性新密碼回傳(design §4.3 簡化版:
// admin 口頭轉交;must_change 首登改密列第二波)。
portalRouter . post ( '/portal/admin/users/:id/reset-password' , ( c ) = >
run ( c , async ( ) = > {
const auth = await requirePortalAdmin ( c ) ;
if ( ! auth . ok ) return auth . res ;
const recordId = c . req . param ( 'id' ) ;
const rec = await assertPortalUserRecord ( c . env , recordId ) ;
if ( ! rec ) return c . json ( { error : '用戶不存在' } , 404 ) ;
const password = generatePassword ( ) ;
await patchRecordValues ( c . env , recordId , {
password_hash : await hashPassword ( password ) ,
updated_at : new Date ( ) . toISOString ( ) ,
} ) ;
return c . json ( { success : true , password } ) ; // 一次性回傳,server 不留明碼
} ) ,
) ;
// ── 庫目錄(design §3.2 portal_library:登記簿;庫本體=條目上的 metadata 標記)────
function toPublicLibrary ( rec : PortalRecord ) {
const v = rec . values ;
return {
record_id : rec.record_id ,
name : v.name ? ? '' ,
display_name : v.display_name ? ? '' ,
description : v.description ? ? '' ,
status : v.status ? ? '' ,
} ;
}
// GET /portal/admin/libraries — 庫目錄列表。
portalRouter . get ( '/portal/admin/libraries' , ( c ) = >
run ( c , async ( ) = > {
const auth = await requirePortalAdmin ( c ) ;
if ( ! auth . ok ) return auth . res ;
const libs = await listRecordsByTemplate ( c . env , LIBRARY_TEMPLATE ) ;
return c . json ( { success : true , libraries : libs.map ( toPublicLibrary ) , count : libs.length } ) ;
} ) ,
) ;
// POST /portal/admin/libraries — 登記一個庫。body {name, display_name?, description?}。
portalRouter . post ( '/portal/admin/libraries' , ( c ) = >
run ( c , async ( ) = > {
const auth = await requirePortalAdmin ( c ) ;
if ( ! auth . ok ) return auth . res ;
const body = await c . req . json ( ) . catch ( ( ) = > null ) ;
const name = String ( body ? . name ? ? '' ) . trim ( ) ;
if ( ! isValidLibraryName ( name ) || name === '*' ) {
return c . json ( { error : '庫名限 A-Za-z0-9_-( 1-64 字元;"*" 是保留值不可登記)' } , 400 ) ;
}
const seeded = await ensurePortalTemplates ( c . env ) ;
if ( seeded . errors . length > 0 ) {
return c . json ( { error : ` portal templates seed 失敗: ${ seeded . errors . join ( '; ' ) } ` } , 502 ) ;
}
const existing = await listRecordsByTemplate ( c . env , LIBRARY_TEMPLATE ) ;
if ( existing . some ( ( l ) = > ( l . values . name ? ? '' ) === name ) ) {
return c . json ( { error : ` 庫 ${ name } 已登記 ` } , 409 ) ;
}
const ns = portalNamespace ( c . env ) ;
const res = await kbdbFetch ( c . env , '/records' , {
method : 'POST' ,
body : JSON.stringify ( {
template : LIBRARY_TEMPLATE ,
owner_id : ns ,
values : {
name ,
display_name : String ( body ? . display_name ? ? '' ) . trim ( ) || name ,
description : String ( body ? . description ? ? '' ) . trim ( ) ,
status : 'active' ,
} ,
} ) ,
} ) ;
if ( ! res . ok ) throw new KbdbError ( ` POST /records( portal_library)→ ${ res . status } ` ) ;
const created = ( await res . json ( ) ) as { record? : PortalRecord } ;
return c . json ( { success : true , library : created.record ? toPublicLibrary ( created . record ) : { name } } ) ;
} ) ,
) ;
// PATCH /portal/admin/libraries/:id — 改 display_name/description/status(停用庫=翻 status slot)。
portalRouter . patch ( '/portal/admin/libraries/:id' , ( c ) = >
run ( c , async ( ) = > {
const auth = await requirePortalAdmin ( c ) ;
if ( ! auth . ok ) return auth . res ;
const recordId = c . req . param ( 'id' ) ;
// 成員資格:record 必須在本實例的庫目錄列表內(庫數小,list 比對即可)
const libs = await listRecordsByTemplate ( c . env , LIBRARY_TEMPLATE ) ;
if ( ! libs . some ( ( l ) = > l . record_id === recordId ) ) return c . json ( { error : '庫不存在' } , 404 ) ;
const body = await c . req . json ( ) . catch ( ( ) = > null ) ;
if ( ! body ) return c . json ( { error : 'body 必須是 JSON' } , 400 ) ;
const patch : Record < string , string > = { } ;
if ( body . display_name !== undefined ) patch . display_name = String ( body . display_name ) . trim ( ) ;
if ( body . description !== undefined ) patch . description = String ( body . description ) . trim ( ) ;
if ( body . status !== undefined ) {
if ( body . status !== 'active' && body . status !== 'disabled' ) {
return c . json ( { error : 'status 只能是 active / disabled' } , 400 ) ;
}
patch . status = body . status ;
}
if ( Object . keys ( patch ) . length === 0 ) {
return c . json ( { error : '沒有可更新的欄位(display_name/description/status) ' } , 400 ) ;
}
const updated = await patchRecordValues ( c . env , recordId , patch ) ;
return c . json ( { success : true , library : toPublicLibrary ( updated ) } ) ;
} ) ,
) ;