stack/apps/backend/src/lib/request-api-url.ts
BilalG1 609579abab
Some checks failed
all-good: Did all the other checks pass? / all-good (push) Has been cancelled
Ensure Prisma migrations are in sync with the schema / check_prisma_migrations (22.x) (push) Has been cancelled
DB migration compat / Check if migrations changed (push) Has been cancelled
Docker Server Build and Push / Docker Build and Push Server (push) Has been cancelled
Docker Server Build and Run / docker (push) Has been cancelled
Runs E2E API Tests (Local Emulator) / E2E Tests (Local Emulator, Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (mock, 22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (prod, 22.x) (push) Has been cancelled
Runs E2E API Tests with custom port prefix / build (22.x) (push) Has been cancelled
Runs E2E Fallback Tests / E2E Fallback Tests (Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Lint & build / lint_and_build (24) (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled
DB migration compat / Back-compat — Current branch migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code (push) Has been cancelled
DB migration compat / Forward-compat — Current branch code with ${{ needs.check-migrations-changed.outputs.base_branch }} branch migrations (push) Has been cancelled
DB migration compat / No migration changes (skipped) (push) Has been cancelled
feat(hexclave): PR 3 — native @hexclave/* source rename + delete dual-publish wiring (#1482)
2026-05-29 15:21:59 -07:00

122 lines
5.3 KiB
TypeScript

import { CLOUD_HOST_PAIRS } from "@hexclave/shared/dist/utils/cloud-hosts";
import { getEnvVariable } from "@hexclave/shared/dist/utils/env";
import { captureError, HexclaveAssertionError } from "@hexclave/shared/dist/utils/errors";
/**
* The stack-auth ↔ hexclave cloud host pairs live in stack-shared
* (`utils/cloud-hosts.ts`) so the dashboard and OAuth callback logic can share
* them. Re-exported here because `tokens.tsx` imports it from this module to
* build `issuerHostAliases` (and the source-of-truth comment lives with the
* pairs themselves).
*/
export { CLOUD_HOST_PAIRS };
/**
* Cloud hosts where this backend serves customer SDK traffic. Each request
* that arrives on one of these hosts is treated as "branded" to its canonical
* API host: the JWT `iss` claim and the OAuth `redirect_uri` we send to
* providers (Google, GitHub, ...) both use the same brand the SDK targeted.
* That way a customer whose SDK is on `api.stack-auth.com` continues to
* receive `iss: api.stack-auth.com/...` tokens and OAuth redirect URIs
* registered with their provider apps as
* `https://api.stack-auth.com/api/v1/auth/oauth/callback/<provider>`, and a
* customer whose SDK is on `api.hexclave.com` gets the hexclave-branded
* equivalents.
*
* Fallback/analytics hosts (`api1`, `api2`, `api3`, `r`) map back to the
* canonical `api` host for the same brand/environment. We should never stamp
* those load-balancing or recording hosts into customer-facing OAuth callback
* URLs or JWT issuers.
*
* Hosts NOT in this map (localhost, vercel preview URLs, self-host custom
* domains) fall back to `NEXT_PUBLIC_STACK_API_URL` so single-host deployments
* keep behaving exactly as before. We capture those fallbacks as errors so
* missed cloud host aliases are visible during the rebrand rollout.
*
* Trust model: on Vercel, `x-forwarded-host` is set by the edge from the
* customer-facing hostname and cannot be spoofed by a client. The blast
* radius of any host-header manipulation is bounded to the allowlist above
* — a spoofed host that isn't in the list falls back to the env-var default,
* and the resulting `iss` would still validate via `issuerHostAliases`. The
* helper does NOT gate on a trusted-proxy signal; it assumes the deployment's
* proxy chain sets `x-forwarded-host` from a trusted source.
*/
function apiHostAliasesForCanonicalHost(canonicalHost: string): string[] {
const suffix = canonicalHost.slice("api.".length);
return [
canonicalHost,
`api1.${suffix}`,
`api2.${suffix}`,
`api3.${suffix}`,
`r.${suffix}`,
...suffix.startsWith("dev.") ? [`app.${suffix}`] : [],
];
}
const CLOUD_API_HOST_BY_REQUEST_HOST = new Map<string, string>(
CLOUD_HOST_PAIRS
.flat()
.flatMap((canonicalHost) => (
apiHostAliasesForCanonicalHost(canonicalHost).map((requestHost) => [requestHost, canonicalHost] as const)
)),
);
function normalizeRequestHost(host: string | undefined | null): string | undefined {
if (!host) return undefined;
const firstHost = host.split(",")[0]?.trim();
if (!firstHost) return undefined;
return firstHost.split(":")[0].toLowerCase();
}
/**
* Map a request's host header to the canonical API URL to use for any outward-
* facing identifier produced for that request (JWT issuer, OAuth redirect URI,
* etc.). Pass the bare hostname (no scheme, no port).
*/
export function getApiUrlForHost(host: string | undefined | null): string {
const normalizedHost = normalizeRequestHost(host);
if (normalizedHost) {
const apiHost = CLOUD_API_HOST_BY_REQUEST_HOST.get(normalizedHost);
if (apiHost) {
return `https://${apiHost}`;
}
}
const fallbackApiUrl = getEnvVariable("NEXT_PUBLIC_STACK_API_URL");
captureError("request-api-url.fallback", new HexclaveAssertionError(`Falling back to NEXT_PUBLIC_STACK_API_URL while resolving request API URL`, {
host,
normalizedHost,
fallbackApiUrl,
}));
return fallbackApiUrl;
}
/**
* Resolve the API URL for the host the incoming request is targeting. Prefers
* `x-forwarded-host` (set by Vercel's edge proxy) over `host` so we see the
* customer-facing hostname rather than the internal one.
*
* The `headers` shape matches what `smart-route-handler` exposes as `fullReq`:
* a record of lowercase header names to value arrays.
*/
export function getApiUrlForRequest(req: { headers: Record<string, string[] | undefined> }): string {
const host = req.headers["x-forwarded-host"]?.[0] ?? req.headers["host"]?.[0];
return getApiUrlForHost(host);
}
import.meta.vitest?.test("getApiUrlForHost maps cloud sibling hosts to canonical API hosts", ({ expect }) => {
for (const [stackAuthHost, hexclaveHost] of CLOUD_HOST_PAIRS) {
for (const canonicalHost of [stackAuthHost, hexclaveHost]) {
const suffix = canonicalHost.slice("api.".length);
for (const prefix of ["api", "api1", "api2", "api3", "r"]) {
expect(getApiUrlForHost(`${prefix}.${suffix}`)).toBe(`https://${canonicalHost}`);
expect(getApiUrlForHost(`${prefix.toUpperCase()}.${suffix}:443`)).toBe(`https://${canonicalHost}`);
}
}
}
});
import.meta.vitest?.test("getApiUrlForHost maps app.dev sibling hosts to canonical dev API hosts", ({ expect }) => {
expect(getApiUrlForHost("app.dev.stack-auth.com")).toBe("https://api.dev.stack-auth.com");
expect(getApiUrlForHost("app.dev.hexclave.com")).toBe("https://api.dev.hexclave.com");
});