import { getEnvVariable, getNodeEnvironment } from '@hexclave/shared/dist/utils/env'; import { HexclaveAssertionError } from '@hexclave/shared/dist/utils/errors'; import { wait } from '@hexclave/shared/dist/utils/promises'; import apiVersions from './generated/api-versions.json'; import routes from './generated/routes.json'; import './polyfills'; import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; import { SmartRouter } from './smart-router'; const DEV_RATE_LIMIT_MAX_REQUESTS = 100; const DEV_RATE_LIMIT_WINDOW_MS = 10_000; const devRateLimitTimestamps: number[] = []; const corsAllowedRequestHeaders = [ // General 'content-type', 'authorization', // used for OAuth basic authentication 'x-stack-project-id', 'x-stack-branch-id', 'x-stack-override-error-status', 'x-stack-random-nonce', // used to forcefully disable some caches 'x-stack-client-version', 'x-stack-disable-artificial-development-delay', // Project auth 'x-stack-access-type', 'x-stack-publishable-client-key', 'x-stack-secret-server-key', 'x-stack-super-secret-admin-key', 'x-stack-admin-access-token', // User auth 'x-stack-refresh-token', 'x-stack-access-token', 'x-stack-allow-restricted-user', 'x-stack-allow-anonymous-user', // Sentry 'baggage', 'sentry-trace', // Vercel 'x-vercel-protection-bypass', // ngrok 'ngrok-skip-browser-warning', ]; const corsAllowedResponseHeaders = [ 'content-type', 'x-stack-actual-status', 'x-stack-known-error', ]; // Hexclave rebrand: every `x-stack-*` header is dual-accepted under its `x-hexclave-*` equivalent. // Derive the alias names so the CORS allowlists never drift. function withHexclaveHeaderAliases(headers: string[]): string[] { return headers.flatMap((header) => header.startsWith('x-stack-') ? [header, `x-hexclave-${header.slice('x-stack-'.length)}`] : [header]); } const corsAllowedRequestHeadersWithAliases = withHexclaveHeaderAliases(corsAllowedRequestHeaders); const corsAllowedResponseHeadersWithAliases = withHexclaveHeaderAliases(corsAllowedResponseHeaders); // This function can be marked `async` if using `await` inside export async function proxy(request: NextRequest) { const url = new URL(request.url); const delay = +getEnvVariable('STACK_ARTIFICIAL_DEVELOPMENT_DELAY_MS', '0'); if (delay) { if (getNodeEnvironment().includes('production')) { throw new HexclaveAssertionError('STACK_ARTIFICIAL_DEVELOPMENT_DELAY_MS environment variable is only allowed in development'); } if (!request.headers.get('x-stack-disable-artificial-development-delay')) { await wait(delay); } } const isApiRequest = url.pathname.startsWith('/api/'); const corsHeadersInit = isApiRequest ? { // CORS headers "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS", "Access-Control-Max-Age": "86400", // 1 day (capped to lower values, eg. 10min, by some browsers) "Access-Control-Allow-Headers": corsAllowedRequestHeadersWithAliases.join(', '), "Access-Control-Expose-Headers": corsAllowedResponseHeadersWithAliases.join(', '), "Vary": corsAllowedRequestHeadersWithAliases.join(', '), } : undefined; // ensure our clients can handle 429 responses if (isApiRequest && !request.headers.get('x-stack-disable-artificial-development-delay') && getNodeEnvironment() === 'development' && request.method !== 'OPTIONS' && !request.url.includes(".well-known") && !request.url.includes("/api/latest/internal/external-db-sync/")) { const now = Date.now(); while (devRateLimitTimestamps.length > 0 && now - devRateLimitTimestamps[0] > DEV_RATE_LIMIT_WINDOW_MS) { devRateLimitTimestamps.shift(); } if (devRateLimitTimestamps.length >= DEV_RATE_LIMIT_MAX_REQUESTS) { const waitMs = Math.max(0, DEV_RATE_LIMIT_WINDOW_MS - (now - devRateLimitTimestamps[0])); const retryAfterSeconds = Math.max(1, Math.ceil(waitMs / 1000)); const response = NextResponse.json({ message: 'Artificial development rate limit triggered. Wait before retrying.', }, { status: 429, }); // since not all firewalls return CORS headers with their 429 responses, 50% chance that we don't set the CORS headers if (Math.random() < 0.5 && corsHeadersInit) { for (const [key, value] of Object.entries(corsHeadersInit)) { response.headers.set(key, value); } } if (Math.random() < 0.5) { // for debugging, make sure we don't always set the Retry-After header response.headers.set('Retry-After', retryAfterSeconds.toString()); } return response; } else { devRateLimitTimestamps.push(now); } } const newRequestHeaders = new Headers(request.headers); // Hexclave rebrand: dual-accept request headers. New SDKs emit `x-hexclave-*`; copy each onto its // `x-stack-*` equivalent here — before routing and yup validation — so downstream auth parsing // and route schemas (which read `x-stack-*`) keep working unchanged. The new form wins when both // are present. for (const [name, value] of request.headers) { if (name.startsWith('x-hexclave-')) { newRequestHeaders.set(`x-stack-${name.slice('x-hexclave-'.length)}`, value); } } const responseInit = isApiRequest ? { request: { headers: newRequestHeaders, }, headers: corsHeadersInit, } as const : undefined; // we want to allow preflight requests to pass through // even if the API route does not implement OPTIONS if (request.method === 'OPTIONS' && isApiRequest) { return new Response(null, responseInit); } // if no route is available for the requested version, rewrite to newer version let pathname = url.pathname; outer: for (let i = 0; i < apiVersions.length - 1; i++) { const version = apiVersions[i]; const nextVersion = apiVersions[i + 1]; if (!nextVersion.migrationFolder) { throw new HexclaveAssertionError(`No migration folder found for version ${nextVersion.name}. This is a bug because every version except the first should have a migration folder.`); } if ((pathname + "/").startsWith(version.servedRoute + "/")) { const nextPathname = pathname.replace(version.servedRoute, nextVersion.servedRoute); const migrationPathname = nextPathname.replace(nextVersion.servedRoute, nextVersion.migrationFolder); // okay, we're in an API version of the current version. let's check if at least one route matches this URL (doesn't matter which) for (const route of routes) { if (nextVersion.migrationFolder && (route.normalizedPath + "/").startsWith(nextVersion.migrationFolder + "/")) { if (SmartRouter.matchNormalizedPath(migrationPathname, route.normalizedPath)) { // success! we found a route that matches the request // rewrite request to the migration folder pathname = migrationPathname; break outer; } } } // if no route matches, rewrite to the next version pathname = nextPathname; } } const newUrl = request.nextUrl.clone(); newUrl.pathname = pathname; return NextResponse.rewrite(newUrl, responseInit); } // See "Matching Paths" below to learn more export const config = { matcher: '/:path*', };