Organize src/ into concern folders (http, auth, admin, plugin-host, ui); co-locate tests, move plugin-api barrel into plugin-host, sync docs + AGENTS layout
This commit is contained in:
File diff suppressed because it is too large
Load Diff
+598
@@ -0,0 +1,598 @@
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import * as ejs from "ejs";
|
||||
import { ADMIN_CLIENTS_BASE, ADMIN_GROUPS_BASE, ADMIN_ROLES_BASE, ADMIN_USERS_BASE } from "../admin/admin-nav.ts";
|
||||
import { type AdminClientsDeps, handleAdminClients } from "../admin/admin-clients.ts";
|
||||
import { type AdminGroupsDeps, handleAdminGroups } from "../admin/admin-groups.ts";
|
||||
import { type AdminRolesDeps, handleAdminRoles } from "../admin/admin-roles.ts";
|
||||
import { type AdminUsersDeps, handleAdminUsers } from "../admin/admin-users.ts";
|
||||
import { readFormBody } from "./body.ts";
|
||||
import { buildPluginChrome, type PageChrome } from "../ui/chrome.ts";
|
||||
import { buildContext, type User } from "./context.ts";
|
||||
import { CSRF_FIELD, csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "../auth/csrf.ts";
|
||||
import type { Denylist } from "../auth/denylist.ts";
|
||||
import { buildDashboardModel } from "../ui/dashboard.ts";
|
||||
import { PLUGINS_DIR } from "../plugin-host/discovery.ts";
|
||||
import { GuardError, loginRedirect } from "../auth/guards.ts";
|
||||
import { AUTH_FLOWS, buildFlowView } from "../auth/flow-view.ts";
|
||||
import { runRequestHooks, runResponseHooks } from "../plugin-host/hooks.ts";
|
||||
import { HydraError, type HydraAdmin } from "../auth/hydra-admin.ts";
|
||||
import type { JwksProvider } from "../auth/jwks.ts";
|
||||
import { resolveSession, type VerifyOptions } from "../auth/jwt-middleware.ts";
|
||||
import type { KetoClient } from "../auth/keto-client.ts";
|
||||
import type { KratosAdmin } from "../auth/kratos-admin.ts";
|
||||
import { type Flow, KratosError, type KratosPublic } from "../auth/kratos-public.ts";
|
||||
import { createLogger, type Log, requestLogger, runWithLog } from "../logger.ts";
|
||||
import { clearSessionCookie, completeLogin, remintSession, sessionCookie } from "../auth/login.ts";
|
||||
import { resolveLoginChallenge } from "../auth/oauth-login.ts";
|
||||
import { acceptConsent, rejectConsent, resolveConsentChallenge } from "../auth/oauth-consent.ts";
|
||||
import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts";
|
||||
import type { Plugin, RouteHandler, RouteResult } from "../plugin-host/plugin.ts";
|
||||
import { allowedMethods, isAuthorized, matchRoute } from "../plugin-host/router.ts";
|
||||
import { securityHeaders } from "./security-headers.ts";
|
||||
import { localPath } from "./safe-url.ts";
|
||||
import { routePublic, serveStatic } from "./static.ts";
|
||||
import { renderPluginView } from "../plugin-host/view-resolver.ts";
|
||||
|
||||
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
|
||||
export interface AppOptions {
|
||||
appUrl?: string; // canonical public URL (config.appUrl); off-host GET/HEAD visitors are 308'd here. Omitted ⇒ no redirect
|
||||
auth?: VerifyOptions; // expected JWT issuer/audience + clock skew (config); used with jwks
|
||||
// Cache compiled templates; caller decides (server passes config.cacheTemplates).
|
||||
// Off by default so edits show live; the app itself never inspects the environment.
|
||||
cache?: boolean;
|
||||
csrfSecret?: string; // HMAC key for the double-submit CSRF token (config.csrfSecret); random if omitted
|
||||
denylist?: Denylist; // optional instant-revoke; the hot path rejects revoked subjects, admin writes record revokes
|
||||
hydra?: HydraAdmin; // Hydra admin client; with kratos enables the OAuth2 login challenge
|
||||
jwks?: JwksProvider; // verify the session JWT → ctx.user/roles; absent ⇒ always anonymous
|
||||
keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion
|
||||
kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes
|
||||
kratosAdmin?: KratosAdmin; // Kratos admin client; with kratos+keto enables login completion
|
||||
log?: Log; // app-level logger; per-request access log + trace span. Default: silent (tests)
|
||||
menu?: MenuConfig; // central override + branding (config/menu.ts); defaults to DEFAULT_MENU
|
||||
plugins?: Plugin[]; // discovered manifests to mount (router); empty until discovery runs
|
||||
pluginsDir?: string; // where plugin views/static live; defaults to the scanned plugins/
|
||||
publicDir?: string;
|
||||
secureCookies?: boolean; // set Secure on our session/CSRF cookies (config.secureCookies; off in dev http)
|
||||
viewsDir?: string;
|
||||
}
|
||||
|
||||
export function createApp(options: AppOptions = {}): Server {
|
||||
// The denylist (when enabled) rides in the verify options so resolveSession rejects a revoked
|
||||
// subject on the hot path; the bound `revoke` is handed to the admin handlers that should
|
||||
// revoke instantly. Both absent ⇒ the feature is fully off (no cost, no behaviour change).
|
||||
const denylist = options.denylist;
|
||||
const authOptions: VerifyOptions = denylist ? { ...(options.auth ?? {}), denylist } : (options.auth ?? {});
|
||||
const revoke = denylist ? (sub: string): void => denylist.revoke(sub) : undefined;
|
||||
const cache = options.cache ?? false;
|
||||
// Canonical public host (APP_URL): when set, an off-host GET/HEAD visitor is redirected here so
|
||||
// every cookie (esp. Kratos' cross-origin CSRF cookie) shares one host. Omitted ⇒ feature off.
|
||||
const canonical = options.appUrl ? new URL(options.appUrl) : undefined;
|
||||
const canonicalHost = canonical?.host; // host[:port], default ports omitted — matches the Host header
|
||||
const canonicalOrigin = canonical?.origin; // scheme + host[:port], no trailing slash
|
||||
const csrfSecret = options.csrfSecret ?? randomBytes(32).toString("hex"); // server passes config; tests pass their own
|
||||
const secureCookies = options.secureCookies ?? false;
|
||||
const hydra = options.hydra;
|
||||
const jwks = options.jwks;
|
||||
const keto = options.keto;
|
||||
const kratos = options.kratos;
|
||||
const kratosAdmin = options.kratosAdmin;
|
||||
// Silent default so unit/integration tests stay quiet; server.ts injects the configured logger.
|
||||
const log = options.log ?? createLogger({ level: "none" });
|
||||
const menu = options.menu ?? DEFAULT_MENU;
|
||||
const plugins = options.plugins ?? [];
|
||||
const pluginIds = new Set(plugins.map((p) => p.id));
|
||||
// A plugin may fully replace the public landing "/" (`home`) or the gated dashboard "/dashboard"
|
||||
// (`dashboard`) — Discovery's findConflicts guarantees at most one of each, so `find` is
|
||||
// unambiguous; the predicates narrow the slot to defined.
|
||||
const homePlugin = plugins.find((p): p is Plugin & { home: RouteHandler } => typeof p.home === "function");
|
||||
const dashboardPlugin = plugins.find((p): p is Plugin & { dashboard: RouteHandler } => typeof p.dashboard === "function");
|
||||
// Skip the hook pipeline entirely unless a plugin declares the hook (keeps the hot path free).
|
||||
const anyRequestHooks = plugins.some((p) => p.hooks?.onRequest);
|
||||
const anyResponseHooks = plugins.some((p) => p.hooks?.onResponse);
|
||||
const pluginsDir = options.pluginsDir ?? PLUGINS_DIR;
|
||||
const publicDir = options.publicDir ?? join(rootDir, "public");
|
||||
const viewsDir = options.viewsDir ?? join(rootDir, "views");
|
||||
// Response security headers, fixed at boot (only HSTS depends on the https deployment signal).
|
||||
const secHeaderEntries = Object.entries(securityHeaders({ secure: secureCookies }));
|
||||
|
||||
// `views: [viewsDir]` lets a view in a subfolder (e.g. admin/users.ejs) include() the shared
|
||||
// partials/ by the same root-relative name top-level views use (EJS tries relative first).
|
||||
const render = (view: string, data: Record<string, unknown>): Promise<string> =>
|
||||
ejs.renderFile(join(viewsDir, `${view}.ejs`), data, { cache, views: [viewsDir] });
|
||||
|
||||
// A `view` RouteResult renders plugins/<id>/views/<view>.ejs; such views may include() the core
|
||||
// building-block partials (resolved from viewsDir) and their own partials/subfolders.
|
||||
const renderView = renderPluginView({ cache, coreViewsDir: viewsDir, pluginsDir });
|
||||
|
||||
// Built-in admin screens — wired only when their Ory clients are present (the writes go
|
||||
// there). They render core views via `render` and are gated/CSRF-guarded inside the handler.
|
||||
// Users writes to Kratos; Groups writes to Keto and reads users from Kratos for the pickers.
|
||||
const adminDeps: AdminUsersDeps | null = kratosAdmin ? { csrfSecret, kratosAdmin, menu, render, ...(revoke ? { revoke } : {}) } : null;
|
||||
const adminGroupsDeps: AdminGroupsDeps | null = kratosAdmin && keto ? { csrfSecret, keto, kratosAdmin, menu, render } : null;
|
||||
const adminRolesDeps: AdminRolesDeps | null = kratosAdmin && keto ? { csrfSecret, keto, kratosAdmin, menu, render, ...(revoke ? { revoke } : {}) } : null;
|
||||
// OAuth2 clients write to Hydra; wired only when the Hydra admin client is present.
|
||||
const adminClientsDeps: AdminClientsDeps | null = hydra ? { csrfSecret, hydra, menu, render } : null;
|
||||
|
||||
const sendHtml = (res: ServerResponse, status: number, html: string): void => {
|
||||
res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
|
||||
res.end(html);
|
||||
};
|
||||
|
||||
// The request handler. Run inside runWithLog (below) so the per-request logger is ambient: every
|
||||
// outbound fetch (the Ory clients via tracedFetch) and any deep module joins this request's trace
|
||||
// and correlation with no logger threaded through their signatures.
|
||||
const handleRequest = async (req: IncomingMessage, res: ServerResponse, reqLog: Log): Promise<void> => {
|
||||
try {
|
||||
const method = req.method ?? "GET";
|
||||
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
|
||||
|
||||
// Set before any branch so every response — static/redirect/error included — inherits them
|
||||
// (writeHead merges these with its own headers; a plugin's RouteResult.headers can override).
|
||||
for (const [name, value] of secHeaderEntries) res.setHeader(name, value);
|
||||
|
||||
if (pathname.startsWith("/public/") && (method === "GET" || method === "HEAD")) {
|
||||
// /public/<id>/… serves a plugin's public/; everything else the core public/.
|
||||
// Before auth: assets don't need a verified user, and the JWT cookie rides every request.
|
||||
const { dir, subPath } = routePublic(pathname.slice("/public/".length), publicDir, pluginsDir, pluginIds);
|
||||
await serveStatic(dir, subPath, res, method === "HEAD", (err) => reqLog.error("static stream error", { error: String(err) }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Canonical host (APP_URL): a visitor who reached us on a different host (localhost vs
|
||||
// 127.0.0.1, a secondary domain) is sent to the configured origin, path + query preserved, so
|
||||
// the browser, the themed forms, and the cross-origin Kratos POST all share one cookie host —
|
||||
// otherwise the host-scoped Kratos CSRF cookie is lost and login dumps onto /error. Static
|
||||
// assets above are served on any host (health checks). GET/HEAD only — a 308 must not replay a
|
||||
// cross-host POST; first-party forms are always served from a canonical page anyway.
|
||||
if (canonicalHost && (method === "GET" || method === "HEAD")) {
|
||||
const host = req.headers.host;
|
||||
if (host !== undefined && host !== canonicalHost) {
|
||||
res.writeHead(308, { location: canonicalOrigin + (req.url ?? "/") }).end();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the session JWT once (cached JWKS) → ctx.user/roles; none/invalid ⇒ anonymous.
|
||||
// If the token has lapsed but a live Kratos session still backs it (and we have the Ory
|
||||
// clients), silently re-mint it — "stay signed in": re-read roles from Keto, re-tokenize,
|
||||
// and set the fresh cookie via setHeader so it rides whatever response this request produces
|
||||
// (a dead session clears the stale cookie). This is the only place the hot path touches Ory.
|
||||
let user: User | null = null;
|
||||
if (jwks) {
|
||||
const auth = await resolveSession(req.headers.cookie, jwks, authOptions);
|
||||
user = auth.user;
|
||||
if (!user && auth.expired && keto && kratos && kratosAdmin) {
|
||||
try {
|
||||
const reminted = await remintSession({ keto, kratosAdmin, kratosPublic: kratos }, req.headers.cookie, { secure: secureCookies });
|
||||
user = reminted.user;
|
||||
res.appendHeader("set-cookie", reminted.setCookie);
|
||||
} catch (err) {
|
||||
// Ory unreachable (Kratos/Keto 5xx, refused, timeout) — degrade to anonymous instead of
|
||||
// 500ing every lapsed request. Leave the cookie alone: it can re-mint once Ory recovers.
|
||||
reqLog.warn("session re-mint failed (Ory unreachable?)", { error: String(err) });
|
||||
}
|
||||
}
|
||||
}
|
||||
// CSRF token for this request's first-party forms: reuse a genuine cookie token, else mint
|
||||
// one (the form page below Set-Cookies it). Verified on our own state-changing routes.
|
||||
const csrf = ensureCsrfToken(req.headers.cookie, csrfSecret);
|
||||
// Bound CSRF verifier handed to plugins via ctx.verifyCsrf (the host owns the secret).
|
||||
const verifyCsrf = (submitted: string | null | undefined): boolean =>
|
||||
verifyCsrfRequest({ cookieHeader: req.headers.cookie, secret: csrfSecret, submitted });
|
||||
// Chrome (brand/global-nav/user/theme/csrf) composes the whole menu, so it's resolved lazily and
|
||||
// at most once per request: this app-level memo shares it across the contexts below, and each
|
||||
// ctx.chrome getter only triggers it when a handler actually reads it (a json/redirect handler,
|
||||
// or the public "/" with a standalone home, never composes the menu).
|
||||
let chromeMemo: PageChrome | undefined;
|
||||
const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, menu, plugins, user }));
|
||||
|
||||
// base context (no route params yet); reused for onRequest + the built-in admin screens.
|
||||
const ctx = buildContext(req, res, { chrome, log: reqLog, user, verifyCsrf });
|
||||
|
||||
// Plugin onRequest hooks run before routing and may short-circuit the request.
|
||||
if (anyRequestHooks) {
|
||||
const short = await runRequestHooks(plugins, ctx);
|
||||
if (short) {
|
||||
// Set the fresh CSRF cookie like every other page-emitting path, so a form the hook
|
||||
// renders (its token is in ctx.chrome.csrfToken) has the matching double-submit cookie.
|
||||
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
|
||||
await sendResult(res, short.result, (view, data) => renderView(short.plugin.id, view, data));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin routes (any method): gate on the route's permission, then run the handler. The
|
||||
// handler gets ctx.chrome (native app shell) + ctx.verifyCsrf (guard its own forms); a fresh
|
||||
// CSRF cookie is set so those forms have a valid double-submit token.
|
||||
const match = matchRoute(plugins, method, pathname);
|
||||
if (match) {
|
||||
const routeCtx = buildContext(req, res, { chrome, log: reqLog, params: match.params, user, verifyCsrf });
|
||||
if (!isAuthorized(match.route, routeCtx.roles)) {
|
||||
// Anonymous → sign in (like the built-in screens' requireSession), remembering the page as
|
||||
// return_to; a signed-in user who simply lacks the role gets the 403 page.
|
||||
if (!routeCtx.user) { res.writeHead(303, { location: loginRedirect(routeCtx) }).end(); return; }
|
||||
reqLog.warn("forbidden: missing role", { path: pathname, required: match.route.permission ?? "", sub: routeCtx.user.id });
|
||||
sendHtml(res, 403, await render("403", { title: "Forbidden" }));
|
||||
return;
|
||||
}
|
||||
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
|
||||
const result = (await match.route.handler(routeCtx)) ?? null;
|
||||
if (anyResponseHooks) await runResponseHooks(plugins, routeCtx, result); // observers; a throw → 500
|
||||
await sendResult(res, result, (view, data) => renderView(match.plugin.id, view, data));
|
||||
return;
|
||||
}
|
||||
|
||||
// Built-in admin screens. Each handler gates (admin only; throws GuardError the catch
|
||||
// maps), CSRF-guards mutations, and returns html/redirect. Set the page's CSRF cookie when
|
||||
// freshly minted (its forms carry the matching token); null ⇒ unknown subpath → 404.
|
||||
if (adminDeps && pathname.startsWith(ADMIN_USERS_BASE)) {
|
||||
const result = await handleAdminUsers(ctx, csrf.token, adminDeps);
|
||||
if (result) {
|
||||
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
|
||||
await sendResult(res, result, () => Promise.reject(new Error("admin screens return html, not view")));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (adminGroupsDeps && pathname.startsWith(ADMIN_GROUPS_BASE)) {
|
||||
const result = await handleAdminGroups(ctx, csrf.token, adminGroupsDeps);
|
||||
if (result) {
|
||||
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
|
||||
await sendResult(res, result, () => Promise.reject(new Error("admin screens return html, not view")));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (adminRolesDeps && pathname.startsWith(ADMIN_ROLES_BASE)) {
|
||||
const result = await handleAdminRoles(ctx, csrf.token, adminRolesDeps);
|
||||
if (result) {
|
||||
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
|
||||
await sendResult(res, result, () => Promise.reject(new Error("admin screens return html, not view")));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (adminClientsDeps && pathname.startsWith(ADMIN_CLIENTS_BASE)) {
|
||||
const result = await handleAdminClients(ctx, csrf.token, adminClientsDeps);
|
||||
if (result) {
|
||||
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
|
||||
await sendResult(res, result, () => Promise.reject(new Error("admin screens return html, not view")));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Themed Kratos self-service pages (login/registration/recovery/verification/settings).
|
||||
const flowType = AUTH_FLOWS[pathname];
|
||||
if (kratos && flowType && (method === "GET" || method === "HEAD")) {
|
||||
// Already signed in? Re-authenticating / re-registering is pointless — send them to the app
|
||||
// dashboard. (/settings, /recovery, /verification stay reachable — a signed-in user can use those.)
|
||||
if (ctx.user && (pathname === "/login" || pathname === "/registration")) {
|
||||
res.writeHead(303, { location: "/dashboard" }).end();
|
||||
return;
|
||||
}
|
||||
const cookie = req.headers.cookie;
|
||||
const flowId = ctx.url.searchParams.get("flow");
|
||||
// Only the Kratos calls are in the try, so a render/buildFlowView bug below falls through to
|
||||
// the catch-all 500 (with a stack), not the "Ory unreachable" 503.
|
||||
let flow: Flow;
|
||||
try {
|
||||
if (!flowId) {
|
||||
// No flow yet: init one server-side, relay Kratos' CSRF cookie, bounce to ?flow=<id>.
|
||||
// A `return_to` is baked into the flow so Kratos lands there after login instead of the
|
||||
// default completion route. A first-party deep link (host-relative, from the gate's
|
||||
// return_to) is wrapped through /auth/complete so the session JWT is minted before the
|
||||
// user reaches the page; an absolute target (the OAuth2 login challenge) is passed
|
||||
// as-is — Kratos allow-lists it. localPath rejects an off-origin "//evil.com".
|
||||
const raw = ctx.url.searchParams.get("return_to");
|
||||
const local = localPath(raw);
|
||||
let returnTo: string | undefined;
|
||||
if (local) {
|
||||
const origin = `${secureCookies ? "https" : "http"}://${req.headers.host ?? "127.0.0.1:3000"}`;
|
||||
const complete = new URL(`${origin}/auth/complete`);
|
||||
complete.searchParams.set("return_to", local);
|
||||
returnTo = complete.toString();
|
||||
} else if (raw) returnTo = raw;
|
||||
const { flow: initiated, setCookie } = await kratos.initBrowserFlow(flowType, { ...(cookie ? { cookie } : {}), ...(returnTo ? { returnTo } : {}) });
|
||||
if (setCookie.length) res.appendHeader("set-cookie", setCookie);
|
||||
res.writeHead(303, { location: `${pathname}?flow=${initiated.id}` }).end();
|
||||
return;
|
||||
}
|
||||
flow = await kratos.getFlow(flowType, flowId, cookie ? { cookie } : {});
|
||||
} catch (err) {
|
||||
// Expired/unknown flow → restart by re-initialising (drop the stale ?flow=).
|
||||
if (err instanceof KratosError && [403, 404, 410].includes(err.status)) {
|
||||
res.writeHead(303, { location: pathname }).end();
|
||||
return;
|
||||
}
|
||||
// Already authenticated at Kratos but no app JWT yet (e.g. straight after registration, whose
|
||||
// `session` hook signs the user in but routes to verification, not /auth/complete — so ctx.user
|
||||
// is null and the "already signed in" short-circuit above can't fire). Initialising a login/
|
||||
// registration flow then returns Kratos 400 `session_already_available`. Recover by completing
|
||||
// login (mint the JWT from the live session), honouring return_to — never a 500.
|
||||
if (err instanceof KratosError && err.status === 400 && err.body.includes("session_already_available")) {
|
||||
const local = localPath(ctx.url.searchParams.get("return_to"));
|
||||
res.writeHead(303, { location: local ? `/auth/complete?return_to=${encodeURIComponent(local)}` : "/auth/complete" }).end();
|
||||
return;
|
||||
}
|
||||
// Ory unreachable (Kratos 5xx / connection refused / timeout): "Ory down ⇒ no logins" is
|
||||
// documented, so render an honest 503 rather than the catch-all "error on our end" 500.
|
||||
if (!(err instanceof KratosError) || err.status >= 500) {
|
||||
reqLog.warn("auth flow failed (Ory unreachable?)", { error: String(err), path: pathname });
|
||||
sendHtml(res, 503, await render("503", { title: "Sign-in unavailable" }));
|
||||
return;
|
||||
}
|
||||
throw err; // any other Kratos 4xx → the catch-all (genuinely unexpected)
|
||||
}
|
||||
// Rendered inside the unified app shell, so set a fresh CSRF cookie when minted — the
|
||||
// shell's Sign-out form (shown on /settings, where the user is signed in) needs the token.
|
||||
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
|
||||
sendHtml(res, 200, await render("auth", { chrome: ctx.chrome, flow: buildFlowView(flow, flowType) }));
|
||||
return;
|
||||
}
|
||||
|
||||
// OAuth2 login challenge: Hydra hands the browser here when another app logs in
|
||||
// *through* us. Resolve it via the Kratos session and accept; an unauthenticated user
|
||||
// bounces to our themed login and returns here once signed in. Provider-only.
|
||||
if (hydra && kratos && pathname === "/oauth2/login" && (method === "GET" || method === "HEAD")) {
|
||||
const challenge = ctx.url.searchParams.get("login_challenge");
|
||||
if (!challenge) {
|
||||
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end("Missing login_challenge");
|
||||
return;
|
||||
}
|
||||
// Absolute return target so Kratos lands back here post-login. Host reflects what the
|
||||
// browser used (so it matches Kratos' allowed_return_urls); scheme follows SECURE_COOKIES.
|
||||
// A spoofed Host can't escape — Kratos validates return_to against its allow-list.
|
||||
const origin = `${secureCookies ? "https" : "http"}://${req.headers.host ?? "127.0.0.1:3000"}`;
|
||||
const selfUrl = `${origin}/oauth2/login?login_challenge=${encodeURIComponent(challenge)}`;
|
||||
try {
|
||||
const { redirect } = await resolveLoginChallenge({ hydra, kratos }, challenge, req.headers.cookie, selfUrl);
|
||||
res.writeHead(303, { location: redirect }).end();
|
||||
} catch (err) {
|
||||
// A stale/invalid/consumed challenge (Hydra 4xx — back button, slow login, re-used URL) is
|
||||
// user-reachable: tell them to restart rather than 500. A 5xx (Hydra down) rethrows → 500.
|
||||
if (err instanceof HydraError && err.status < 500) {
|
||||
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end("This sign-in request has expired. Please start again from the application you were signing in to.");
|
||||
} else throw err;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// OAuth2 consent challenge: after login Hydra hands the browser here. A first-party
|
||||
// (or Hydra-skipped) client is auto-granted its scopes; a third-party client gets the themed
|
||||
// consent screen, whose CSRF-guarded POST accepts (Allow) or rejects (Deny). Provider-only.
|
||||
if (hydra && kratos && pathname === "/oauth2/consent") {
|
||||
const consentDeps = { hydra, kratos };
|
||||
try {
|
||||
if (method === "GET" || method === "HEAD") {
|
||||
const challenge = ctx.url.searchParams.get("consent_challenge");
|
||||
if (!challenge) {
|
||||
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end("Missing consent_challenge");
|
||||
return;
|
||||
}
|
||||
const { redirect, view } = await resolveConsentChallenge(consentDeps, challenge, req.headers.cookie);
|
||||
if (redirect) {
|
||||
res.writeHead(303, { location: redirect }).end();
|
||||
return;
|
||||
}
|
||||
// Third-party: show the consent screen, carrying a CSRF token its form echoes back.
|
||||
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
|
||||
sendHtml(res, 200, await render("oauth-consent", { brand: menu.branding.name, consent: view, csrfField: CSRF_FIELD, csrfToken: csrf.token }));
|
||||
return;
|
||||
}
|
||||
if (method === "POST") {
|
||||
const form = await readFormBody(req);
|
||||
if (!verifyCsrfRequest({ cookieHeader: req.headers.cookie, secret: csrfSecret, submitted: form.get(CSRF_FIELD) })) {
|
||||
reqLog.warn("csrf rejected", { path: pathname });
|
||||
sendHtml(res, 403, await render("403", { title: "Forbidden" }));
|
||||
return;
|
||||
}
|
||||
const challenge = form.get("consent_challenge");
|
||||
if (!challenge) {
|
||||
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end("Missing consent_challenge");
|
||||
return;
|
||||
}
|
||||
const redirect = form.get("decision") === "allow"
|
||||
? await acceptConsent(consentDeps, challenge, req.headers.cookie)
|
||||
: await rejectConsent(consentDeps, challenge);
|
||||
res.writeHead(303, { location: redirect }).end();
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
// Stale/consumed challenge (Hydra 4xx) → recoverable 400; a genuine outage (5xx) → 500 (as /oauth2/login).
|
||||
if (err instanceof HydraError && err.status < 500) {
|
||||
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end("This authorization request has expired. Please start again from the application you were signing in to.");
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// OAuth2 RP-initiated logout: Hydra hands the browser here to end the OAuth2 session
|
||||
// (hydra.yml urls.logout). Accept the challenge and resume to Hydra's post-logout redirect;
|
||||
// the first-party POST /logout (below) owns the Kratos session + our JWT cookie. Provider-only.
|
||||
// GET-accept is safe (like the login/consent handlers): the challenge is Hydra-minted +
|
||||
// single-use, so a forged GET can't fabricate one — we skip only the optional "confirm logout?".
|
||||
if (hydra && pathname === "/oauth2/logout" && (method === "GET" || method === "HEAD")) {
|
||||
const challenge = ctx.url.searchParams.get("logout_challenge");
|
||||
if (!challenge) {
|
||||
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end("Missing logout_challenge");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { redirect } = await hydra.acceptLogoutRequest(challenge);
|
||||
res.writeHead(303, { location: redirect }).end();
|
||||
} catch (err) {
|
||||
// Stale/consumed challenge (Hydra 4xx) → recoverable 400; a genuine outage (5xx) → 500.
|
||||
if (err instanceof HydraError && err.status < 500) {
|
||||
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end("This logout request has expired. Please start again from the application you were signing out of.");
|
||||
} else throw err;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Login completion: where Kratos lands the browser after authenticating (kratos.yml).
|
||||
// Mint our session JWT — read roles from Keto, project onto the identity, tokenize —
|
||||
// and store it as the cookie; no active session bounces back to sign in.
|
||||
if (pathname === "/auth/complete" && method === "GET" && kratos && kratosAdmin && keto) {
|
||||
const completed = await completeLogin({ keto, kratosAdmin, kratosPublic: kratos }, req.headers.cookie);
|
||||
if (!completed) {
|
||||
res.writeHead(303, { location: "/login" }).end();
|
||||
return;
|
||||
}
|
||||
res.appendHeader("set-cookie", sessionCookie(completed.jwt, { secure: secureCookies }));
|
||||
// Land on the deep link the user was headed to (return_to, validated host-relative so a
|
||||
// crafted ?return_to= can't make this an open redirect), else the gated dashboard.
|
||||
res.writeHead(303, { location: localPath(ctx.url.searchParams.get("return_to")) ?? "/dashboard" }).end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Logout: a state change, so a CSRF-guarded POST (the shell submits a form, not a GET link).
|
||||
// Clear our local JWT and revoke the Kratos session — Kratos' own cookie lives on its origin,
|
||||
// so redirect to its logout URL (it revokes the session, clears plainpages_session, then lands
|
||||
// on /login per kratos.yml). No active session ⇒ just clear our cookie and go to /login.
|
||||
if (pathname === "/logout" && method === "POST" && kratos) {
|
||||
const form = await readFormBody(req);
|
||||
if (!verifyCsrfRequest({ cookieHeader: req.headers.cookie, secret: csrfSecret, submitted: form.get(CSRF_FIELD) })) {
|
||||
reqLog.warn("csrf rejected", { path: pathname });
|
||||
sendHtml(res, 403, await render("403", { title: "Forbidden" }));
|
||||
return;
|
||||
}
|
||||
const flow = await kratos.createLogoutFlow(req.headers.cookie ? { cookie: req.headers.cookie } : {});
|
||||
res.appendHeader("set-cookie", clearSessionCookie({ secure: secureCookies }));
|
||||
reqLog.info("logout", { sub: user?.id ?? "" });
|
||||
res.writeHead(303, { location: flow?.logoutUrl ?? "/login" }).end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Kratos' self-service error sink (kratos.yml flows.error.ui_url → /error). A flow that fails a
|
||||
// security/expiry check redirects the browser here with ?id=<uuid>. Render a themed page with a
|
||||
// path back into sign-in instead of the catch-all 404 ("Page not found") it used to hit. The
|
||||
// canonical-host redirect above prevents the common cause (a lost cross-host CSRF cookie); this
|
||||
// is the honest fallback for any genuine flow error. The id is shown only for support reference.
|
||||
if (pathname === "/error" && (method === "GET" || method === "HEAD")) {
|
||||
sendHtml(res, 200, await render("error", { id: ctx.url.searchParams.get("id"), title: "Sign-in problem" }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === "/" && (method === "GET" || method === "HEAD")) {
|
||||
// The public landing: ungated — anyone may see it. A plugin may fully own it via `home`
|
||||
// (rendered against its own views, native shell via ctx.chrome, with a fresh CSRF cookie for
|
||||
// any form it ships). Else the built-in intro page with prominent sign-in / register links.
|
||||
if (homePlugin) {
|
||||
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
|
||||
const homeCtx = buildContext(req, res, { chrome, log: reqLog, user, verifyCsrf });
|
||||
const result = (await homePlugin.home(homeCtx)) ?? null;
|
||||
if (anyResponseHooks) await runResponseHooks(plugins, homeCtx, result);
|
||||
await sendResult(res, result, (view, data) => renderView(homePlugin.id, view, data));
|
||||
return;
|
||||
}
|
||||
// Default landing in the unified app shell: `user` picks "go to dashboard" vs sign-in,
|
||||
// and the shell's Sign-out form (when signed in) needs a fresh CSRF cookie.
|
||||
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
|
||||
sendHtml(res, 200, await render("home", { chrome: ctx.chrome, user }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === "/dashboard" && (method === "GET" || method === "HEAD")) {
|
||||
// The post-login app home, gated to a signed-in user: anonymous bounces to sign in,
|
||||
// remembering /dashboard as return_to.
|
||||
if (!user) { res.writeHead(303, { location: loginRedirect(ctx) }).end(); return; }
|
||||
// The page carries the Sign-out form, so Set-Cookie a fresh CSRF token here when absent.
|
||||
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
|
||||
// A plugin may fully own the dashboard: render its handler against its own views, native
|
||||
// shell via ctx.chrome — same path as a plugin route. Else the built-in mock-data People list.
|
||||
if (dashboardPlugin) {
|
||||
const dashCtx = buildContext(req, res, { chrome, log: reqLog, user, verifyCsrf });
|
||||
const result = (await dashboardPlugin.dashboard(dashCtx)) ?? null;
|
||||
if (anyResponseHooks) await runResponseHooks(plugins, dashCtx, result);
|
||||
await sendResult(res, result, (view, data) => renderView(dashboardPlugin.id, view, data));
|
||||
return;
|
||||
}
|
||||
// The one global menu (ctx.chrome.nav) + branding/override from config/menu.ts.
|
||||
sendHtml(res, 200, await render("index", { model: buildDashboardModel({ csrfToken: csrf.token, menu, nav: ctx.chrome.nav, user }) }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Known path, wrong method → 405 with Allow; otherwise nothing here → 404.
|
||||
const allow = allowedMethods(plugins, pathname);
|
||||
if (allow.length) {
|
||||
res.writeHead(405, { allow: allow.join(", "), "content-type": "text/plain; charset=utf-8" }).end("Method Not Allowed");
|
||||
return;
|
||||
}
|
||||
sendHtml(res, 404, await render("404", { title: "Not found" }));
|
||||
} catch (err) {
|
||||
// A guard thrown anywhere in handling maps to a response (not a 500): a `location` ⇒ a
|
||||
// redirect (requireSession → /login), otherwise the status renders the error page.
|
||||
if (err instanceof GuardError) {
|
||||
if (res.headersSent) return void res.end();
|
||||
if (err.location) return void res.writeHead(303, { location: err.location }).end();
|
||||
return void sendHtml(res, err.status, await render("403", { title: "Forbidden" }));
|
||||
}
|
||||
reqLog.error("unhandled request error", { error: err instanceof Error ? (err.stack ?? err.message) : String(err) });
|
||||
if (res.headersSent) return void res.end(); // a partial body is already on the wire
|
||||
try {
|
||||
// Render before writing: if the 500 page itself throws, headers stay unsent
|
||||
// and we fall back to plain text below instead of a half-written response.
|
||||
sendHtml(res, 500, await render("500", { title: "Server error" }));
|
||||
} catch (renderErr) {
|
||||
reqLog.error("error page render failed", { error: renderErr instanceof Error ? (renderErr.stack ?? renderErr.message) : String(renderErr) });
|
||||
res.writeHead(500, { "content-type": "text/plain; charset=utf-8" }).end("Internal Server Error");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return createServer((req, res) => {
|
||||
// Per-request log + trace span: a "request" span, continuing an upstream W3C traceparent
|
||||
// when present (distributed tracing across a proxy). "close" (not "finish") fires on both a
|
||||
// completed response and a premature disconnect/abort, so an aborted/truncated request is still
|
||||
// logged and its span flushed.
|
||||
const startMs = Date.now();
|
||||
const reqLog = requestLogger(log, {
|
||||
requestId: randomUUID(),
|
||||
...(typeof req.headers.traceparent === "string" ? { traceparent: req.headers.traceparent } : {}),
|
||||
});
|
||||
// end() must run exactly once, after BOTH the handler has fully unwound (settled) AND the
|
||||
// response has closed (the access line is then emitted with the final status). Ending earlier
|
||||
// would throw "already ended" from a still-running handler's ctx.log/tracedFetch on a client
|
||||
// abort, or drop the access line on the happy path (handler settles before close). Coordinating
|
||||
// the two signals avoids both. Logging must never crash a served request, so it's all guarded.
|
||||
let settled = false;
|
||||
let closed = false;
|
||||
const finalize = (): void => { if (settled && closed) void reqLog.end().catch(() => {}); };
|
||||
res.on("close", () => {
|
||||
closed = true;
|
||||
try {
|
||||
// path only (no query — it may carry tokens); method/status are header-safe here.
|
||||
reqLog.info("request", { method: req.method ?? "GET", ms: Date.now() - startMs, path: (req.url ?? "/").split("?", 1)[0] ?? "/", status: res.statusCode });
|
||||
} catch { /* never let logging crash a served request */ }
|
||||
finalize();
|
||||
});
|
||||
// Make reqLog ambient for the whole handler (sync body + every await) so all outbound fetch is
|
||||
// traced. handleRequest owns its own try/catch; the .catch logs a pathological escape via the
|
||||
// app logger (not reqLog, which may be the thing that broke), never crashing the request.
|
||||
void runWithLog(reqLog, () => handleRequest(req, res, reqLog))
|
||||
.catch((err) => log.error("request handler escaped its try/catch", { error: err instanceof Error ? (err.stack ?? err.message) : String(err) }))
|
||||
.finally(() => { settled = true; finalize(); });
|
||||
});
|
||||
}
|
||||
|
||||
type ViewRenderer = (view: string, data: Record<string, unknown>) => Promise<string>;
|
||||
|
||||
// Turn a handler's RouteResult into the HTTP response. `null` = the handler took over `ctx.res`
|
||||
// itself (the void escape hatch). Author `headers` override the content-type default.
|
||||
async function sendResult(res: ServerResponse, result: RouteResult | null, renderView: ViewRenderer): Promise<void> {
|
||||
if (result == null || res.writableEnded) return;
|
||||
if ("redirect" in result) {
|
||||
res.writeHead(result.status ?? 303, { location: result.redirect }).end();
|
||||
return;
|
||||
}
|
||||
if ("json" in result) {
|
||||
res.writeHead(result.status ?? 200, { "content-type": "application/json; charset=utf-8", ...result.headers });
|
||||
res.end(JSON.stringify(result.json));
|
||||
return;
|
||||
}
|
||||
const body = "html" in result ? result.html : await renderView(result.view, result.data ?? {});
|
||||
res.writeHead(result.status ?? 200, { "content-type": "text/html; charset=utf-8", ...result.headers });
|
||||
res.end(body); // Node suppresses the body for HEAD automatically
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import assert from "node:assert/strict";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import { Readable } from "node:stream";
|
||||
import { test } from "node:test";
|
||||
import { readFormBody } from "./body.ts";
|
||||
|
||||
const reqOf = (body: string): IncomingMessage => Readable.from([Buffer.from(body)]) as unknown as IncomingMessage;
|
||||
|
||||
test("readFormBody parses urlencoded fields, handles an empty body, and caps the size", async () => {
|
||||
const form = await readFormBody(reqOf("_csrf=abc.def&name=Sam+Rivers"));
|
||||
assert.equal(form.get("_csrf"), "abc.def");
|
||||
assert.equal(form.get("name"), "Sam Rivers");
|
||||
|
||||
assert.equal([...(await readFormBody(reqOf("")))].length, 0); // empty body ⇒ no fields, no throw
|
||||
|
||||
await assert.rejects(() => readFormBody(reqOf("x".repeat(50)), { limit: 10 }), /limit/);
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
// Read an application/x-www-form-urlencoded request body. Our own POST forms are
|
||||
// tiny, so cap the size and reject anything larger rather than buffer unbounded. Consumes the
|
||||
// stream once; never throws on an empty body. The CSRF gate + admin forms read fields here.
|
||||
import type { IncomingMessage } from "node:http";
|
||||
|
||||
const DEFAULT_LIMIT = 1024 * 1024; // 1 MiB
|
||||
|
||||
export async function readFormBody(req: IncomingMessage, options: { limit?: number } = {}): Promise<URLSearchParams> {
|
||||
const limit = options.limit ?? DEFAULT_LIMIT;
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
for await (const chunk of req) {
|
||||
const buf = chunk as Buffer;
|
||||
size += buf.length;
|
||||
if (size > limit) throw new Error("request body exceeds limit");
|
||||
chunks.push(buf);
|
||||
}
|
||||
return new URLSearchParams(Buffer.concat(chunks).toString("utf8"));
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { Socket } from "node:net";
|
||||
import { test } from "node:test";
|
||||
import { buildContext, type User } from "./context.ts";
|
||||
import { createLogger } from "../logger.ts";
|
||||
|
||||
// A req/res pair without a live server — enough to build and inspect a context.
|
||||
function reqRes(url?: string): { req: IncomingMessage; res: ServerResponse } {
|
||||
const req = new IncomingMessage(new Socket());
|
||||
if (url !== undefined) req.url = url;
|
||||
req.method = "GET";
|
||||
return { req, res: new ServerResponse(req) };
|
||||
}
|
||||
|
||||
test("buildContext parses the URL, exposes query, and defaults to an anonymous user", () => {
|
||||
const { req, res } = reqRes("/users?q=ann&page=2");
|
||||
const ctx = buildContext(req, res);
|
||||
assert.equal(ctx.req, req);
|
||||
assert.equal(ctx.res, res);
|
||||
assert.equal(ctx.url.pathname, "/users");
|
||||
assert.equal(ctx.query, ctx.url.searchParams); // same instance, not a copy
|
||||
assert.equal(ctx.query.get("q"), "ann");
|
||||
assert.equal(ctx.query.get("page"), "2");
|
||||
assert.equal(ctx.user, null);
|
||||
assert.deepEqual(ctx.roles, []);
|
||||
assert.deepEqual(ctx.params, {});
|
||||
});
|
||||
|
||||
test("buildContext threads path params supplied by the router", () => {
|
||||
const { req, res } = reqRes("/users/42");
|
||||
const ctx = buildContext(req, res, { params: { id: "42" } });
|
||||
assert.equal(ctx.params.id, "42");
|
||||
});
|
||||
|
||||
test("buildContext threads the user and derives roles from it", () => {
|
||||
const { req, res } = reqRes("/");
|
||||
const user: User = { email: "a@b.c", id: "u1", roles: ["admin", "editor"] };
|
||||
const ctx = buildContext(req, res, { user });
|
||||
assert.equal(ctx.user, user);
|
||||
assert.equal(ctx.roles, user.roles); // same reference, never a divergent copy — buildContext is the only writer
|
||||
});
|
||||
|
||||
test("buildContext defaults a missing request URL to /", () => {
|
||||
const { req, res } = reqRes();
|
||||
assert.equal(buildContext(req, res).url.pathname, "/");
|
||||
});
|
||||
|
||||
test("buildContext provides a logger: a silent default, or the host's request logger", () => {
|
||||
const { req, res } = reqRes("/");
|
||||
assert.equal(typeof buildContext(req, res).log.info, "function"); // always present (silent default)
|
||||
const log = createLogger({ level: "none" });
|
||||
assert.equal(buildContext(req, res, { log }).log, log); // host's request logger threads through
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { PageChrome } from "../ui/chrome.ts"; // type-only: no runtime import, so no cycle
|
||||
import { createLogger, type Log } from "../logger.ts";
|
||||
|
||||
// The request context threaded to every route handler (plugin + built-in), built once
|
||||
// per request by `buildContext`: the router supplies matched path `params`, the JWT
|
||||
// middleware supplies `user` (null until then). The host's single handler argument.
|
||||
|
||||
// The authenticated user, projected from verified session JWT claims:
|
||||
// `id` = `sub`, plus `email` and the coarse `roles` carried in the token.
|
||||
export interface User {
|
||||
email: string;
|
||||
id: string;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
export interface RequestContext {
|
||||
// Page chrome (brand/global-nav/user/theme/csrf) a plugin view hands to partials/shell so its
|
||||
// page renders the native app shell; the host builds it per request (anonymous default otherwise).
|
||||
chrome: PageChrome;
|
||||
// Request-scoped logger: structured, in the request's trace. `log.info/warn/error(...)` to
|
||||
// log; `log.fetch(url)` for an upstream call (a client span continuing the trace). Correlates by
|
||||
// requestId. Additive, stable per the contract; defaults to a silent logger off the request path.
|
||||
log: Log;
|
||||
params: Record<string, string>; // path params from the route match, e.g. /users/:id → { id }
|
||||
query: URLSearchParams; // alias of url.searchParams, for ctx.query.get("q")
|
||||
req: IncomingMessage;
|
||||
res: ServerResponse;
|
||||
roles: string[]; // user?.roles ?? [] — coarse gate without a null-check
|
||||
url: URL;
|
||||
user: User | null;
|
||||
// Gate a first-party form submission: true iff `submitted` matches this request's signed CSRF
|
||||
// cookie (double-submit). The host binds the secret; a plugin calls it after reading its body.
|
||||
verifyCsrf(submitted: string | null | undefined): boolean;
|
||||
}
|
||||
|
||||
export interface BuildContextOptions {
|
||||
// Lazy chrome factory: composing the global menu is only paid for if the handler actually reads
|
||||
// ctx.chrome (a json/redirect handler, or the public "/" with a standalone home, pays nothing).
|
||||
// The host's factory is memoised, so the menu composes at most once per request across contexts.
|
||||
chrome?: () => PageChrome;
|
||||
log?: Log;
|
||||
params?: Record<string, string>;
|
||||
user?: User | null;
|
||||
verifyCsrf?: (submitted: string | null | undefined) => boolean;
|
||||
}
|
||||
|
||||
// Anonymous default chrome — used until the host supplies a real one (built-in routes, tests).
|
||||
const ANON_CHROME: PageChrome = { brand: { name: "Plainpages" }, csrfToken: "", nav: [], signInHref: "/login", user: { email: "", initials: "G", name: "Guest" } };
|
||||
// Silent default logger — used off the request path (built-in routes built ad hoc, tests) until the
|
||||
// host supplies the real request logger. One instance, no output, negligible cost.
|
||||
const SILENT_LOG = createLogger({ level: "none" });
|
||||
|
||||
export function buildContext(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
options: BuildContextOptions = {},
|
||||
): RequestContext {
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
const user = options.user ?? null;
|
||||
const buildChrome = options.chrome;
|
||||
let chromeMemo: PageChrome | undefined; // resolve the factory at most once per context
|
||||
return {
|
||||
get chrome(): PageChrome { return (chromeMemo ??= buildChrome ? buildChrome() : ANON_CHROME); },
|
||||
log: options.log ?? SILENT_LOG,
|
||||
params: options.params ?? {},
|
||||
query: url.searchParams,
|
||||
req,
|
||||
res,
|
||||
roles: user?.roles ?? [],
|
||||
url,
|
||||
user,
|
||||
verifyCsrf: options.verifyCsrf ?? (() => false), // fail-closed unless the host binds the secret
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { parseCookies, serializeCookie } from "./cookie.ts";
|
||||
|
||||
// parseCookies returns a null-prototype map; spread into a plain object to deep-equal.
|
||||
const flat = (header: string | undefined): Record<string, string> => ({ ...parseCookies(header) });
|
||||
|
||||
test("parseCookies returns an empty object for an absent or empty header", () => {
|
||||
assert.deepEqual(flat(undefined), {});
|
||||
assert.deepEqual(flat(""), {});
|
||||
});
|
||||
|
||||
test("parseCookies splits pairs, trims, keeps `=` in values, and skips nameless/`=`-less pairs", () => {
|
||||
assert.deepEqual(flat("a=1; b=2"), { a: "1", b: "2" });
|
||||
assert.deepEqual(flat(" a = 1 ;b= 2"), { a: "1", b: "2" });
|
||||
assert.deepEqual(flat("t=ey.Jh=="), { t: "ey.Jh==" }); // `=` inside a base64/JWT value is kept
|
||||
assert.deepEqual(flat("novalue; =orphan; a=1"), { a: "1" });
|
||||
});
|
||||
|
||||
test("parseCookies decodes percent-encoded values, raw on malformed", () => {
|
||||
assert.equal(parseCookies("a=one%20two").a, "one two");
|
||||
assert.equal(parseCookies("a=%E0%A4%A").a, "%E0%A4%A"); // invalid escape → untouched, no throw
|
||||
});
|
||||
|
||||
test("parseCookies strips one layer of surrounding double-quotes", () => {
|
||||
assert.equal(parseCookies('a="quoted"').a, "quoted");
|
||||
});
|
||||
|
||||
test("parseCookies keeps the first occurrence of a duplicate name", () => {
|
||||
assert.equal(parseCookies("a=first; a=second").a, "first");
|
||||
});
|
||||
|
||||
test("parseCookies is not vulnerable to prototype pollution", () => {
|
||||
const parsed = parseCookies("__proto__=polluted; a=1");
|
||||
assert.equal(Object.getPrototypeOf(parsed), null); // null-prototype map
|
||||
assert.equal(parsed["__proto__"], "polluted"); // stored as a plain own key, not the prototype
|
||||
assert.equal(parsed.a, "1");
|
||||
assert.equal(Object.getPrototypeOf({}), Object.prototype); // global prototype untouched
|
||||
});
|
||||
|
||||
test("serializeCookie emits name=value, encoding specials but leaving JWT chars (-_.) readable", () => {
|
||||
assert.equal(serializeCookie("session", "abc"), "session=abc");
|
||||
assert.equal(serializeCookie("session", "a b&c"), "session=a%20b%26c");
|
||||
assert.equal(serializeCookie("session", "ab-_.cd"), "session=ab-_.cd");
|
||||
});
|
||||
|
||||
test("serializeCookie appends the secure-by-default attribute flags", () => {
|
||||
const out = serializeCookie("session", "x", { httpOnly: true, path: "/", sameSite: "Lax", secure: true });
|
||||
assert.equal(out, "session=x; Path=/; HttpOnly; SameSite=Lax; Secure");
|
||||
});
|
||||
|
||||
test("serializeCookie writes Max-Age (incl. non-positive, expire-now) and rejects a non-integer", () => {
|
||||
assert.match(serializeCookie("a", "1", { maxAge: 600 }), /; Max-Age=600(;|$)/);
|
||||
assert.match(serializeCookie("a", "1", { maxAge: 0 }), /; Max-Age=0(;|$)/);
|
||||
assert.match(serializeCookie("a", "1", { maxAge: -1 }), /; Max-Age=-1(;|$)/);
|
||||
assert.throws(() => serializeCookie("a", "1", { maxAge: 1.5 }), /integer/);
|
||||
});
|
||||
|
||||
test("serializeCookie writes Expires from a Date and rejects invalid or out-of-range ones", () => {
|
||||
assert.match(serializeCookie("a", "1", { expires: new Date(0) }), /; Expires=Thu, 01 Jan 1970 00:00:00 GMT/);
|
||||
assert.throws(() => serializeCookie("a", "1", { expires: new Date("nope") }), /Expires/);
|
||||
// toUTCString() of a year > 9999 yields a 6-digit year browsers may reject — fail loud instead.
|
||||
assert.throws(() => serializeCookie("a", "1", { expires: new Date(8640000000000000) }), /Expires/);
|
||||
});
|
||||
|
||||
test("serializeCookie writes Domain/Path and rejects empty or injecting values", () => {
|
||||
const out = serializeCookie("a", "1", { domain: "example.com", path: "/admin" });
|
||||
assert.match(out, /; Domain=example\.com/);
|
||||
assert.match(out, /; Path=\/admin/);
|
||||
assert.throws(() => serializeCookie("a", "1", { domain: "" }), /domain/); // misconfigured deploy
|
||||
assert.throws(() => serializeCookie("a", "1", { path: "" }), /path/);
|
||||
assert.throws(() => serializeCookie("a", "1", { path: "/x; Domain=evil.com" }), /path/); // attribute injection
|
||||
assert.throws(() => serializeCookie("a", "1", { domain: "evil\r\nSet-Cookie: x=y" }), /domain/); // header split
|
||||
});
|
||||
|
||||
test("serializeCookie rejects SameSite=None without Secure (browsers would drop it)", () => {
|
||||
assert.throws(() => serializeCookie("a", "1", { sameSite: "None" }), /Secure/);
|
||||
assert.doesNotThrow(() => serializeCookie("a", "1", { sameSite: "None", secure: true }));
|
||||
});
|
||||
|
||||
test("serializeCookie rejects an invalid cookie name", () => {
|
||||
assert.throws(() => serializeCookie("bad name", "1"), /name/);
|
||||
assert.throws(() => serializeCookie("a;b", "1"), /name/);
|
||||
});
|
||||
|
||||
test("serializeCookie and parseCookies round-trip an arbitrary value", () => {
|
||||
const value = "header.payload.sig with spaces & symbols=";
|
||||
const setCookie = serializeCookie("session", value, { httpOnly: true });
|
||||
const cookieHeader = setCookie.split("; ")[0]; // browsers send only name=value
|
||||
assert.equal(parseCookies(cookieHeader).session, value);
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
// Cookie helpers — parse the request `Cookie` header, build secure-by-default
|
||||
// `Set-Cookie` headers. Stdlib only (no `cookie` dep); stores/clears the session
|
||||
// JWT + CSRF token here. Values round-trip via percent-encoding; JWT `-_.` chars are
|
||||
// URI-unreserved, so JWTs stay readable.
|
||||
|
||||
export interface CookieOptions {
|
||||
domain?: string;
|
||||
expires?: Date;
|
||||
httpOnly?: boolean;
|
||||
maxAge?: number; // seconds; 0 / negative expire the cookie immediately
|
||||
path?: string;
|
||||
sameSite?: "Lax" | "None" | "Strict";
|
||||
secure?: boolean;
|
||||
}
|
||||
|
||||
// RFC 6265 cookie-name token: no control chars, whitespace, or separators.
|
||||
const cookieName = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
||||
|
||||
// Cookie Expires must be a 4-digit-year HTTP-date (RFC 1123); a Date outside this
|
||||
// range makes toUTCString() emit a 6-digit/negative year browsers may reject.
|
||||
const minExpires = Date.UTC(1601, 0, 1);
|
||||
const maxExpires = Date.UTC(9999, 11, 31, 23, 59, 59, 999);
|
||||
|
||||
function decode(value: string): string {
|
||||
if (!value.includes("%")) return value; // fast path: nothing to decode
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value; // malformed input is untrusted — keep raw rather than throw
|
||||
}
|
||||
}
|
||||
|
||||
// Parse a `Cookie` header into a name→value map. First occurrence of a name wins.
|
||||
// Null-prototype result, so a `__proto__`/`constructor` key can't pollute. Header
|
||||
// length is bounded upstream by Node's `maxHeaderSize` (~16 KB).
|
||||
export function parseCookies(header: string | undefined): Record<string, string> {
|
||||
const out: Record<string, string> = Object.create(null);
|
||||
if (!header) return out;
|
||||
|
||||
for (const pair of header.split(";")) {
|
||||
const eq = pair.indexOf("=");
|
||||
if (eq < 0) continue;
|
||||
const name = pair.slice(0, eq).trim();
|
||||
if (!name || name in out) continue;
|
||||
let value = pair.slice(eq + 1).trim();
|
||||
if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1);
|
||||
out[name] = decode(value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Validate a Domain/Path attribute: non-empty (fail loud on a misconfig) and free of
|
||||
// chars that could inject extra attributes or split the header (CRLF). Cheap insurance
|
||||
// against Set-Cookie injection, even though these come from config.
|
||||
function assertAttrSafe(label: string, value: string): void {
|
||||
if (value === "" || /[;\x00-\x1f\x7f]/.test(value)) throw new Error(`invalid cookie ${label}: ${JSON.stringify(value)}`);
|
||||
}
|
||||
|
||||
// Build a `Set-Cookie` header value. Throws on inputs that would produce a
|
||||
// malformed or injectable header.
|
||||
export function serializeCookie(name: string, value: string, options: CookieOptions = {}): string {
|
||||
if (!cookieName.test(name)) throw new Error(`invalid cookie name: ${JSON.stringify(name)}`);
|
||||
|
||||
const parts = [`${name}=${encodeURIComponent(value)}`];
|
||||
|
||||
if (options.maxAge !== undefined) {
|
||||
if (!Number.isInteger(options.maxAge)) throw new Error("cookie maxAge must be an integer number of seconds");
|
||||
parts.push(`Max-Age=${options.maxAge}`);
|
||||
}
|
||||
if (options.domain !== undefined) {
|
||||
assertAttrSafe("domain", options.domain);
|
||||
parts.push(`Domain=${options.domain}`);
|
||||
}
|
||||
if (options.path !== undefined) {
|
||||
assertAttrSafe("path", options.path);
|
||||
parts.push(`Path=${options.path}`);
|
||||
}
|
||||
if (options.expires !== undefined) {
|
||||
const t = options.expires.getTime();
|
||||
if (Number.isNaN(t)) throw new Error("cookie Expires is an invalid Date");
|
||||
if (t < minExpires || t > maxExpires) throw new Error("cookie Expires year is out of the 4-digit RFC range");
|
||||
parts.push(`Expires=${options.expires.toUTCString()}`);
|
||||
}
|
||||
if (options.httpOnly) parts.push("HttpOnly");
|
||||
if (options.sameSite !== undefined) {
|
||||
if (options.sameSite === "None" && !options.secure) throw new Error("SameSite=None requires Secure");
|
||||
parts.push(`SameSite=${options.sameSite}`);
|
||||
}
|
||||
if (options.secure) parts.push("Secure");
|
||||
|
||||
return parts.join("; ");
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { localPath, safeUrl } from "./safe-url.ts";
|
||||
|
||||
test("safeUrl: passes relative + http(s) through, neutralises dangerous schemes", () => {
|
||||
// Relative forms (no scheme) and http(s) are rendered as-is.
|
||||
assert.equal(safeUrl("/admin/users?q=1#f"), "/admin/users?q=1#f");
|
||||
assert.equal(safeUrl("?q=1"), "?q=1");
|
||||
assert.equal(safeUrl("#frag"), "#frag");
|
||||
assert.equal(safeUrl("shifts/edit"), "shifts/edit");
|
||||
assert.equal(safeUrl("http://example.com/x"), "http://example.com/x");
|
||||
assert.equal(safeUrl("https://example.com/x"), "https://example.com/x");
|
||||
assert.equal(safeUrl("HTTPS://EXAMPLE.com"), "HTTPS://EXAMPLE.com"); // scheme match is case-insensitive
|
||||
// Any other scheme (the contract is: relative or http(s) only) ⇒ neutralised to "#".
|
||||
assert.equal(safeUrl("javascript:alert(1)"), "#");
|
||||
assert.equal(safeUrl("data:text/html,<script>alert(1)</script>"), "#");
|
||||
assert.equal(safeUrl("vbscript:msgbox(1)"), "#");
|
||||
assert.equal(safeUrl("mailto:x@y.z"), "#");
|
||||
// Control-char / leading-whitespace obfuscation can't slip a scheme past the check (browsers
|
||||
// strip TAB/CR/LF and leading controls before resolving the scheme).
|
||||
assert.equal(safeUrl("java\tscript:alert(1)"), "#");
|
||||
assert.equal(safeUrl("java\nscript:alert(1)"), "#");
|
||||
assert.equal(safeUrl(" javascript:alert(1)"), "#");
|
||||
// Empty / control-only ⇒ a safe no-op href.
|
||||
assert.equal(safeUrl(""), "#");
|
||||
// Leading Unicode whitespace above U+0020 (NBSP/NEL/LS) is left as-is on purpose: a browser only
|
||||
// strips C0+space when resolving an href, so a NBSP-prefixed "javascript:" is an invalid scheme to
|
||||
// it too \u2014 it resolves as a relative reference, not script. Documented so the strip set isn't widened later.
|
||||
assert.equal(safeUrl("\u00a0javascript:alert(1)"), "\u00a0javascript:alert(1)");
|
||||
});
|
||||
|
||||
test("localPath: accepts host-relative paths, rejects absolute / protocol-relative / odd input", () => {
|
||||
assert.equal(localPath("/admin/users?q=1&page=2"), "/admin/users?q=1&page=2");
|
||||
assert.equal(localPath("/"), "/");
|
||||
// Protocol-relative and backslash variants are off-origin → rejected (open-redirect guard).
|
||||
assert.equal(localPath("//evil.com"), null);
|
||||
assert.equal(localPath("/\\evil.com"), null);
|
||||
assert.equal(localPath("https://evil.com"), null);
|
||||
assert.equal(localPath("javascript:alert(1)"), null);
|
||||
assert.equal(localPath("relative/no-leading-slash"), null);
|
||||
// Control chars / whitespace (a return_to is a server-built path) ⇒ rejected.
|
||||
assert.equal(localPath("/x\nSet-Cookie: y"), null);
|
||||
assert.equal(localPath("/a b"), null);
|
||||
assert.equal(localPath(""), null);
|
||||
assert.equal(localPath(null), null);
|
||||
assert.equal(localPath(undefined), null);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
// URL safety helpers. Two pure, dependency-free guards:
|
||||
//
|
||||
// safeUrl(value) — sanitise an untrusted URL before rendering it in an href/src attribute.
|
||||
// Partials escape *text*, but a URL field is emitted verbatim, so a
|
||||
// `javascript:`/`data:` URL from upstream/user data would be live XSS. The
|
||||
// contract (docs/plugin-contract.md) is: a relative or http(s) URL is allowed,
|
||||
// anything else collapses to "#". Exported to plugins via plugin-api.ts.
|
||||
//
|
||||
// localPath(value) — validate a redirect target is a *same-origin* path (the redirect-URI
|
||||
// allowlist). Used for `return_to`: a host-relative "/a/b?x=1" passes, an
|
||||
// absolute or protocol-relative ("//evil.com", "https://evil.com") is rejected
|
||||
// so a crafted ?return_to= can't turn login completion into an open redirect.
|
||||
|
||||
// ASCII control chars + space that browsers strip/ignore when resolving a URL — strip them before
|
||||
// the scheme check so "java\tscript:" / a leading space can't masquerade as relative.
|
||||
const CONTROL_G = /[\u0000-\u0020\u007f]/g;
|
||||
const CONTROL = /[\u0000-\u0020\u007f]/;
|
||||
const HAS_SCHEME = /^[a-z][a-z0-9+.-]*:/i; // a URL scheme prefix, e.g. "javascript:", "http:"
|
||||
const HTTP_SCHEME = /^https?:/i;
|
||||
|
||||
export function safeUrl(value: string): string {
|
||||
const cleaned = value.replace(CONTROL_G, "");
|
||||
if (!cleaned) return "#";
|
||||
// A scheme present? Allow only http(s). No scheme ⇒ relative ⇒ safe. Return the original once
|
||||
// deemed safe (EJS still HTML-escapes it into the attribute; the inert control chars don't matter).
|
||||
if (HAS_SCHEME.test(cleaned) && !HTTP_SCHEME.test(cleaned)) return "#";
|
||||
return value;
|
||||
}
|
||||
|
||||
export function localPath(value: string | null | undefined): string | null {
|
||||
if (!value || CONTROL.test(value)) return null;
|
||||
if (!value.startsWith("/")) return null; // must be host-relative
|
||||
if (value.startsWith("//") || value.startsWith("/\\")) return null; // protocol-relative ⇒ off-origin
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { securityHeaders } from "./security-headers.ts";
|
||||
|
||||
test("securityHeaders: strict zero-JS defaults; HSTS only over https", () => {
|
||||
const h = securityHeaders();
|
||||
// Always-on hardening, independent of scheme.
|
||||
assert.equal(h["x-content-type-options"], "nosniff");
|
||||
assert.equal(h["x-frame-options"], "DENY");
|
||||
assert.equal(h["referrer-policy"], "strict-origin-when-cross-origin");
|
||||
assert.equal(h["cross-origin-opener-policy"], "same-origin");
|
||||
|
||||
const csp = h["content-security-policy"] ?? "";
|
||||
assert.match(csp, /default-src 'self'/);
|
||||
assert.match(csp, /script-src 'self'/); // a plugin may ship its own JS; the core ships none
|
||||
assert.doesNotMatch(csp, /script-src[^;]*'unsafe-inline'/); // an injected <script> can't run
|
||||
assert.match(csp, /style-src 'self' 'unsafe-inline'/); // a few partials use inline style= attrs
|
||||
assert.match(csp, /frame-ancestors 'none'/); // clickjacking guard (modern X-Frame-Options)
|
||||
assert.match(csp, /object-src 'none'/);
|
||||
assert.doesNotMatch(csp, /form-action/); // omitted: the themed login posts to Kratos' (cross-origin) action
|
||||
|
||||
// No HSTS on the dev http origin…
|
||||
assert.equal(h["strict-transport-security"], undefined);
|
||||
// …but present once the deployment is https.
|
||||
assert.match(securityHeaders({ secure: true })["strict-transport-security"] ?? "", /max-age=\d+; includeSubDomains/);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
// Response security headers: set once per request in app.ts so every response — page,
|
||||
// JSON, redirect, static, or error — carries them (writeHead merges with setHeader). A plugin route
|
||||
// may override any of them per-response via RouteResult.headers (e.g. relax the CSP to ship its own JS).
|
||||
|
||||
// Strict default CSP for the zero-JS, server-rendered core:
|
||||
// - script-src 'self' : the core ships no JS; a plugin may still serve its own /public/<id>/*.js for
|
||||
// opt-in progressive enhancement. No 'unsafe-inline' ⇒ an injected <script>
|
||||
// can't run (the main XSS sink).
|
||||
// - style-src adds 'unsafe-inline' : a few partials carry inline style= attributes.
|
||||
// - img-src adds data: : favicon + inline data URIs.
|
||||
// - no form-action : the themed login form posts to Kratos' (often cross-origin) action URL.
|
||||
// - frame-ancestors 'none' : clickjacking guard (the modern X-Frame-Options).
|
||||
const CSP = [
|
||||
"base-uri 'self'",
|
||||
"default-src 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
"img-src 'self' data:",
|
||||
"object-src 'none'",
|
||||
"script-src 'self'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
].join("; ");
|
||||
|
||||
export interface SecurityHeaderOptions {
|
||||
secure?: boolean; // https deployment (mirrors SECURE_COOKIES) → also emit HSTS
|
||||
}
|
||||
|
||||
export function securityHeaders(options: SecurityHeaderOptions = {}): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
"content-security-policy": CSP,
|
||||
"cross-origin-opener-policy": "same-origin",
|
||||
"referrer-policy": "strict-origin-when-cross-origin",
|
||||
"x-content-type-options": "nosniff",
|
||||
"x-frame-options": "DENY",
|
||||
};
|
||||
// HSTS only over https — ignored (and meaningless) on the dev http origin.
|
||||
if (options.secure) headers["strict-transport-security"] = "max-age=31536000; includeSubDomains";
|
||||
return headers;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { createReadStream } from "node:fs";
|
||||
import { stat } from "node:fs/promises";
|
||||
import type { ServerResponse } from "node:http";
|
||||
import { extname, isAbsolute, join, relative } from "node:path";
|
||||
|
||||
const contentTypes: Record<string, string> = {
|
||||
".css": "text/css; charset=utf-8",
|
||||
".html": "text/html; charset=utf-8",
|
||||
".ico": "image/x-icon",
|
||||
".jpeg": "image/jpeg",
|
||||
".jpg": "image/jpeg",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".png": "image/png",
|
||||
".svg": "image/svg+xml",
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
".webp": "image/webp",
|
||||
".woff2": "font/woff2",
|
||||
};
|
||||
|
||||
export function contentTypeFor(filePath: string): string {
|
||||
return contentTypes[extname(filePath).toLowerCase()] ?? "application/octet-stream";
|
||||
}
|
||||
|
||||
// Resolve a request path inside `dir`, or null if it escapes (traversal) or carries a
|
||||
// control char (NUL etc.) — an explicit guard rather than relying on `stat` to throw.
|
||||
export function resolveStaticPath(dir: string, requestedPath: string): string | null {
|
||||
if (/[\x00-\x1f]/.test(requestedPath)) return null;
|
||||
const filePath = join(dir, requestedPath);
|
||||
const rel = relative(dir, filePath);
|
||||
return rel.startsWith("..") || isAbsolute(rel) ? null : filePath;
|
||||
}
|
||||
|
||||
export interface StaticRoute {
|
||||
dir: string;
|
||||
subPath: string;
|
||||
}
|
||||
|
||||
// Route a `/public/<rest>` request to a base dir + sub-path: a leading segment naming a discovered
|
||||
// plugin serves from plugins/<id>/public/, anything else from the core public/. Plugin ids are
|
||||
// URL-safe (no %-encoding), so the raw segment compares directly to the id set; serveStatic decodes
|
||||
// and traversal-guards the sub-path as before.
|
||||
export function routePublic(restPath: string, publicDir: string, pluginsDir: string, pluginIds: Set<string>): StaticRoute {
|
||||
const slash = restPath.indexOf("/");
|
||||
const first = slash === -1 ? restPath : restPath.slice(0, slash);
|
||||
if (pluginIds.has(first)) {
|
||||
return { dir: join(pluginsDir, first, "public"), subPath: slash === -1 ? "" : restPath.slice(slash + 1) };
|
||||
}
|
||||
return { dir: publicDir, subPath: restPath };
|
||||
}
|
||||
|
||||
function plain(res: ServerResponse, status: number, body: string): void {
|
||||
res.writeHead(status, { "content-type": "text/plain; charset=utf-8" }).end(body);
|
||||
}
|
||||
|
||||
// onError handles a mid-stream read failure (headers already sent); defaults to console.error so
|
||||
// static.ts stays standalone, while app.ts passes the request logger for structured output.
|
||||
export async function serveStatic(dir: string, requestedPath: string, res: ServerResponse, head = false, onError: (err: Error) => void = (err) => console.error(err)): Promise<void> {
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(requestedPath);
|
||||
} catch {
|
||||
return plain(res, 400, "Bad Request");
|
||||
}
|
||||
|
||||
const filePath = resolveStaticPath(dir, decoded);
|
||||
if (filePath === null) return plain(res, 403, "Forbidden");
|
||||
|
||||
try {
|
||||
const info = await stat(filePath);
|
||||
if (!info.isFile()) return plain(res, 404, "Not Found");
|
||||
res.writeHead(200, { "content-length": info.size, "content-type": contentTypeFor(filePath) });
|
||||
if (head) return void res.end(); // headers only — skip opening the file
|
||||
// Headers are already sent, so a mid-stream read error can't become an HTTP status —
|
||||
// log and destroy the response to signal a truncated body, not a hung socket.
|
||||
createReadStream(filePath)
|
||||
.on("error", (err) => {
|
||||
onError(err);
|
||||
res.destroy();
|
||||
})
|
||||
.pipe(res);
|
||||
} catch {
|
||||
plain(res, 404, "Not Found");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user