Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/server/host-routing-runtime.ts
T

477 lines
17 KiB
TypeScript

import {
getCanonicalHostRedirectUrl,
getHostAwareInternalUrlPath,
getHostRoutingContext,
getRequestRoutingHost,
type HostRoutingContext,
} from "../apps/web/app/utils/app/HostRouting.server";
import { reservedTenantRouteSlugs } from "../apps/web/app/utils/app/PublicRouteSegments";
import { isPortalCustomDomainRuntimeEnabled } from "../apps/web/app/services/customer-portal/portal-custom-domain-runtime.server";
import { getDomainRoutingConfig, normalizeHostForProtocol, type DomainRoutingConfig, type WebRuntimeSurface } from "@hivegrid/config";
export type HostRoutingRuntimeDecision =
| { type: "redirect"; status: 308; location: string }
| { type: "reject"; status: 404 }
| { type: "resolve-portal-tenant"; tenantSlug: string; request: Request }
| { type: "resolve-portal-host"; hostname: string; request: Request }
| { type: "dispatch"; target: "api" | "web"; request: Request; serveStaticAssets: boolean; reactRouterFallback: boolean };
export type PortalTenantRoutingDecision = Extract<HostRoutingRuntimeDecision, { type: "resolve-portal-tenant" }>;
export type PortalHostRoutingDecision = Extract<HostRoutingRuntimeDecision, { type: "resolve-portal-host" }>;
export type PortalHostResolution = {
readonly hostname: string;
readonly tenantSlug: string;
readonly activeOrigin: string;
};
export type FinalHostRoutingRuntimeDecision = Exclude<
HostRoutingRuntimeDecision,
PortalTenantRoutingDecision | PortalHostRoutingDecision
>;
export type HostRoutingRuntimeOptions = {
runtimeSurface?: WebRuntimeSurface;
routing?: DomainRoutingConfig;
customDomainRuntimeEnabled?: boolean;
};
const internalWebApiProxyHeader = "x-hivegrid-internal-web-api-proxy";
const portalContextHeaderPrefix = "x-hivegrid-portal-";
const portalTenantHeader = "x-hivegrid-portal-tenant";
const portalOriginHeader = "x-hivegrid-portal-origin";
const portalCustomHostHeader = "x-hivegrid-portal-custom-host";
const portalTenantSlugPattern = /^[a-z0-9][a-z0-9-]{1,62}$/;
const portalReservedSlugs = new Set<string>([...reservedTenantRouteSlugs, "portal", "__portal"]);
const portalStaticPrefixes = ["/assets/", "/fonts/", "/images/", "/locales/"] as const;
const portalStaticExactPaths = new Set(["/favicon.ico", "/favicon.svg", "/favicon-32x32.png", "/site.webmanifest"]);
const portalFrameworkPaths = new Set(["/__manifest"]);
const adminStaticPrefixes = ["/assets/", "/fonts/", "/images/", "/locales/"] as const;
const adminStaticExactPaths = new Set([
"/android-chrome-192x192.png",
"/android-chrome-512x512.png",
"/android-icon-36x36.png",
"/android-icon-48x48.png",
"/android-icon-72x72.png",
"/android-icon-96x96.png",
"/android-icon-192x192.png",
"/apple-touch-icon.png",
"/browserconfig.xml",
"/favicon.ico",
"/favicon-16x16.png",
"/favicon-32x32.png",
"/favicon-96x96.png",
"/manifest.webmanifest",
"/ms-icon-70x70.png",
"/ms-icon-150x150.png",
"/ms-icon-310x310.png",
"/site.webmanifest",
"/sw.js",
]);
export function getHostRoutingRuntimeDecision(request: Request, options: HostRoutingRuntimeOptions = {}): HostRoutingRuntimeDecision {
const runtimeSurface = options.runtimeSurface ?? "core";
const routing = options.routing ?? getDomainRoutingConfig(process.env);
if (runtimeSurface === "portal") {
return getPortalRuntimeDecision(request, routing, options.customDomainRuntimeEnabled ?? isPortalCustomDomainRuntimeEnabled());
}
const directHost = request.headers.get("host") ?? new URL(request.url).host;
const directContext = getHostRoutingContext(directHost, routing);
if (hostsEqual(directHost, routing.portalAppHost, routing.protocol) || isPortalInternalPath(new URL(request.url).pathname)) {
return { type: "reject", status: 404 };
}
if (directContext === "unknown") {
return { type: "reject", status: 404 };
}
if (runtimeSurface === "admin" ? directContext !== "admin" : directContext === "admin") {
return { type: "reject", status: 404 };
}
const context = getHostRoutingContext(
getRequestRoutingHost(request, new URL(request.url), routing),
routing
);
if (context === "unknown") {
return { type: "reject", status: 404 };
}
if (context === "admin" && isAdminStaticAssetPath(new URL(request.url).pathname)) {
if (request.method !== "GET" && request.method !== "HEAD") {
return { type: "reject", status: 404 };
}
return {
type: "dispatch",
target: "web",
request: sanitizePortalContextHeaders(request),
serveStaticAssets: true,
reactRouterFallback: false,
};
}
if (isInternalWebApiProxyRequest(request, context)) {
const requestUrl = new URL(request.url);
const rewrittenRequest = rewriteRequestUrl(request, `${requestUrl.pathname}${requestUrl.search}${requestUrl.hash}`);
return {
type: "dispatch",
target: "web",
request: sanitizePortalContextHeaders(rewrittenRequest),
serveStaticAssets: true,
reactRouterFallback: true,
};
}
const legacyAdminLoginRedirect = getLegacyAdminLoginRedirect(request, context);
if (legacyAdminLoginRedirect) {
return {
type: "redirect",
status: 308,
location: legacyAdminLoginRedirect,
};
}
if (isRemovedLegacyRoute(request, context)) {
return {
type: "reject",
status: 404,
};
}
const canonicalRedirectUrl = getCanonicalHostRedirectUrl(request);
if (canonicalRedirectUrl) {
return {
type: "redirect",
status: 308,
location: canonicalRedirectUrl,
};
}
const rewrittenRequest = rewriteRequestUrl(request, getRuntimeInternalUrlPath(request, context));
return {
type: "dispatch",
target: context === "api" ? "api" : "web",
request: sanitizePortalContextHeaders(rewrittenRequest),
serveStaticAssets: context !== "api",
reactRouterFallback: true,
};
}
function getPortalRuntimeDecision(request: Request, routing: DomainRoutingConfig, customDomainRuntimeEnabled: boolean): HostRoutingRuntimeDecision {
const url = new URL(request.url);
const directHost = request.headers.get("host") ?? url.host;
if (!hostsEqual(directHost, routing.portalAppHost, routing.protocol)) {
if (!customDomainRuntimeEnabled) return { type: "reject", status: 404 };
const hostname = getCustomPortalHostname(directHost, routing);
if (!hostname || (isOperationalPath(url.pathname) && url.pathname !== "/healthz") || isPortalInternalPath(url.pathname)) {
return { type: "reject", status: 404 };
}
return { type: "resolve-portal-host", hostname, request: sanitizePortalContextHeaders(request) };
}
if (isOperationalPath(url.pathname)) {
return {
type: "dispatch",
target: "web",
request: sanitizePortalContextHeaders(request),
serveStaticAssets: false,
reactRouterFallback: true,
};
}
if (isPortalFrameworkPath(url.pathname)) {
return {
type: "dispatch",
target: "web",
request: sanitizePortalContextHeaders(request),
serveStaticAssets: false,
reactRouterFallback: true,
};
}
if (isPortalStaticAssetPath(url.pathname)) {
return {
type: "dispatch",
target: "web",
request: sanitizePortalContextHeaders(request),
serveStaticAssets: true,
reactRouterFallback: false,
};
}
if (isPortalInternalPath(url.pathname)) {
return { type: "reject", status: 404 };
}
const tenantSlug = getPortalTenantSlug(url.pathname);
if (!tenantSlug) {
return { type: "reject", status: 404 };
}
return {
type: "resolve-portal-tenant",
tenantSlug,
request: sanitizePortalContextHeaders(request),
};
}
/**
* Completes the internal rewrite only after the caller has resolved the
* standard-host tenant through the portal DB role's pre-auth capability.
*/
export function finalizePortalTenantRoutingDecision(decision: PortalTenantRoutingDecision, resolvedTenantSlug: string | null): FinalHostRoutingRuntimeDecision {
if (resolvedTenantSlug !== decision.tenantSlug) {
return { type: "reject", status: 404 };
}
const url = new URL(decision.request.url);
const rewrittenPath = `/__portal${url.pathname}${url.search}${url.hash}`;
return {
type: "dispatch",
target: "web",
request: rewriteRequestUrl(decision.request, rewrittenPath, {
tenantSlug: resolvedTenantSlug,
origin: new URL(decision.request.url).origin,
}),
serveStaticAssets: false,
reactRouterFallback: true,
};
}
/**
* Completes custom-host dispatch only after the portal DB role has returned an
* active, generation-current domain binding. This gate deliberately runs for
* assets as well as routes so an unknown host can never observe the portal
* build or reach authentication.
*/
export function finalizePortalHostRoutingDecision(decision: PortalHostRoutingDecision, resolved: PortalHostResolution | null): FinalHostRoutingRuntimeDecision {
if (!resolved || resolved.hostname !== decision.hostname || !portalTenantSlugPattern.test(resolved.tenantSlug)) {
return { type: "reject", status: 404 };
}
const activeOrigin = normalizeResolvedPortalOrigin(resolved.activeOrigin, decision.hostname);
if (!activeOrigin) return { type: "reject", status: 404 };
const url = new URL(decision.request.url);
const context = { tenantSlug: resolved.tenantSlug, origin: activeOrigin, customHost: decision.hostname } as const;
if (url.pathname === "/healthz") {
return {
type: "dispatch",
target: "web",
// Host resolution above is the authorization gate. The operational
// manifest boundary intentionally rejects tenant context headers, so a
// health probe must cross it as a context-free operational request.
request: sanitizePortalContextHeaders(decision.request),
serveStaticAssets: false,
reactRouterFallback: true,
};
}
if (isPortalFrameworkPath(url.pathname)) {
const rewrittenManifestRequest = rewriteCustomPortalManifestRequest(decision.request, resolved.tenantSlug, context);
if (!rewrittenManifestRequest) return { type: "reject", status: 404 };
return {
type: "dispatch",
target: "web",
request: rewrittenManifestRequest,
serveStaticAssets: false,
reactRouterFallback: true,
};
}
if (isPortalStaticAssetPath(url.pathname)) {
return {
type: "dispatch",
target: "web",
request: sanitizePortalContextHeaders(decision.request, context),
serveStaticAssets: true,
reactRouterFallback: false,
};
}
const tenantPath = prependPortalTenantToCustomPath(url.pathname, resolved.tenantSlug);
if (!tenantPath) return { type: "reject", status: 404 };
return {
type: "dispatch",
target: "web",
request: rewriteRequestUrl(decision.request, `/__portal${tenantPath}${url.search}${url.hash}`, context),
serveStaticAssets: false,
reactRouterFallback: true,
};
}
function getPortalTenantSlug(pathname: string) {
if (!pathname.startsWith("/") || pathname.startsWith("//")) {
return null;
}
const rawFirstSegment = pathname.slice(1).split("/", 1)[0] ?? "";
if (rawFirstSegment.endsWith(".data") && pathname !== `/${rawFirstSegment}`) {
return null;
}
const firstSegment = rawFirstSegment.replace(/\.data$/, "");
if (!portalTenantSlugPattern.test(firstSegment) || portalReservedSlugs.has(firstSegment)) {
return null;
}
return firstSegment;
}
function isPortalStaticAssetPath(pathname: string) {
return portalStaticExactPaths.has(pathname) || portalStaticPrefixes.some((prefix) => pathname.startsWith(prefix));
}
function isAdminStaticAssetPath(pathname: string) {
return adminStaticExactPaths.has(pathname) || adminStaticPrefixes.some((prefix) => pathname.startsWith(prefix));
}
function isPortalFrameworkPath(pathname: string) {
return portalFrameworkPaths.has(pathname);
}
function isOperationalPath(pathname: string) {
return pathname === "/healthz" || pathname === "/readyz" || pathname === "/healthcheck" || pathname === "/api/metrics";
}
function isPortalInternalPath(pathname: string) {
return pathname === "/__portal" || pathname.startsWith("/__portal/") || pathname === "/portal" || pathname.startsWith("/portal/");
}
function getCustomPortalHostname(directHost: string, routing: DomainRoutingConfig): string | null {
const normalized = normalizeHostForProtocol(directHost, routing.protocol);
if (!normalized || normalized.includes(":")) return null;
if (
[routing.portalAppHost, routing.webAppHost, routing.adminAppHost, routing.apiHost, routing.appBaseDomain].some((host) =>
hostsEqual(normalized, host, routing.protocol)
)
) {
return null;
}
const hostname = new URL(`${routing.protocol}://${normalized}`).hostname.toLowerCase();
if (
!hostname.includes(".") ||
hostname.endsWith(".localhost") ||
hostname.endsWith(".local") ||
hostname.endsWith(".internal") ||
hostname.endsWith(".example") ||
hostname.endsWith(".invalid") ||
hostname.endsWith(".test")
) {
return null;
}
return hostname;
}
function normalizeResolvedPortalOrigin(activeOrigin: string, hostname: string): string | null {
try {
const origin = new URL(activeOrigin);
if (origin.protocol !== "https:" || origin.hostname.toLowerCase() !== hostname || origin.port || origin.pathname !== "/") return null;
if (origin.username || origin.password || origin.search || origin.hash) return null;
return origin.origin;
} catch {
return null;
}
}
function prependPortalTenantToCustomPath(pathname: string, tenantSlug: string): string | null {
if (!pathname.startsWith("/") || pathname.startsWith("//")) return null;
if (pathname === "/") return `/${tenantSlug}`;
if (pathname === "/.data") return `/${tenantSlug}.data`;
return `/${tenantSlug}${pathname}`;
}
function rewriteCustomPortalManifestRequest(request: Request, tenantSlug: string, context: TrustedPortalContext): Request | null {
const url = new URL(request.url);
const rawPaths = url.searchParams.get("paths");
if (!rawPaths) return null;
const publicPaths = rawPaths.split(",");
if (publicPaths.some((pathname) => prependPortalTenantToCustomPath(pathname, tenantSlug) === null)) {
return null;
}
return sanitizePortalContextHeaders(new Request(url, request), context);
}
function hostsEqual(actualHost: string, expectedHost: string, protocol: DomainRoutingConfig["protocol"]) {
const normalizedActualHost = normalizeHostForProtocol(actualHost, protocol);
const normalizedExpectedHost = normalizeHostForProtocol(expectedHost, protocol);
return Boolean(normalizedActualHost && normalizedExpectedHost && normalizedActualHost === normalizedExpectedHost);
}
function isInternalWebApiProxyRequest(request: Request, context: ReturnType<typeof getHostRoutingContextFromRequest>) {
if (context !== "app" || request.headers.get(internalWebApiProxyHeader) !== "1") {
return false;
}
const pathname = new URL(request.url).pathname;
return pathname === "/internal/api/usage" || pathname === "/internal/api/billing.webhooks" || pathname.startsWith("/internal/api/workflows/");
}
function getLegacyAdminLoginRedirect(request: Request, context: ReturnType<typeof getHostRoutingContextFromRequest>) {
if (context !== "admin") {
return null;
}
const url = new URL(request.url);
const normalizedPathname = url.pathname.replace(/\/+$/g, "") || "/";
if (normalizedPathname !== "/admin/login") {
return null;
}
url.pathname = "/login";
return url.toString();
}
function isRemovedLegacyRoute(request: Request, context: ReturnType<typeof getHostRoutingContextFromRequest>) {
const pathname = new URL(request.url).pathname.replace(/\/+$/g, "") || "/";
const isLegacyApiV1Route = pathname === "/api/v1" || pathname.startsWith("/api/v1/");
const isWebMetricsRoute = pathname === "/api/metrics";
const isAdminApiSectionRoute = context === "admin" && (pathname === "/api" || pathname.startsWith("/api/")) && !isLegacyApiV1Route;
return (
pathname === "/app" ||
pathname.startsWith("/app/") ||
pathname === "/admin" ||
pathname.startsWith("/admin/") ||
(!isAdminApiSectionRoute && !isWebMetricsRoute && (pathname === "/api" || (pathname.startsWith("/api/") && !isLegacyApiV1Route)))
);
}
function getRuntimeInternalUrlPath(request: Request, context: HostRoutingContext) {
if (context === "app") {
const url = new URL(request.url);
return `${url.pathname}${url.search}${url.hash}`;
}
return getHostAwareInternalUrlPath(request);
}
type TrustedPortalContext = {
readonly tenantSlug: string;
readonly origin: string;
readonly customHost?: string;
};
function rewriteRequestUrl(request: Request, internalUrlPath: string, portalContext?: TrustedPortalContext) {
const url = new URL(request.url);
url.pathname = "/";
url.search = "";
url.hash = "";
const rewrittenUrl = new URL(internalUrlPath, url);
const init: RequestInit & { duplex?: "half" } = {
method: request.method,
headers: request.headers,
signal: request.signal,
};
if (request.method !== "GET" && request.method !== "HEAD") {
init.body = request.body;
init.duplex = "half";
}
const rewrittenRequest = new Request(rewrittenUrl, init);
return sanitizePortalContextHeaders(rewrittenRequest, portalContext);
}
function sanitizePortalContextHeaders(request: Request, portalContext?: TrustedPortalContext) {
const headers = new Headers();
for (const [name, value] of request.headers) {
if (!name.toLowerCase().startsWith(portalContextHeaderPrefix)) {
headers.append(name, value);
}
}
if (portalContext) {
headers.set(portalTenantHeader, portalContext.tenantSlug);
headers.set(portalOriginHeader, portalContext.origin);
if (portalContext.customHost) headers.set(portalCustomHostHeader, portalContext.customHost);
}
return new Request(request, { headers });
}