/** * @wrnexus/helpers — safe conveniences for common WrNexus application flows. * * Helpers stay small and composable. They accept the standard WrNexus Context * and return web-platform values such as URL and Response. */ import type { Context } from "@wrnexus/core"; import { currentAppOrigin } from "./workspace.ts"; export type RequestContext = Pick; export type AllowedHosts = readonly string[] | ReadonlySet | ((host: string, ctx: RequestContext) => boolean); export interface OriginalRequestOptions { /** * Hosts that the application permits as redirect destinations. This is * required when a proxy supplied X-Forwarded-Host is present. */ allowedHosts?: AllowedHosts; } export interface LoginRedirectOptions extends OriginalRequestOptions { /** Query parameter that receives the original absolute URL. */ returnToParam?: string; /** Browser redirect status. Defaults to 302. */ status?: 301 | 302 | 303 | 307 | 308; } function forwardedValue(ctx: RequestContext, name: string): string | null { const value = ctx.req.headers.get(name)?.trim(); if (!value) return null; if (value.includes(",")) { throw new TypeError(`${name} must contain exactly one value`); } return value; } function hostIsAllowed(host: string, ctx: RequestContext, allowedHosts?: AllowedHosts): boolean { if (!allowedHosts) return false; if (typeof allowedHosts === "function") return allowedHosts(host, ctx); const normalized = host.toLowerCase(); for (const allowed of allowedHosts) { if (allowed.toLowerCase() === normalized) return true; } return false; } /** Get the original path and query string seen by the gateway. */ export function getOriginalRequestPath(ctx: RequestContext): string { const value = forwardedValue(ctx, "x-original-uri") ?? `${ctx.url.pathname}${ctx.url.search}`; if ( !value.startsWith("/") || value.startsWith("//") || value.includes("\\") || value.includes("#") ) { throw new TypeError("x-original-uri must be an absolute request path"); } return value; } /** Get the original HTTP method seen by the gateway. */ export function getOriginalRequestMethod(ctx: RequestContext): string { const method = forwardedValue(ctx, "x-original-method") ?? ctx.req.method; if (!/^[A-Za-z]+$/.test(method)) throw new TypeError("x-original-method is invalid"); return method.toUpperCase(); } /** * Reconstruct the absolute URL that reached the gateway. * * Forwarded hosts are never trusted implicitly: pass allowedHosts when this is * used behind the WrNexus gateway. Direct requests fall back to ctx.url. */ export function getOriginalRequestUrl( ctx: RequestContext, options: OriginalRequestOptions = {}, ): URL { // A forward-auth verifier can itself sit behind the same gateway. In that // nested hop X-Forwarded-Host correctly describes the verifier (SSO), while // X-Original-Host keeps the protected application's host (for returnTo). const host = forwardedValue(ctx, "x-original-host") ?? forwardedValue(ctx, "x-forwarded-host"); const path = getOriginalRequestPath(ctx); if (!host) return new URL(path, ctx.url.origin); if (!hostIsAllowed(host, ctx, options.allowedHosts)) { throw new TypeError(`Untrusted forwarded host: ${host}`); } const protocol = ( forwardedValue(ctx, "x-original-proto") ?? forwardedValue(ctx, "x-forwarded-proto") ?? ctx.url.protocol ).replace(/:$/, ""); if (protocol !== "http" && protocol !== "https") { throw new TypeError(`Unsupported forwarded protocol: ${protocol}`); } const origin = new URL(`${protocol}://${host}`); if (origin.username || origin.password || origin.pathname !== "/") { throw new TypeError(`Invalid forwarded host: ${host}`); } const original = new URL(path, origin); if (original.origin !== origin.origin) { throw new TypeError("Original request URL must stay on the forwarded origin"); } return original; } /** Get the original request origin, for example http://admin.localhost:3000. */ export function getOriginalRequestOrigin( ctx: RequestContext, options: OriginalRequestOptions = {}, ): string { return getOriginalRequestUrl(ctx, options).origin; } /** * Redirect to a login page with the original absolute URL encoded as returnTo. * Relative login URLs resolve against the current app (normally the SSO app). */ export function redirectToLogin( ctx: RequestContext, loginUrl: string | URL, options: LoginRedirectOptions = {}, ): Response { const target = new URL(loginUrl, `${currentAppOrigin() ?? ctx.url.origin}/`); if (target.protocol !== "http:" && target.protocol !== "https:") { throw new TypeError("Login URL must use http or https"); } const original = getOriginalRequestUrl(ctx, options); target.searchParams.set(options.returnToParam ?? "returnTo", original.href); return Response.redirect(target, options.status ?? 302); } export { appOrigin, appUrl, currentAppName, currentAppOrigin, workspaceAppOrigins, workspaceRootDomain, } from "./workspace.ts"; export { backoffDelay, sleep, retry, withTimeout, stableStringify, safeJsonParse, clamp, once, } from "./resilience.ts"; export type { RetryOptions } from "./resilience.ts";