477 lines
17 KiB
TypeScript
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 });
|
|
}
|