diff --git a/README.md b/README.md index 72efdc9..06f874d 100644 --- a/README.md +++ b/README.md @@ -93,3 +93,8 @@ Domain detail pages support an on-demand public check. The refresh endpoint runs - Run `bun run db:migrate` before starting an upgraded deployment. The first version checks the domain’s DNS records, TLS certificate and existing registration/host enrichment. It does not run a separate HTTP status probe yet; scheduled portfolio-wide checks and webhook notifications are tracked separately. + + +## Role-based access control + +ProxyDeck supports **admin**, **operator**, and **viewer** roles enforced by the API. The first account in an existing installation is promoted to admin by the role migration; new accounts default to viewer. Administrators can inspect and change roles through the user-management API. See [RBAC documentation](docs/rbac.md) for the capability matrix, endpoints, migration behavior, and security model. diff --git a/docs/rbac.md b/docs/rbac.md new file mode 100644 index 0000000..5204235 --- /dev/null +++ b/docs/rbac.md @@ -0,0 +1,34 @@ +# Role-based access control + +Roles are stored on each Better Auth user record and enforced server-side for all authenticated API requests. + +## Roles + +| Capability | Admin | Operator | Viewer | +|---|:---:|:---:|:---:| +| Read domains and server inventory | Yes | Yes | Yes | +| Create/update/delete/import/export domains | Yes | Yes | No | +| Create/update/delete/import/export servers | Yes | Yes | No | +| Read proxy configuration, status, certificates and logs | Yes | Yes | Yes | +| Validate/apply/rollback proxy configuration | Yes | Yes | No | +| Read application settings | Yes | No | Yes | +| Change SMTP and authentication settings | Yes | No | No | +| List users and change roles | Yes | No | No | + +Operators manage operational resources but cannot change global settings or users. Viewers have read-only access to operational data. + +## Migration and first administrator + +Run `bun run db:migrate` during deployment. Migration `0004_add_user_roles` promotes the earliest-created existing account to `admin`. Other existing accounts and new accounts default to `viewer`. Signup cannot select a role. + +## User-management API + +Only admins are allowed: +- `GET /api/users` — list users, roles, and effective permissions. +- `PATCH /api/users/:id/role` with `{"role":"operator"}` — assign `admin`, `operator`, or `viewer`. + +The last administrator cannot demote themselves. Roles are read live from the database on each request, so changes take effect immediately. + +## Enforcement + +GET/HEAD/OPTIONS operations map to read permissions; POST/PUT/PATCH/DELETE map to write permissions. Unclassified API paths fail closed with 403. The previously public proxy status endpoint now requires a session and proxy-read access. Existing owner-scoped domain and server data queries are retained; roles do not grant access to another user's private inventory. diff --git a/drizzle/0004_add_user_roles.sql b/drizzle/0004_add_user_roles.sql new file mode 100644 index 0000000..9694db0 --- /dev/null +++ b/drizzle/0004_add_user_roles.sql @@ -0,0 +1,5 @@ +ALTER TABLE "user" ADD COLUMN "role" text NOT NULL DEFAULT 'viewer'; +-- Promote the first pre-existing account so current installations retain admin access. +WITH first_user AS (SELECT "id" FROM "user" ORDER BY "createdAt" ASC,"id" ASC LIMIT 1) +UPDATE "user" SET "role"='admin' WHERE "id" IN (SELECT "id" FROM first_user); +ALTER TABLE "user" ADD CONSTRAINT "user_role_check" CHECK ("role" IN ('admin','operator','viewer')); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index ae42a0c..c8380b9 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -5,6 +5,7 @@ { "idx": 0, "version": "7", "when": 1774095492703, "tag": "0000_spooky_odin", "breakpoints": true }, { "idx": 1, "version": "7", "when": 1774437795200, "tag": "0001_strange_jasper_sitwell", "breakpoints": true }, { "idx": 2, "version": "7", "when": 1775055276544, "tag": "0002_outstanding_bishop", "breakpoints": true }, - { "idx": 3, "version": "7", "when": 1793550000000, "tag": "0003_domain_check_runs", "breakpoints": true } + { "idx": 3, "version": "7", "when": 1793550000000, "tag": "0003_domain_check_runs", "breakpoints": true }, + { "idx": 4, "version": "7", "when": 1793707200000, "tag": "0004_add_user_roles", "breakpoints": true } ] } diff --git a/src/__tests__/roles.test.ts b/src/__tests__/roles.test.ts new file mode 100644 index 0000000..8927e34 --- /dev/null +++ b/src/__tests__/roles.test.ts @@ -0,0 +1,10 @@ +import {describe,expect,test} from "bun:test"; +import {hasPermission,permissionForApiRequest} from "../auth/roles"; +describe("role based access control",()=>{ + test("admin has every permission",()=>{for(const p of ["domains:read","domains:write","servers:read","servers:write","proxy:read","proxy:write","logs:read","settings:read","settings:write","users:read","users:write"] as const)expect(hasPermission("admin",p)).toBe(true)}); + test("operator can operate services but cannot manage users or instance settings",()=>{expect(hasPermission("operator","domains:write")).toBe(true);expect(hasPermission("operator","servers:write")).toBe(true);expect(hasPermission("operator","proxy:write")).toBe(true);expect(hasPermission("operator","settings:write")).toBe(false);expect(hasPermission("operator","users:write")).toBe(false)}); + test("viewer is read only",()=>{expect(hasPermission("viewer","domains:read")).toBe(true);expect(hasPermission("viewer","domains:write")).toBe(false);expect(hasPermission("viewer","proxy:write")).toBe(false)}); + test("API method mapping covers resource read and write operations",()=>{expect(permissionForApiRequest("GET","/api/domains")).toBe("domains:read");expect(permissionForApiRequest("POST","/api/domains")).toBe("domains:write");expect(permissionForApiRequest("DELETE","/api/servers/1")).toBe("servers:write");expect(permissionForApiRequest("POST","/api/config/apply")).toBe("proxy:write");expect(permissionForApiRequest("PUT","/api/settings/app")).toBe("settings:write");expect(permissionForApiRequest("GET","/api/logs")).toBe("logs:read")}); + test("unknown routes fail closed",()=>expect(permissionForApiRequest("GET","/api/new-feature")).toBe("admin:only")); + test("health and auth capability endpoints have no role requirement",()=>{expect(permissionForApiRequest("GET","/api/health")).toBeNull();expect(permissionForApiRequest("POST","/api/auth/sign-out")).toBeNull()}); +}); diff --git a/src/auth/middleware.ts b/src/auth/middleware.ts index ab64d5c..63e7bd3 100644 --- a/src/auth/middleware.ts +++ b/src/auth/middleware.ts @@ -5,58 +5,32 @@ import { session, user } from "../db/schema"; const PUBLIC_PATHS = ["/login", "/signup", "/api/auth", "/api/allow-signup"]; const STATIC_PREFIX = "/assets"; const SESSION_COOKIE_NAME = "better-auth.session_token"; - function isPublicPath(pathname: string): boolean { if (pathname.startsWith(STATIC_PREFIX)) return true; return PUBLIC_PATHS.some((p) => pathname === p || pathname.startsWith(p + "/")); } - function getCookie(headers: Headers, name: string): string | null { const cookie = headers.get("cookie") ?? headers.get("Cookie") ?? ""; const match = cookie.match(new RegExp(`${name}=([^;]+)`)); return match ? decodeURIComponent(match[1].trim()) : null; } - -/** Resolve session from DB using cookie token. */ +/** Resolve session and live role from DB on every request so role changes take effect immediately. */ async function getSessionFromDb(cookieValue: string): Promise<{ user: unknown } | null> { const tokenPart = cookieValue.split(".")[0]; - const rows = await db - .select({ - id: user.id, - name: user.name, - email: user.email, - emailVerified: user.emailVerified, - username: user.username, - }) - .from(session) - .innerJoin(user, eq(session.userId, user.id)) - .where( - and( - or(eq(session.token, cookieValue), eq(session.token, tokenPart)), - gt(session.expiresAt, sql`now()`) - ) - ) + const rows = await db.select({ + id: user.id, name: user.name, email: user.email, emailVerified: user.emailVerified, + username: user.username, role: user.role, + }).from(session).innerJoin(user, eq(session.userId, user.id)) + .where(and(or(eq(session.token,cookieValue),eq(session.token,tokenPart)),gt(session.expiresAt,sql`now()`))) .limit(1); const row = rows[0]; if (!row) return null; - return { - user: { - id: row.id, - name: row.name, - email: row.email, - emailVerified: row.emailVerified, - username: row.username, - }, - }; + return { user: { id:row.id,name:row.name,email:row.email,emailVerified:row.emailVerified,username:row.username,role:row.role } }; } - export async function getSession(requestOrHeaders: Request | Headers) { const headers = requestOrHeaders instanceof Request ? requestOrHeaders.headers : requestOrHeaders; const cookieValue = getCookie(headers, SESSION_COOKIE_NAME); if (!cookieValue) return null; return getSessionFromDb(cookieValue); } - -export function isProtectedPath(pathname: string): boolean { - return !isPublicPath(pathname); -} +export function isProtectedPath(pathname: string): boolean { return !isPublicPath(pathname); } diff --git a/src/auth/roles.ts b/src/auth/roles.ts new file mode 100644 index 0000000..abbe1b4 --- /dev/null +++ b/src/auth/roles.ts @@ -0,0 +1,20 @@ +export const USER_ROLES=["admin","operator","viewer"] as const; +export type UserRole=(typeof USER_ROLES)[number]; +export type Permission="admin:only"|"domains:read"|"domains:write"|"servers:read"|"servers:write"|"proxy:read"|"proxy:write"|"logs:read"|"settings:read"|"settings:write"|"users:read"|"users:write"; +const ROLE_PERMISSIONS:Record={admin:["domains:read","domains:write","servers:read","servers:write","proxy:read","proxy:write","logs:read","settings:read","settings:write","users:read","users:write"],operator:["domains:read","domains:write","servers:read","servers:write","proxy:read","proxy:write","logs:read"],viewer:["domains:read","servers:read","proxy:read","logs:read","settings:read"]}; +export function isUserRole(v:unknown):v is UserRole{return typeof v==="string"&&(USER_ROLES as readonly string[]).includes(v)} +export function hasPermission(role:UserRole,permission:Permission):boolean{return ROLE_PERMISSIONS[role].includes(permission)} +export function permissionsForRole(role:UserRole):readonly Permission[]{return ROLE_PERMISSIONS[role]} +export function permissionForApiRequest(method:string,pathname:string):Permission|null{ + const verb=method.toUpperCase(),write=["POST","PUT","PATCH","DELETE"].includes(verb); + if(pathname==="/api/health"||pathname==="/api/allow-signup"||pathname==="/api/auth-capabilities"||pathname==="/api/auth"||pathname.startsWith("/api/auth/")||pathname==="/api/docs"||pathname.startsWith("/api/docs/"))return null; + if(pathname==="/api/users"||pathname==="/api/users/")return "users:read"; + if(pathname.startsWith("/api/users/"))return write?"users:write":"users:read"; + if(pathname==="/api/settings/app")return write?"settings:write":"settings:read"; + if(pathname.startsWith("/api/domains"))return write?"domains:write":"domains:read"; + if(pathname.startsWith("/api/servers"))return write?"servers:write":"servers:read"; + if(pathname.startsWith("/api/config"))return write?"proxy:write":"proxy:read"; + if(pathname==="/api/proxy/status"||pathname==="/api/certificates")return "proxy:read"; + if(pathname==="/api/logs")return "logs:read"; + return "admin:only" as Permission; +} diff --git a/src/controllers/appSettings.controller.ts b/src/controllers/appSettings.controller.ts index 48d5109..fc9461c 100644 --- a/src/controllers/appSettings.controller.ts +++ b/src/controllers/appSettings.controller.ts @@ -1,6 +1,7 @@ import { Elysia } from "elysia"; import { jsonResponse, readJsonBody, toResponse } from "../http/json"; import { getUserIdFromRequest } from "../http/sessionUser"; +import { getSession } from "../auth/middleware"; import { type AppSettingsUpdateBody, getAppSettingsPublic, @@ -55,6 +56,8 @@ export const appSettingsRoutes = new Elysia().group("/api/settings", (app) => async ({ request }) => { const userId = await getUserIdFromRequest(request); if (!userId) return jsonResponse({ error: "Unauthorized" }, 401); + const session = await getSession(request); + if ((session?.user as { role?: string } | undefined)?.role !== "admin") return jsonResponse({ error: "Only administrators can update application settings" }, 403); const raw = await readJsonBody(request); if (raw === null) return jsonResponse({ error: "Expected application/json body" }, 400); try { diff --git a/src/controllers/system.controller.ts b/src/controllers/system.controller.ts index d702cfb..04abc93 100644 --- a/src/controllers/system.controller.ts +++ b/src/controllers/system.controller.ts @@ -5,49 +5,11 @@ import { allowSignup } from "../auth/allow-signup"; import { getEmailCapabilities } from "../services/appSettings.service"; import { getProxyStatusSafe } from "../services/proxyStatus.service"; import { readProxyLogTail } from "../services/logs.service"; - -const systemOpenapi = { - tags: ["system"], -}; - -export const systemRoutes = new Elysia() - .get("/api/health", () => jsonResponse({ ok: true, version: appVersion }), { - detail: { - ...systemOpenapi, - summary: "Health check", - }, - }) - .get("/api/allow-signup", async () => jsonResponse({ allowSignup: await allowSignup() }), { - detail: { - ...systemOpenapi, - summary: "Signup allowed?", - description: "Whether new account registration is permitted.", - }, - }) - .get("/api/auth-capabilities", async () => jsonResponse({ email: await getEmailCapabilities() }), { - detail: { - ...systemOpenapi, - summary: "Auth-related feature flags", - description: - "Public snapshot of which email-backed auth features are active (no secrets). Use for UI (e.g. show forgot-password link).", - }, - }) - .get("/api/proxy/status", async () => jsonResponse(await getProxyStatusSafe()), { - detail: { - ...systemOpenapi, - summary: "Proxy detection status", - }, - }) - .get("/api/certificates", () => jsonResponse([]), { - detail: { - ...systemOpenapi, - summary: "Certificates (stub)", - description: "Placeholder; returns an empty list until implemented.", - }, - }) - .get("/api/logs", () => jsonResponse({ lines: readProxyLogTail() }), { - detail: { - ...systemOpenapi, - summary: "Recent proxy log lines", - }, - }); +const systemOpenapi={tags:["system"]}; +export const systemRoutes=new Elysia() + .get("/api/health",()=>jsonResponse({ok:true,version:appVersion}),{detail:{...systemOpenapi,summary:"Health check"}}) + .get("/api/allow-signup",async()=>jsonResponse({allowSignup:await allowSignup()}),{detail:{...systemOpenapi,summary:"Signup allowed?"}}) + .get("/api/auth-capabilities",async()=>jsonResponse({email:await getEmailCapabilities()}),{detail:{...systemOpenapi,summary:"Auth-related feature flags"}}) + .get("/api/proxy/status",async()=>jsonResponse(await getProxyStatusSafe()),{detail:{...systemOpenapi,summary:"Proxy detection status"}}) + .get("/api/certificates",()=>jsonResponse([]),{detail:{...systemOpenapi,summary:"Certificates (stub)"}}) + .get("/api/logs",()=>jsonResponse({lines:readProxyLogTail()}),{detail:{...systemOpenapi,summary:"Recent proxy log lines"}}); diff --git a/src/controllers/user.controller.ts b/src/controllers/user.controller.ts new file mode 100644 index 0000000..81ecccc --- /dev/null +++ b/src/controllers/user.controller.ts @@ -0,0 +1,35 @@ +import { Elysia } from "elysia"; +import { eq } from "drizzle-orm"; +import { db } from "../db/client"; +import { user } from "../db/schema"; +import { getUserIdFromRequest } from "../http/sessionUser"; +import { isUserRole, permissionsForRole, USER_ROLES, type UserRole } from "../auth/roles"; +import { jsonResponse } from "../http/json"; + +export const userRoutes = new Elysia().group("/api/users", (app) => + app.get("/", async ({ request }) => { + const actorId = await getUserIdFromRequest(request); + if (!actorId) return jsonResponse({ error: "Unauthorized" }, 401); + const rows = await db.select({ + id:user.id,name:user.name,email:user.email,username:user.username,role:user.role,createdAt:user.createdAt, + }).from(user).orderBy(user.createdAt); + return jsonResponse({ roles:USER_ROLES, users:rows.map((entry) => ({ + ...entry, permissions:permissionsForRole(entry.role as UserRole), + })) }); + }).patch("/:id/role", async ({ request, params }) => { + const actorId = await getUserIdFromRequest(request); + if (!actorId) return jsonResponse({ error:"Unauthorized" },401); + let body: unknown; + try { body = await request.json(); } catch { return jsonResponse({ error:"Invalid JSON body" },400); } + const role = body && typeof body === "object" ? (body as { role?: unknown }).role : null; + if (!isUserRole(role)) return jsonResponse({ error:"role must be admin, operator, or viewer" },400); + if (params.id === actorId && role !== "admin") { + const admins = await db.select({ id:user.id }).from(user).where(eq(user.role,"admin")); + if (admins.length <= 1) return jsonResponse({ error:"The last administrator cannot demote themselves" },409); + } + const result = await db.update(user).set({role,updatedAt:new Date()}) + .where(eq(user.id,params.id)).returning({id:user.id,role:user.role}); + if (!result.length) return jsonResponse({error:"User not found"},404); + return jsonResponse({user:result[0],permissions:permissionsForRole(role)}); + }) +); diff --git a/src/db/schema.ts b/src/db/schema.ts index 5a6e474..758eeb0 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -17,6 +17,7 @@ export const user = pgTable("user", { email: text("email").notNull().unique(), emailVerified: boolean("emailVerified").notNull().default(false), image: text("image"), + role: text("role").$type<"admin" | "operator" | "viewer">().notNull().default("viewer"), username: text("username").unique(), displayUsername: text("displayUsername"), createdAt: timestamp("createdAt", { mode: "date", precision: 3 }) diff --git a/src/http/constants.ts b/src/http/constants.ts index 0f8d24b..807612e 100644 --- a/src/http/constants.ts +++ b/src/http/constants.ts @@ -1,9 +1,2 @@ -/** API paths that skip session checks (see `middleware/apiAuthGuard`). */ -export const PUBLIC_API_PATHS = [ - "/api/auth", - "/api/docs", - "/api/health", - "/api/allow-signup", - "/api/auth-capabilities", - "/api/proxy/status", -] as const; +/** Public API endpoints; keep the unauthenticated surface deliberately small. */ +export const PUBLIC_API_PATHS=["/api/auth","/api/docs","/api/health","/api/allow-signup","/api/auth-capabilities"] as const; diff --git a/src/middleware/apiAuthGuard.ts b/src/middleware/apiAuthGuard.ts index 28aed52..a4e91b3 100644 --- a/src/middleware/apiAuthGuard.ts +++ b/src/middleware/apiAuthGuard.ts @@ -1,51 +1,24 @@ import { getSession } from "../auth/middleware"; -import { PUBLIC_API_PATHS } from "../http/constants"; +import { hasPermission, permissionForApiRequest, type Permission, type UserRole } from "../auth/roles"; import { getAppSettingsRowCached, rowRequiresVerifiedSignIn } from "../services/appSettings.service"; -/** API prefixes reachable before email verification (when that policy is on). */ -const API_OK_BEFORE_EMAIL_VERIFIED = [ - "/api/auth", - "/api/health", - "/api/allow-signup", - "/api/auth-capabilities", - "/api/docs", - "/api/settings/app", -] as const; - -function isOkBeforeEmailVerified(pathname: string): boolean { - return API_OK_BEFORE_EMAIL_VERIFIED.some((p) => pathname === p || pathname.startsWith(`${p}/`)); -} - -export async function apiAuthGuard({ request }: { request: Request }): Promise { - const pathname = new URL(request.url).pathname; - if (!pathname.startsWith("/api")) return; - const isPublic = PUBLIC_API_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`)); - if (isPublic) return; - const session = await getSession(request); - if (!session) { - return new Response(JSON.stringify({ error: "Unauthorized" }), { - status: 401, - headers: { "Content-Type": "application/json" }, - }); - } - const settings = await getAppSettingsRowCached(); - if (rowRequiresVerifiedSignIn(settings)) { - const u = session.user; - if (u && typeof u === "object" && "emailVerified" in u && u.emailVerified === false) { - if (!isOkBeforeEmailVerified(pathname)) { - return new Response( - JSON.stringify({ - error: "Forbidden", - code: "EMAIL_NOT_VERIFIED", - message: "Verify your email to use this API.", - }), - { - status: 403, - headers: { "Content-Type": "application/json" }, - }, - ); - } - } - } - return; +const API_OK_BEFORE_EMAIL_VERIFIED=["/api/auth","/api/health","/api/allow-signup","/api/auth-capabilities","/api/docs"] as const; +function jsonError(status:number,error:string,code:string):Response{return new Response(JSON.stringify({error,code}),{status,headers:{"Content-Type":"application/json"}})} +export async function apiAuthGuard({request}:{request:Request}):Promise{ + const pathname=new URL(request.url).pathname; + if(!pathname.startsWith("/api"))return; + const isPublic=["/api/auth","/api/docs","/api/health","/api/allow-signup","/api/auth-capabilities"].some(p=>pathname===p||pathname.startsWith(p+"/")); + if(isPublic)return; + const currentSession=await getSession(request); + if(!currentSession)return jsonError(401,"Unauthorized","UNAUTHORIZED"); + const currentUser=currentSession.user as {emailVerified?:boolean;role?:unknown}; + const settings=await getAppSettingsRowCached(); + if(rowRequiresVerifiedSignIn(settings)&¤tUser.emailVerified===false&&!API_OK_BEFORE_EMAIL_VERIFIED.some(p=>pathname===p||pathname.startsWith(p+"/")))return jsonError(403,"Verify your email to use this API.","EMAIL_NOT_VERIFIED"); + const permission=permissionForApiRequest(request.method,pathname); + if(permission===null)return; + if(permission==="admin:only")return jsonError(403,"No authorization policy is defined for this API route.","ROLE_POLICY_MISSING"); + const role=currentUser.role; + if(role!=="admin"&&role!=="operator"&&role!=="viewer")return jsonError(403,"User role is missing or invalid.","INVALID_ROLE"); + if(!hasPermission(role as UserRole,permission as Permission))return jsonError(403,"Your role does not allow this action.","FORBIDDEN"); + return; } diff --git a/src/routes/createApp.ts b/src/routes/createApp.ts index 2f5bb26..65ea2be 100644 --- a/src/routes/createApp.ts +++ b/src/routes/createApp.ts @@ -6,43 +6,17 @@ import { domainRoutes } from "../controllers/domain.controller"; import { configApiRoutes } from "../controllers/configApi.controller"; import { infrastructureServerRoutes } from "../controllers/infrastructureServer.controller"; import { appSettingsRoutes } from "../controllers/appSettings.controller"; +import { userRoutes } from "../controllers/user.controller"; import { systemRoutes } from "../controllers/system.controller"; import { openapiDocsPlugin } from "../plugins/openapiDocs"; import { createSpaStaticHandler } from "../static/spaStatic"; - -/** - * Composes HTTP middleware, API route plugins, auth catch-all, and SPA static fallback. - */ -export function createApp(frontendDistDir: string) { - const serveStatic = createSpaStaticHandler(frontendDistDir); - - const forwardAuth = (request: Request) => getAuth().handler(request); - - return ( - new Elysia() - .use(requestLogPlugin()) - .onBeforeHandle(apiAuthGuard) - .use(domainRoutes) - .use(infrastructureServerRoutes) - .use(configApiRoutes) - .use(appSettingsRoutes) - .use(systemRoutes) - .use(openapiDocsPlugin()) - // Elysia matches `.get("/*")` before `.all("/api/auth/*")` for GET requests, which - // returned an empty body for get-session and broke the SPA session check. Register - // auth paths with explicit methods so they win over the SPA catch-all. - // `parse: "none"` keeps Elysia from consuming `request.json()` before Better Auth runs - // (avoids ERR_BODY_ALREADY_USED on sign-in/sign-up POSTs). - .get("/api/auth/*", ({ request }) => forwardAuth(request), { parse: "none" }) - .post("/api/auth/*", ({ request }) => forwardAuth(request), { parse: "none" }) - .options("/api/auth/*", ({ request }) => forwardAuth(request), { parse: "none" }) - .get("/*", ({ request }) => { - const pathname = new URL(request.url).pathname; - if (pathname.startsWith("/api")) return; - const res = serveStatic(pathname); - if (res) return res; - const indexRes = serveStatic("/index.html"); - return indexRes ?? new Response("Not Found", { status: 404 }); - }) - ); +export function createApp(frontendDistDir:string){ + const serveStatic=createSpaStaticHandler(frontendDistDir); + const forwardAuth=(request:Request)=>getAuth().handler(request); + return new Elysia().use(requestLogPlugin()).onBeforeHandle(apiAuthGuard) + .use(domainRoutes).use(infrastructureServerRoutes).use(configApiRoutes).use(appSettingsRoutes).use(userRoutes).use(systemRoutes).use(openapiDocsPlugin()) + .get("/api/auth/*",({request})=>forwardAuth(request),{parse:"none"}) + .post("/api/auth/*",({request})=>forwardAuth(request),{parse:"none"}) + .options("/api/auth/*",({request})=>forwardAuth(request),{parse:"none"}) + .get("/*",({request})=>{const pathname=new URL(request.url).pathname;if(pathname.startsWith("/api"))return;const res=serveStatic(pathname);if(res)return res;const indexRes=serveStatic("/index.html");return indexRes??new Response("Not Found",{status:404})}); }