Trust hosted domains
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
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
Publish npm packages / publish (push) Has been cancelled
Publish Swift SDK to prerelease repo / publish (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled

This commit is contained in:
Konstantin Wohlwend 2026-05-21 18:23:23 -07:00
parent 9fc7332ead
commit 99f07e9516
10 changed files with 310 additions and 69 deletions

View File

@ -518,5 +518,8 @@ A: Track startup auth transitions as pending client-app promises and make `_getS
## Q: When should hosted OAuth callback handling auto-start on a client app page?
A: Only auto-start hosted OAuth callback handling when the current URL has `code` and `state` and the matching `stack-oauth-outer-${state}` verifier cookie exists. Generic `code/state` or `errorCode/message` query parameters are not Stack-owned enough to run callback processing automatically on every hosted app page.
## Q: Should built-with hosted handler domains be manually configured as trusted domains?
A: No. Treat the hosted handler origin for the project, such as `https://<project-id>.built-with-stack-auth.com` or the origin derived from `NEXT_PUBLIC_STACK_HOSTED_HANDLER_URL_TEMPLATE`, as an implicit trusted redirect domain on both client and backend validation paths. The hosted template must put `{projectId}` in the hostname so every project has its own origin; path-based templates like `https://host/{projectId}/{hostedPath}` are not safe for implicit origin trust.
## Q: How should the npm publish workflow create the post-publish dev version bump?
A: The workflow needs a full checkout using the fine-grained `NPM_PUBLISH_VERSION_UPDATE_PR_PAT` secret. It then fetches `origin/dev`, checks out `dev`, creates a non-interactive patch changeset, runs `pnpm changeset version`, copies the generated `packages/template/package.json` version line back into `packages/template/package-template.json`, and commit/pushes `chore: update package versions`. Because direct pushes to `dev` are blocked by repository rules requiring PRs and the `all-good` status check, the PAT's owning user or bot account must be added to the ruleset bypass list with "Always allow" rather than "For pull requests only".

View File

@ -1,8 +1,48 @@
import { describe, expect, it } from 'vitest';
import { isAcceptedNativeAppUrl, validateRedirectUrl } from './redirect-urls';
import { getOAuthRedirectUrisForTenancy, validateRedirectHostname, isAcceptedNativeAppUrl, validateRedirectUrl } from './redirect-urls';
import { Tenancy } from './tenancies';
describe('validateRedirectUrl', () => {
const withHostedHandlerEnv = <T,>(
values: {
hostedHandlerUrlTemplate?: string,
hostedHandlerDomainSuffix?: string,
stackPortPrefix?: string,
},
callback: () => T,
): T => {
const processEnv = Reflect.get(process, "env");
const oldHostedHandlerUrlTemplate = Reflect.get(processEnv, "NEXT_PUBLIC_STACK_HOSTED_HANDLER_URL_TEMPLATE");
const oldHostedHandlerDomainSuffix = Reflect.get(processEnv, "NEXT_PUBLIC_STACK_HOSTED_HANDLER_DOMAIN_SUFFIX");
const oldStackPortPrefix = Reflect.get(processEnv, "NEXT_PUBLIC_STACK_PORT_PREFIX");
try {
for (const [key, value] of Object.entries({
NEXT_PUBLIC_STACK_HOSTED_HANDLER_URL_TEMPLATE: values.hostedHandlerUrlTemplate,
NEXT_PUBLIC_STACK_HOSTED_HANDLER_DOMAIN_SUFFIX: values.hostedHandlerDomainSuffix,
NEXT_PUBLIC_STACK_PORT_PREFIX: values.stackPortPrefix,
})) {
if (value == null) {
Reflect.deleteProperty(processEnv, key);
} else {
Reflect.set(processEnv, key, value);
}
}
return callback();
} finally {
for (const [key, value] of Object.entries({
NEXT_PUBLIC_STACK_HOSTED_HANDLER_URL_TEMPLATE: oldHostedHandlerUrlTemplate,
NEXT_PUBLIC_STACK_HOSTED_HANDLER_DOMAIN_SUFFIX: oldHostedHandlerDomainSuffix,
NEXT_PUBLIC_STACK_PORT_PREFIX: oldStackPortPrefix,
})) {
if (value == null) {
Reflect.deleteProperty(processEnv, key);
} else {
Reflect.set(processEnv, key, value);
}
}
}
};
const createMockTenancy = (config: Partial<Tenancy['config']>): Tenancy => {
return {
config: {
@ -13,10 +53,87 @@ describe('validateRedirectUrl', () => {
},
...config,
},
project: {
id: "12345678-1234-4234-8234-123456789abc",
},
} as Tenancy;
};
describe('exact domain matching', () => {
it('should implicitly validate hosted handler domains for the project', () => {
withHostedHandlerEnv({
hostedHandlerUrlTemplate: "http://{projectId}.localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}09/{hostedPath}",
stackPortPrefix: "92",
}, () => {
const tenancy = createMockTenancy({
domains: {
allowLocalhost: false,
trustedDomains: {},
},
});
expect(validateRedirectUrl('http://12345678-1234-4234-8234-123456789abc.localhost:9209/anything', tenancy)).toBe(true);
expect(validateRedirectUrl('http://other-project.localhost:9209/anything', tenancy)).toBe(false);
});
});
it('should reject hosted handler URL templates that put the project ID in the path', () => {
withHostedHandlerEnv({
hostedHandlerUrlTemplate: "http://localhost:9209/{projectId}/{hostedPath}",
}, () => {
const tenancy = createMockTenancy({
domains: {
allowLocalhost: false,
trustedDomains: {},
},
});
expect(() => validateRedirectUrl('http://localhost:9209/anything', tenancy))
.toThrowErrorMatchingInlineSnapshot(`
[StackAssertionError: The hosted handler URL template must put {projectId} in the hostname.
This is likely an error in Stack. Please make sure you are running the newest version and report it.]
`);
});
});
it('should include the implicit hosted callback in OAuth redirect URIs', () => {
withHostedHandlerEnv({}, () => {
const tenancy = createMockTenancy({
domains: {
allowLocalhost: false,
trustedDomains: {
'1': { baseUrl: 'https://example.com', handlerPath: '/handler' },
},
},
});
expect(getOAuthRedirectUrisForTenancy(tenancy)).toMatchInlineSnapshot(`
[
"https://example.com/handler",
"https://12345678-1234-4234-8234-123456789abc.built-with-stack-auth.com/handler/oauth-callback",
]
`);
});
});
it('should validate Turnstile hostnames through the same trusted domains', () => {
withHostedHandlerEnv({}, () => {
const tenancy = createMockTenancy({
domains: {
allowLocalhost: false,
trustedDomains: {
'1': { baseUrl: 'https://*.example.com', handlerPath: '/handler' },
},
},
});
expect(validateRedirectHostname('app.example.com', tenancy)).toBe(true);
expect(validateRedirectHostname('12345678-1234-4234-8234-123456789abc.built-with-stack-auth.com', tenancy)).toBe(true);
expect(validateRedirectHostname('evil.example.test', tenancy)).toBe(false);
});
});
it('should validate exact domain matches', () => {
const tenancy = createMockTenancy({
domains: {

View File

@ -1,14 +1,52 @@
import { isAcceptedNativeAppUrl, validateRedirectUrl as validateRedirectUrlAgainstTrustedDomains } from "@stackframe/stack-shared/dist/utils/redirect-urls";
import { getEnvVariable, getProcessEnv } from "@stackframe/stack-shared/dist/utils/env";
import { getHostedHandlerTrustedDomain as getHostedHandlerTrustedDomainFromConfig, isAcceptedNativeAppUrl, validateRedirectUrl as validateRedirectUrlAgainstTrustedDomains } from "@stackframe/stack-shared/dist/utils/redirect-urls";
import { Tenancy } from "./tenancies";
export { isAcceptedNativeAppUrl };
export function getHostedHandlerTrustedDomain(projectId: string): string {
return getHostedHandlerTrustedDomainFromConfig({
projectId,
hostedHandlerDomainSuffix: getProcessEnv("NEXT_PUBLIC_STACK_HOSTED_HANDLER_DOMAIN_SUFFIX"),
hostedHandlerUrlTemplate: getProcessEnv("NEXT_PUBLIC_STACK_HOSTED_HANDLER_URL_TEMPLATE"),
stackPortPrefix: getEnvVariable("NEXT_PUBLIC_STACK_PORT_PREFIX", "81"),
});
}
export function getTrustedDomainsForTenancy(tenancy: Tenancy): string[] {
return [
...Object.values(tenancy.config.domains.trustedDomains)
.map(domain => domain.baseUrl)
.filter((baseUrl): baseUrl is string => baseUrl != null),
getHostedHandlerTrustedDomain(tenancy.project.id),
];
}
export function getOAuthRedirectUrisForTenancy(tenancy: Tenancy): string[] {
return [
...Object.values(tenancy.config.domains.trustedDomains)
.filter((domain) => domain.baseUrl)
.map((domain) => new URL(domain.handlerPath, domain.baseUrl).toString()),
new URL("/handler/oauth-callback", getHostedHandlerTrustedDomain(tenancy.project.id)).toString(),
];
}
export function validateRedirectUrl(
urlOrString: string | URL,
tenancy: Tenancy,
): boolean {
return validateRedirectUrlAgainstTrustedDomains(urlOrString, {
allowLocalhost: tenancy.config.domains.allowLocalhost,
trustedDomains: Object.values(tenancy.config.domains.trustedDomains).map(domain => domain.baseUrl),
trustedDomains: getTrustedDomainsForTenancy(tenancy),
});
}
export function validateRedirectHostname(hostname: string, tenancy: Tenancy): boolean {
return validateRedirectUrlAgainstTrustedDomains(`https://${hostname}`, {
allowLocalhost: tenancy.config.domains.allowLocalhost,
trustedDomains: getTrustedDomainsForTenancy(tenancy),
}) || validateRedirectUrlAgainstTrustedDomains(`http://${hostname}`, {
allowLocalhost: tenancy.config.domains.allowLocalhost,
trustedDomains: getTrustedDomainsForTenancy(tenancy),
});
}

View File

@ -10,8 +10,8 @@ import {
turnstileDevelopmentKeys,
turnstilePhaseValues,
} from "@stackframe/stack-shared/dist/utils/turnstile";
import { createUrlIfValid, isLocalhost, matchHostnamePattern } from "@stackframe/stack-shared/dist/utils/urls";
import { BestEffortEndUserRequestContext, getBestEffortEndUserRequestContext } from "./end-users";
import { validateRedirectHostname } from "./redirect-urls";
import { Tenancy } from "./tenancies";
@ -47,15 +47,7 @@ type SiteverifyResponse = {
const FETCH_TIMEOUT_MS = 10_000;
function isAllowedTurnstileHostname(hostname: string, tenancy: Tenancy): boolean {
if (tenancy.config.domains.allowLocalhost && isLocalhost(`http://${hostname}`)) {
return true;
}
return Object.values(tenancy.config.domains.trustedDomains).some(({ baseUrl }) => {
if (baseUrl == null) return false;
const pattern = createUrlIfValid(baseUrl)?.hostname
?? baseUrl.match(/^[^:]+:\/\/([^/:]+)/)?.[1];
return pattern != null && matchHostnamePattern(pattern, hostname);
});
return validateRedirectHostname(hostname, tenancy);
}
function getSecretKey(override?: string): string {
@ -324,7 +316,7 @@ import.meta.vitest?.describe("verifyTurnstileToken(...)", () => {
expect(captureErrorSpy).not.toHaveBeenCalled();
});
const allowMyapp = (h: string) => h === "myapp.com" || matchHostnamePattern("*.myapp.com", h);
const allowMyapp = (h: string) => h === "myapp.com" || h.endsWith(".myapp.com");
test("returns invalid when hostname does not match allowed hostnames", async ({ expect }) => {
stubFetch({ success: true, action: "sign_up_with_credential", hostname: "evil.example.com" });

View File

@ -3,7 +3,7 @@ import { usersCrudHandlers } from "@/app/api/latest/users/crud";
import { Prisma } from "@/generated/prisma/client";
import { withExternalDbSyncUpdate } from "@/lib/external-db-sync";
import { checkApiKeySet } from "@/lib/internal-api-keys";
import { isAcceptedNativeAppUrl, validateRedirectUrl } from "@/lib/redirect-urls";
import { getOAuthRedirectUrisForTenancy, isAcceptedNativeAppUrl, validateRedirectUrl } from "@/lib/redirect-urls";
import { getSoleTenancyFromProjectBranch, getTenancy } from "@/lib/tenancies";
import { createRefreshTokenObj, decodeAccessToken, generateAccessTokenFromRefreshTokenIfValid, isRefreshTokenValid } from "@/lib/tokens";
import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client";
@ -60,13 +60,9 @@ export class OAuthModel implements AuthorizationCodeModel {
let redirectUris: string[] = [];
try {
redirectUris = Object.entries(tenancy.config.domains.trustedDomains)
// note that this may include wildcard domains, which is fine because we correctly account for them in
// model.validateRedirectUri(...)
.filter(([_, domain]) => {
return domain.baseUrl;
})
.map(([_, domain]) => new URL(domain.handlerPath, domain.baseUrl).toString());
// This may include wildcard domains and the implicit hosted handler domain;
// model.validateRedirectUri(...) performs the authoritative trust check.
redirectUris = getOAuthRedirectUrisForTenancy(tenancy);
} catch (e) {
captureError("get-oauth-redirect-urls", {
error: e,

View File

@ -747,7 +747,7 @@ export default function PageClient() {
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-foreground truncate">Same-email social login policy</div>
<div className="text-xs text-muted-foreground mt-0.5">Determines what happens when a user uses a new social login provider with an email that's already connected to an account</div>
<div className="text-xs text-muted-foreground mt-0.5">Determines what happens when a user uses a new social login provider with an email that&apos;s already connected to an account</div>
</div>
<DesignSelectorDropdown
value={mergeStrategy}

View File

@ -6,7 +6,86 @@ type TrustedDomainConfig = {
trustedDomains: readonly (string | null | undefined)[],
};
const defaultHostedHandlerDomainSuffix = ".built-with-stack-auth.com";
const hostedHandlerProjectIdPlaceholder = "{projectId}";
const hostedHandlerPathPlaceholder = "{hostedPath}";
const defaultPorts = new Map<string, string>([['https:', '443'], ['http:', '80']]);
const hostedHandlerTemplateProjectIdA = "00000000-0000-4000-8000-000000000000";
const hostedHandlerTemplateProjectIdB = "11111111-1111-4111-8111-111111111111";
function replaceStackPortPrefix(input: string | undefined, stackPortPrefix: string | undefined): string | undefined {
if (input == null) return undefined;
return stackPortPrefix ? input.replace(/\$\{NEXT_PUBLIC_STACK_PORT_PREFIX:-81\}/g, stackPortPrefix) : input;
}
function getHostedHandlerUrlFromTemplate(template: string, projectId: string, hostedPath: string): string {
return template
.replaceAll(hostedHandlerProjectIdPlaceholder, projectId)
.replaceAll(hostedHandlerPathPlaceholder, hostedPath);
}
function assertHostedHandlerTemplateHasProjectOrigin(template: string): void {
const projectUrlA = new URL(getHostedHandlerUrlFromTemplate(template, hostedHandlerTemplateProjectIdA, "handler"));
const projectUrlB = new URL(getHostedHandlerUrlFromTemplate(template, hostedHandlerTemplateProjectIdB, "handler"));
if (projectUrlA.origin === projectUrlB.origin || !projectUrlA.hostname.includes(hostedHandlerTemplateProjectIdA)) {
throw new StackAssertionError("The hosted handler URL template must put {projectId} in the hostname.", {
hostedHandlerUrlTemplate: template,
hint: "Use a project-specific origin like 'https://{projectId}.built-with-stack-auth.com/{hostedPath}', not a shared-origin path like 'https://example.com/{projectId}/{hostedPath}'.",
});
}
}
export function getHostedHandlerTrustedDomain(options: {
projectId: string,
hostedHandlerDomainSuffix?: string,
hostedHandlerUrlTemplate?: string,
stackPortPrefix?: string,
}): string {
return new URL(getHostedHandlerUrlFromConfig({
...options,
hostedPath: "handler",
})).origin;
}
export function getHostedHandlerUrlFromConfig(options: {
projectId: string,
hostedPath: string,
hostedHandlerDomainSuffix?: string,
hostedHandlerUrlTemplate?: string,
stackPortPrefix?: string,
}): string {
const configuredTemplate = replaceStackPortPrefix(options.hostedHandlerUrlTemplate, options.stackPortPrefix);
return configuredTemplate == null
? (() => {
const domainSuffix = replaceStackPortPrefix(options.hostedHandlerDomainSuffix, options.stackPortPrefix) ?? defaultHostedHandlerDomainSuffix;
if (!domainSuffix.startsWith(".")) {
throw new StackAssertionError("The hosted handler domain suffix must start with a dot.", {
domainSuffix,
hint: "Set NEXT_PUBLIC_STACK_HOSTED_HANDLER_DOMAIN_SUFFIX to a value like '.built-with-stack-auth.com'.",
});
}
return `https://${options.projectId}${domainSuffix}/${options.hostedPath}`;
})()
: (() => {
if (!configuredTemplate.includes(hostedHandlerProjectIdPlaceholder) || !configuredTemplate.includes(hostedHandlerPathPlaceholder)) {
throw new StackAssertionError("The hosted handler URL template must contain {projectId} and {hostedPath}.", {
hostedHandlerUrlTemplate: configuredTemplate,
hint: "Set NEXT_PUBLIC_STACK_HOSTED_HANDLER_URL_TEMPLATE to a value like 'https://{projectId}.built-with-stack-auth.com/{hostedPath}'.",
});
}
assertHostedHandlerTemplateHasProjectOrigin(configuredTemplate);
return getHostedHandlerUrlFromTemplate(configuredTemplate, options.projectId, options.hostedPath);
})();
}
export function getImplicitlyTrustedDomainsForProject(options: {
projectId: string,
hostedHandlerDomainSuffix?: string,
hostedHandlerUrlTemplate?: string,
stackPortPrefix?: string,
}): string[] {
return [getHostedHandlerTrustedDomain(options)];
}
function normalizePort(url: URL): string {
const port = url.port || defaultPorts.get(url.protocol) || '';
@ -137,6 +216,37 @@ import.meta.vitest?.test("validateRedirectUrl matches exact and wildcard trusted
})).toBe(false);
});
import.meta.vitest?.test("validateRedirectUrl trusts implicit hosted handler domains", ({ expect }) => {
const projectId = "12345678-1234-4234-8234-123456789abc";
expect(validateRedirectUrl(`https://${projectId}.built-with-stack-auth.com/anything`, {
allowLocalhost: false,
trustedDomains: getImplicitlyTrustedDomainsForProject({ projectId }),
})).toBe(true);
expect(validateRedirectUrl("https://other-project.built-with-stack-auth.com/anything", {
allowLocalhost: false,
trustedDomains: getImplicitlyTrustedDomainsForProject({ projectId }),
})).toBe(false);
expect(validateRedirectUrl(`http://${projectId}.localhost:9209/anything`, {
allowLocalhost: false,
trustedDomains: getImplicitlyTrustedDomainsForProject({
projectId,
hostedHandlerUrlTemplate: "http://{projectId}.localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}09/{hostedPath}",
stackPortPrefix: "92",
}),
})).toBe(true);
});
import.meta.vitest?.test("getImplicitlyTrustedDomainsForProject rejects shared-origin hosted templates", ({ expect }) => {
expect(() => getImplicitlyTrustedDomainsForProject({
projectId: "12345678-1234-4234-8234-123456789abc",
hostedHandlerUrlTemplate: "https://host.example.com/{projectId}/{hostedPath}",
})).toThrowErrorMatchingInlineSnapshot(`
[StackAssertionError: The hosted handler URL template must put {projectId} in the hostname.
This is likely an error in Stack. Please make sure you are running the newest version and report it.]
`);
});
import.meta.vitest?.test("validateRedirectUrl respects default and explicit ports", ({ expect }) => {
expect(validateRedirectUrl("https://example.com:443/path", {
allowLocalhost: false,

View File

@ -56,7 +56,7 @@ import { NotificationCategory } from "../../notification-categories";
import { TeamPermission } from "../../permissions";
import { AdminOwnedProject, AdminProjectUpdateOptions, Project, adminProjectCreateOptionsToCrud } from "../../projects";
import { EditableTeamMemberProfile, ReceivedTeamInvitation, SentTeamInvitation, Team, TeamCreateOptions, TeamUpdateOptions, TeamUser, teamCreateOptionsToCrud, teamUpdateOptionsToCrud } from "../../teams";
import { buildCliAuthConfirmUrl, isHostedHandlerUrlForProject, resolveHandlerUrls } from "../../url-targets";
import { buildCliAuthConfirmUrl, getHostedHandlerUrl, isHostedHandlerUrlForProject, resolveHandlerUrls } from "../../url-targets";
import { ActiveSession, Auth, BaseUser, CurrentUser, InternalUserExtra, OAuthProvider, ProjectCurrentUser, SyncedPartialUser, TokenPartialUser, UserExtra, UserUpdateOptions, userUpdateOptionsToCrud, withUserDestructureGuard } from "../../users";
import { StackClientApp, StackClientAppConstructorOptions, StackClientAppJson } from "../interfaces/client-app";
import { _StackAdminAppImplIncomplete } from "./admin-app-impl";
@ -1190,7 +1190,10 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
const project = Result.orThrow(await this._currentProjectCache.getOrWait([], "write-only"));
return {
allowLocalhost: project.config.allow_localhost,
trustedDomains: project.config.domains.map(d => d.domain),
trustedDomains: [
...project.config.domains.map(d => d.domain),
new URL(getHostedHandlerUrl({ projectId: this.projectId, pagePath: "" })).origin,
],
};
}

View File

@ -171,7 +171,7 @@ describe("handler URL targets", () => {
});
it("uses the full hosted handler URL template when configured", () => {
vi.stubEnv("NEXT_PUBLIC_STACK_HOSTED_HANDLER_URL_TEMPLATE", "http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}09/{projectId}/{hostedPath}");
vi.stubEnv("NEXT_PUBLIC_STACK_HOSTED_HANDLER_URL_TEMPLATE", "http://{projectId}.localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}09/{hostedPath}");
vi.stubEnv("NEXT_PUBLIC_STACK_PORT_PREFIX", "93");
const urls = resolveHandlerUrls({
@ -181,8 +181,8 @@ describe("handler URL targets", () => {
},
});
expect(urls.signIn).toBe("http://localhost:9309/project-id/handler/sign-in");
expect(urls.accountSettings).toBe("http://localhost:9309/project-id/handler/account-settings");
expect(urls.signIn).toBe("http://project-id.localhost:9309/handler/sign-in");
expect(urls.accountSettings).toBe("http://project-id.localhost:9309/handler/account-settings");
});
it("validates the hosted handler URL template placeholders", () => {
@ -195,6 +195,21 @@ describe("handler URL targets", () => {
},
})).toThrowError(/\{projectId\} and \{hostedPath\}/);
});
it("rejects hosted handler URL templates that put the project ID in the path", () => {
vi.stubEnv("NEXT_PUBLIC_STACK_HOSTED_HANDLER_URL_TEMPLATE", "http://localhost:9309/{projectId}/{hostedPath}");
expect(() => resolveHandlerUrls({
projectId: "project-id",
urls: {
default: { type: "hosted" },
},
})).toThrowErrorMatchingInlineSnapshot(`
[StackAssertionError: The hosted handler URL template must put {projectId} in the hostname.
This is likely an error in Stack. Please make sure you are running the newest version and report it.]
`);
});
});
describe("isLocalHandlerUrlTarget", () => {

View File

@ -1,22 +1,14 @@
import { getCustomPagePrompts, type CustomPagePrompt } from "@stackframe/stack-shared/dist/interface/handler-urls";
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
import { getHostedHandlerUrlFromConfig } from "@stackframe/stack-shared/dist/utils/redirect-urls";
import { envVars } from "../env";
import { DefaultHandlerUrlTarget, HandlerPageUrls, HandlerUrlOptions, HandlerUrlTarget, HandlerUrls, ResolvedHandlerUrls } from "./common";
const defaultHostedHandlerDomainSuffix = ".built-with-stack-auth.com";
const hostedHandlerProjectIdPlaceholder = "{projectId}";
const hostedHandlerPathPlaceholder = "{hostedPath}";
const localUrlPlaceholderOrigin = "http://example.com";
const schemePrefixRegex = /^[a-zA-Z][a-zA-Z\d+\-.]*:/;
const customPagePrompts: Record<keyof Omit<HandlerPageUrls, "handler">, CustomPagePrompt> = getCustomPagePrompts();
const replaceStackPortPrefix = <T extends string | undefined>(input: T): T => {
if (!input) return input;
const prefix = envVars.NEXT_PUBLIC_STACK_PORT_PREFIX;
return prefix ? input.replace(/\$\{NEXT_PUBLIC_STACK_PORT_PREFIX:-81\}/g, prefix) as T : input;
};
const joinHandlerComponentPath = (basePath: string, pagePath: string): string => {
const normalizedBasePath = basePath.endsWith("/") && basePath.length > 1
? basePath.slice(0, -1)
@ -92,33 +84,6 @@ const getHostedPagePathForHandlerName = (handlerName: keyof HandlerUrls): string
}
};
export const getHostedHandlerDomainSuffix = (): string => {
const configuredValue = envVars.NEXT_PUBLIC_STACK_HOSTED_HANDLER_DOMAIN_SUFFIX
?? defaultHostedHandlerDomainSuffix;
const domainSuffix = replaceStackPortPrefix(configuredValue);
if (!domainSuffix.startsWith(".")) {
throw new StackAssertionError("The hosted handler domain suffix must start with a dot.", {
domainSuffix,
hint: "Set NEXT_PUBLIC_STACK_HOSTED_HANDLER_DOMAIN_SUFFIX to a value like '.built-with-stack-auth.com'.",
});
}
return domainSuffix;
};
const getHostedHandlerUrlTemplate = (): string => {
const configuredTemplate = replaceStackPortPrefix(envVars.NEXT_PUBLIC_STACK_HOSTED_HANDLER_URL_TEMPLATE);
if (configuredTemplate != null) {
if (!configuredTemplate.includes(hostedHandlerProjectIdPlaceholder) || !configuredTemplate.includes(hostedHandlerPathPlaceholder)) {
throw new StackAssertionError("The hosted handler URL template must contain {projectId} and {hostedPath}.", {
hostedHandlerUrlTemplate: configuredTemplate,
hint: "Set NEXT_PUBLIC_STACK_HOSTED_HANDLER_URL_TEMPLATE to a value like 'https://{projectId}.built-with-stack-auth.com/{hostedPath}'.",
});
}
return configuredTemplate;
}
return `https://${hostedHandlerProjectIdPlaceholder}${getHostedHandlerDomainSuffix()}/${hostedHandlerPathPlaceholder}`;
};
const resolveCustomTargetUrl = (options: {
target: { type: "custom", url: string, version: number },
handlerName: keyof HandlerUrls,
@ -139,11 +104,13 @@ const resolveCustomTargetUrl = (options: {
export const getHostedHandlerUrl = (options: { projectId: string, pagePath: string }): string => {
const normalizedPagePath = options.pagePath.replace(/^\/+/, "");
const hostedPath = normalizedPagePath.length > 0 ? `handler/${normalizedPagePath}` : "handler";
const template = getHostedHandlerUrlTemplate();
const templateFilled = template
.replaceAll(hostedHandlerProjectIdPlaceholder, options.projectId)
.replaceAll(hostedHandlerPathPlaceholder, hostedPath);
return new URL(templateFilled).toString();
return getHostedHandlerUrlFromConfig({
projectId: options.projectId,
hostedPath,
hostedHandlerDomainSuffix: envVars.NEXT_PUBLIC_STACK_HOSTED_HANDLER_DOMAIN_SUFFIX,
hostedHandlerUrlTemplate: envVars.NEXT_PUBLIC_STACK_HOSTED_HANDLER_URL_TEMPLATE,
stackPortPrefix: envVars.NEXT_PUBLIC_STACK_PORT_PREFIX,
});
};
const isRelativeUrlString = (url: string): boolean => {