diff --git a/apps/backend/src/app/api/latest/internal/backend-urls/route.test.tsx b/apps/backend/src/app/api/latest/internal/backend-urls/route.test.tsx index f477c88e7..f7113f4a4 100644 --- a/apps/backend/src/app/api/latest/internal/backend-urls/route.test.tsx +++ b/apps/backend/src/app/api/latest/internal/backend-urls/route.test.tsx @@ -1,5 +1,14 @@ import { describe, expect, it } from 'vitest'; -import { parseAndValidateConfig } from './route'; +import { getDefaultEntriesForRequest, parseAndValidateConfig } from './route'; + +function reqForHost(host: string, forwardedHost?: string) { + return { + headers: { + host: [host], + ...(forwardedHost ? { "x-forwarded-host": [forwardedHost] } : {}), + }, + }; +} describe('parseAndValidateConfig', () => { it('should parse a single entry with probability 1', () => { @@ -66,3 +75,44 @@ describe('parseAndValidateConfig', () => { expect(() => parseAndValidateConfig({ "1": "https://api.hexclave.com" })).toThrow(); }); }); + +describe('getDefaultEntriesForRequest', () => { + it('keeps legacy Stack Auth requests on Stack Auth API fallbacks', () => { + expect(getDefaultEntriesForRequest(reqForHost("api.stack-auth.com"))).toEqual([ + { + probability: 1, + urls: [ + "https://api.stack-auth.com", + "https://api1.stack-auth.com", + "https://api2.stack-auth.com", + ], + }, + ]); + }); + + it('keeps Hexclave requests on Hexclave API fallbacks', () => { + expect(getDefaultEntriesForRequest(reqForHost("api.hexclave.com"))).toEqual([ + { + probability: 1, + urls: [ + "https://api.hexclave.com", + "https://api1.hexclave.com", + "https://api2.hexclave.com", + ], + }, + ]); + }); + + it('maps fallback hosts back to the same brand canonical API host', () => { + expect(getDefaultEntriesForRequest(reqForHost("api2.stack-auth.com"))[0].urls[0]).toBe("https://api.stack-auth.com"); + expect(getDefaultEntriesForRequest(reqForHost("api2.hexclave.com"))[0].urls[0]).toBe("https://api.hexclave.com"); + }); + + it('prefers x-forwarded-host over host when selecting the brand', () => { + expect(getDefaultEntriesForRequest(reqForHost("api.stack-auth.com", "api.hexclave.com"))[0].urls).toEqual([ + "https://api.hexclave.com", + "https://api1.hexclave.com", + "https://api2.hexclave.com", + ]); + }); +}); diff --git a/apps/backend/src/app/api/latest/internal/backend-urls/route.tsx b/apps/backend/src/app/api/latest/internal/backend-urls/route.tsx index 24a7d5fff..7c5496b25 100644 --- a/apps/backend/src/app/api/latest/internal/backend-urls/route.tsx +++ b/apps/backend/src/app/api/latest/internal/backend-urls/route.tsx @@ -1,8 +1,8 @@ import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { getApiUrlForRequest } from "@/lib/request-api-url"; import { urlSchema, yupArray, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { HexclaveAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; -import { getDefaultApiUrls } from "@stackframe/stack-shared/dist/utils/urls"; /** * Env var format: JSON object mapping probability (as string number) to URL arrays. @@ -44,24 +44,47 @@ export function parseAndValidateConfig(raw: unknown): Array<{ probability: numbe } let cachedEntries: ReturnType | undefined; -function getCachedConfig() { +function getConfiguredEntries() { if (!cachedEntries) { const rawEnv = getEnvVariable("STACK_BACKEND_URLS_CONFIG", ""); - if (rawEnv) { - let parsed; - try { - parsed = JSON.parse(rawEnv); - } catch (e) { - throw new HexclaveAssertionError(`STACK_BACKEND_URLS_CONFIG is not valid JSON: ${e}`); - } - cachedEntries = parseAndValidateConfig(parsed); - } else { - cachedEntries = [{ probability: 1, urls: getDefaultApiUrls(getEnvVariable("NEXT_PUBLIC_STACK_API_URL")) }]; + if (!rawEnv) { + return undefined; } + let parsed; + try { + parsed = JSON.parse(rawEnv); + } catch (e) { + throw new HexclaveAssertionError(`STACK_BACKEND_URLS_CONFIG is not valid JSON: ${e}`); + } + cachedEntries = parseAndValidateConfig(parsed); } return cachedEntries; } +function getDefaultBackendUrls(primaryBaseUrl: string): string[] { + if (primaryBaseUrl === "https://api.stack-auth.com") { + return ["https://api.stack-auth.com", "https://api1.stack-auth.com", "https://api2.stack-auth.com"]; + } + if (primaryBaseUrl === "https://api.dev.stack-auth.com") { + return ["https://api.dev.stack-auth.com", "https://api1.dev.stack-auth.com", "https://api2.dev.stack-auth.com"]; + } + if (primaryBaseUrl === "https://api.hexclave.com") { + return ["https://api.hexclave.com", "https://api1.hexclave.com", "https://api2.hexclave.com"]; + } + if (primaryBaseUrl === "https://api.dev.hexclave.com") { + return ["https://api.dev.hexclave.com", "https://api1.dev.hexclave.com", "https://api2.dev.hexclave.com"]; + } + const localhostMatch = primaryBaseUrl.match(/^http:\/\/localhost:(\d+)02$/); + if (localhostMatch) { + return [primaryBaseUrl, `http://localhost:${localhostMatch[1]}10`]; + } + return [primaryBaseUrl]; +} + +export function getDefaultEntriesForRequest(req: { headers: Record }): ReturnType { + return [{ probability: 1, urls: getDefaultBackendUrls(getApiUrlForRequest(req)) }]; +} + export const GET = createSmartRouteHandler({ metadata: { hidden: true, @@ -79,8 +102,8 @@ export const GET = createSmartRouteHandler({ urls: yupArray(yupString().defined()).defined(), }).defined(), }), - handler: async () => { - const entries = getCachedConfig(); + handler: async (_req, fullReq) => { + const entries = getConfiguredEntries() ?? getDefaultEntriesForRequest(fullReq); const roll = Math.random(); let cumulative = 0; diff --git a/apps/backend/src/proxy.tsx b/apps/backend/src/proxy.tsx index eb42d94e3..ac93b1ad9 100644 --- a/apps/backend/src/proxy.tsx +++ b/apps/backend/src/proxy.tsx @@ -34,6 +34,7 @@ const corsAllowedRequestHeaders = [ // User auth 'x-stack-refresh-token', 'x-stack-access-token', + 'x-stack-allow-restricted-user', 'x-stack-allow-anonymous-user', // Sentry