portal-auth P2(#24 #25):portal_user 模型+認證 API(不 merge,待總管審) (#51)
This commit was merged in pull request #51.
This commit is contained in:
@@ -22,6 +22,7 @@ import type { RecipeDefinition, AuthRecipeDefinition } from './recipes';
|
||||
import { installRecipeRecord, resolveRecipe } from './recipes';
|
||||
import { API_RECIPE_SEEDS } from '../lib/api-recipe-seeds';
|
||||
import { AUTH_RECIPE_SEEDS } from '../lib/auth-recipe-seeds';
|
||||
import { ensurePortalTemplates } from './portal';
|
||||
|
||||
export const initSeedRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -82,15 +83,20 @@ initSeedRouter.post('/init/seed', async (c) => {
|
||||
}
|
||||
}
|
||||
|
||||
const allOk = apiFail === 0 && authFail === 0;
|
||||
// portal-auth P2(#24/#25):portal_user / portal_library template 也是「裝好後預設就緒」
|
||||
// 的種子(KBDB 萬用表 template,零新表),冪等 ensure(已存在跳過)。seed 是 API 行為(rule 07)。
|
||||
const portalTemplates = await ensurePortalTemplates(c.env);
|
||||
|
||||
const allOk = apiFail === 0 && authFail === 0 && portalTemplates.errors.length === 0;
|
||||
return c.json(
|
||||
{
|
||||
success: allOk,
|
||||
api_recipes: { seeded: apiOk, failed: apiFail, errors: apiErrors },
|
||||
auth_recipes: { seeded: authOk, failed: authFail, errors: authErrors },
|
||||
portal_templates: portalTemplates,
|
||||
message: allOk
|
||||
? `seed 完成:${apiOk} 個 API recipe + ${authOk} 個 auth recipe`
|
||||
: `seed 部分失敗(誠實回報,未假綠):API ${apiOk}✓/${apiFail}✗,auth ${authOk}✓/${authFail}✗`,
|
||||
? `seed 完成:${apiOk} 個 API recipe + ${authOk} 個 auth recipe + portal templates(新建 ${portalTemplates.created.length}/已存在 ${portalTemplates.existing.length})`
|
||||
: `seed 部分失敗(誠實回報,未假綠):API ${apiOk}✓/${apiFail}✗,auth ${authOk}✓/${authFail}✗,portal templates 錯誤 ${portalTemplates.errors.length}`,
|
||||
},
|
||||
allOk ? 200 : 207,
|
||||
);
|
||||
|
||||
@@ -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) });
|
||||
}),
|
||||
);
|
||||
Reference in New Issue
Block a user