Deprecate app.urls

This commit is contained in:
Konstantin Wohlwend 2026-03-27 13:58:33 -07:00
parent 766fd02a47
commit 9197d4f32b
12 changed files with 65 additions and 79 deletions

View File

@ -82,7 +82,7 @@ it("adds secure cross-domain handoff parameters when redirecting to hosted sign-
});
});
it("includes redirect-back params in app.urls.signIn for hosted flows", async ({ expect }) => {
it("returns static app.urls.signIn for hosted flows", async ({ expect }) => {
await withHostedDomainSuffix(async () => {
const projectId = "44444444-4444-4444-8444-444444444444";
const currentHref = `${localRedirectUrl}/private-page?foo=bar`;
@ -102,19 +102,10 @@ it("includes redirect-back params in app.urls.signIn for hosted flows", async ({
const signInUrl = new URL(clientApp.urls.signIn);
expect(signInUrl.origin).toBe(`https://${projectId}.example-stack-hosted.test`);
expect(signInUrl.pathname).toBe("/handler/sign-in");
expect(signInUrl.searchParams.get("stack_cross_domain_after_callback_redirect_url")).toBe(currentHref);
const callbackUrl = new URL(signInUrl.searchParams.get("after_auth_return_to") ?? "");
expect(callbackUrl.origin).toBe(new URL(localRedirectUrl).origin);
expect(callbackUrl.pathname).toBe("/handler/oauth-callback");
expect(callbackUrl.searchParams.get("stack_cross_domain_auth")).toBe("1");
const maybeState = signInUrl.searchParams.get("stack_cross_domain_state");
const maybeChallenge = signInUrl.searchParams.get("stack_cross_domain_code_challenge");
if (maybeState != null || maybeChallenge != null) {
expect(maybeState).toEqual(expect.any(String));
expect(maybeChallenge).toEqual(expect.any(String));
}
expect(signInUrl.searchParams.get("after_auth_return_to")).toBeNull();
expect(signInUrl.searchParams.get("stack_cross_domain_state")).toBeNull();
expect(signInUrl.searchParams.get("stack_cross_domain_code_challenge")).toBeNull();
expect(signInUrl.searchParams.get("stack_cross_domain_after_callback_redirect_url")).toBeNull();
} finally {
globalThis.window = previousWindow;
globalThis.document = previousDocument;
@ -122,7 +113,7 @@ it("includes redirect-back params in app.urls.signIn for hosted flows", async ({
});
});
it("includes after_auth_return_to in app.urls.signOut for hosted flows", async ({ expect }) => {
it("returns static app.urls.signOut for hosted flows", async ({ expect }) => {
await withHostedDomainSuffix(async () => {
const projectId = "55555555-5555-4555-8555-555555555555";
const currentHref = `${localRedirectUrl}/signed-in-page?foo=bar`;
@ -142,7 +133,7 @@ it("includes after_auth_return_to in app.urls.signOut for hosted flows", async (
const signOutUrl = new URL(clientApp.urls.signOut);
expect(signOutUrl.origin).toBe(`https://${projectId}.example-stack-hosted.test`);
expect(signOutUrl.pathname).toBe("/handler/sign-out");
expect(signOutUrl.searchParams.get("after_auth_return_to")).toBe(currentHref);
expect(signOutUrl.searchParams.get("after_auth_return_to")).toBeNull();
} finally {
globalThis.window = previousWindow;
globalThis.document = previousDocument;

View File

@ -149,7 +149,10 @@ Q: Why did EventTracker throw `Reflect.get called on non-object` in JS cookie te
A: Partial browser mocks can expose `window` without a real `history` object. Calling `Reflect.get(historyObject, "pushState")` throws before type checks. Use normal guarded access (`Object.getOwnPropertyDescriptor(window, "history")?.value`) plus type guards for `pushState`/`replaceState`, and patch/restore methods directly without `Reflect`.
Q: How are custom handler URL target versions validated?
A: In `packages/template/src/lib/stack-app/url-targets.ts`, `{ type: "custom", url, version }` always allows `version: 0`. Any non-zero version is only allowed when that version exists in `customPagePrompts[handlerName].versions`; otherwise resolution throws `StackAssertionError` including `supportedVersions`.
A: In `packages/template/src/lib/stack-app/url-targets.ts`, custom targets are only allowed for handler names listed in `customPagePrompts` (not for `handler`). For allowed pages, `version: 0` is always accepted and non-zero versions must exist in `customPagePrompts[handlerName].versions`; otherwise an error is thrown.
Q: What ordering matters for custom handler URL target version checks?
A: In `resolveCustomTargetUrl` (`packages/template/src/lib/stack-app/url-targets.ts`), check `version === 0` before handler-name eligibility checks. Otherwise `{ type: "custom", version: 0 }` can be incorrectly rejected for `handler`, breaking legacy string-alias behavior.
Q: How should `StackHandlerClient.redirectIfNotHandler` avoid SSR `window` crashes?
A: In `packages/template/src/components-page/stack-handler-client.tsx`, parse handler URLs with a placeholder origin (`http://example.com`) and avoid reading `window` on the server path. For SSR, compare only handler path shape; for browser, keep origin+path checks using `window.location.origin`.
Q: What is the current `app.urls` contract after deprecating runtime URL mutation?
A: `app.urls` is now static (`getUrls(...)` only) and no longer injects runtime `after_auth_return_to` / `stack_cross_domain_*` params from `window.location`. For navigation flows, examples and consumers should use `redirectToXyz()` methods instead (for example `redirectToSignIn()` / `redirectToSignOut()`), while tests for hosted flows should assert dynamic params on actual redirect methods, not on `app.urls`.

View File

@ -10,7 +10,6 @@ export default function CrossDomainHandoffPage() {
const user = useUser();
const currentUrl = typeof window === "undefined" ? "unknown" : window.location.href;
const isHostedSignIn = app.urls.signIn.startsWith("https://");
const extraRedirectActions: Array<{ label: string, run: () => Promise<void> }> = [
{ label: "redirectToAccountSettings()", run: async () => await app.redirectToAccountSettings() },
{ label: "redirectToHome()", run: async () => await app.redirectToHome() },
@ -26,11 +25,11 @@ export default function CrossDomainHandoffPage() {
{ label: "redirectToError()", run: async () => await app.redirectToError() },
];
const rawUrlActions: Array<{ label: string, href: string }> = [
{ label: "Account Settings URL", href: app.urls.accountSettings },
{ label: "OAuth Callback URL", href: app.urls.oauthCallback },
{ label: "Team Invitation URL", href: app.urls.teamInvitation },
{ label: "MFA URL", href: app.urls.mfa },
{ label: "Error URL", href: app.urls.error },
{ label: "Account Settings URL", href: "/handler/account-settings" },
{ label: "OAuth Callback URL", href: "/handler/oauth-callback" },
{ label: "Team Invitation URL", href: "/handler/team-invitation" },
{ label: "MFA URL", href: "/handler/mfa" },
{ label: "Error URL", href: "/handler/error" },
];
return (
@ -49,11 +48,10 @@ export default function CrossDomainHandoffPage() {
<div className="space-y-2 text-sm">
<div><span className="font-semibold">Signed in:</span> {user ? "yes" : "no"}</div>
<div><span className="font-semibold">Current URL:</span> <code>{currentUrl}</code></div>
<div><span className="font-semibold">Sign-in URL:</span> <code>{app.urls.signIn}</code></div>
<div><span className="font-semibold">OAuth callback URL:</span> <code>{app.urls.oauthCallback}</code></div>
<div><span className="font-semibold">Sign-in route:</span> <code>/handler/sign-in</code></div>
<div><span className="font-semibold">OAuth callback route:</span> <code>/handler/oauth-callback</code></div>
<div>
<span className="font-semibold">Cross-domain mode:</span>{" "}
{isHostedSignIn ? "active (hosted sign-in URL is absolute)" : "inactive (sign-in URL is local/relative)"}
<span className="font-semibold">Cross-domain mode:</span> driven by redirect methods and current URL state
</div>
</div>
</CardContent>

View File

@ -4,11 +4,9 @@ import { UserAvatar, useStackApp, useUser } from '@stackframe/stack';
import { Button, buttonVariants, Card, CardContent, CardFooter, CardHeader, Typography } from '@stackframe/stack-ui';
import Image from 'next/image';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
export default function PageClient() {
const user = useUser({ includeRestricted: true });
const router = useRouter();
const app = useStackApp();
const authButtons = (
@ -18,8 +16,8 @@ export default function PageClient() {
<Typography>Try signing in/up with the buttons below!</Typography>
<Typography>Also feel free to check out the things on the top right corner.</Typography>
<div className='flex gap-2'>
<Button onClick={() => router.push(app.urls.signIn)}>Sign In</Button>
<Button onClick={() => router.push(app.urls.signUp)}>Sign Up</Button>
<Button onClick={async () => await app.redirectToSignIn()}>Sign In</Button>
<Button onClick={async () => await app.redirectToSignUp()}>Sign Up</Button>
</div>
</div>
);
@ -72,9 +70,9 @@ export default function PageClient() {
<Link href="https://app.stack-auth.com" className={buttonVariants()}>
Visit Stack Auth
</Link>
<Link href={app.urls.signOut} className={buttonVariants({ variant: 'destructive' })}>
<Button variant='destructive' onClick={async () => await app.redirectToSignOut()}>
Sign Out
</Link>
</Button>
</div>
</CardFooter>
</Card>

View File

@ -21,6 +21,11 @@ const testKeys = {
};
const authReturnStorageKey = "turnstile-auth-demo-last-redirect";
const handlerRoutes = {
oauthCallback: "/handler/oauth-callback",
magicLinkCallback: "/handler/magic-link-callback",
error: "/handler/error",
};
type FlowResult = {
status: "success" | "error" | "info",
@ -317,7 +322,7 @@ export default function TurnstileSignupPageClient() {
}
function getOAuthCallbackUrlForTurnstileLab() {
const callbackUrl = new URL(getAppAbsoluteUrl(app.urls.oauthCallback));
const callbackUrl = new URL(getAppAbsoluteUrl(handlerRoutes.oauthCallback));
callbackUrl.searchParams.set("after_auth_return_to", getCurrentRelativeUrl());
return callbackUrl.toString();
}
@ -409,7 +414,7 @@ export default function TurnstileSignupPageClient() {
async function handleMagicLinkVisibleDrill(): Promise<FlowResult> {
const drillEmail = freshEmail();
const callbackUrl = getAppAbsoluteUrl(app.urls.magicLinkCallback);
const callbackUrl = getAppAbsoluteUrl(handlerRoutes.magicLinkCallback);
const firstRes = await debugMagicLinkSend(sendRequest, {
email: drillEmail,
@ -451,7 +456,7 @@ export default function TurnstileSignupPageClient() {
codeChallenge: oauthDebugState.codeChallenge,
state: oauthDebugState.state,
redirectUrl: getOAuthCallbackUrlForTurnstileLab(),
errorRedirectUrl: getAppAbsoluteUrl(app.urls.error),
errorRedirectUrl: getAppAbsoluteUrl(handlerRoutes.error),
turnstileToken: "mock-turnstile-invalid",
turnstilePhase: "invisible",
});
@ -469,7 +474,7 @@ export default function TurnstileSignupPageClient() {
codeChallenge: oauthDebugState.codeChallenge,
state: oauthDebugState.state,
redirectUrl: getOAuthCallbackUrlForTurnstileLab(),
errorRedirectUrl: getAppAbsoluteUrl(app.urls.error),
errorRedirectUrl: getAppAbsoluteUrl(handlerRoutes.error),
turnstileToken: visibleToken,
turnstilePhase: "visible",
});
@ -820,12 +825,12 @@ export default function TurnstileSignupPageClient() {
</Typography>
</CardContent>
<CardFooter className="flex gap-2">
<Link href={app.urls.signUp} className="inline-flex items-center rounded-md border px-4 py-2 text-sm font-medium">
<Button variant="secondary" onClick={async () => await app.redirectToSignUp()}>
Hosted sign-up
</Link>
<Link href={app.urls.signIn} className="inline-flex items-center rounded-md border px-4 py-2 text-sm font-medium">
</Button>
<Button variant="secondary" onClick={async () => await app.redirectToSignIn()}>
Hosted sign-in
</Link>
</Button>
</CardFooter>
</Card>
@ -1052,12 +1057,12 @@ export default function TurnstileSignupPageClient() {
<Link href="/" className="inline-flex items-center rounded-md border px-4 py-2 text-sm font-medium">
Back to home
</Link>
<Link href={app.urls.signUp} className="inline-flex items-center rounded-md border px-4 py-2 text-sm font-medium">
<Button variant="secondary" onClick={async () => await app.redirectToSignUp()}>
Open hosted sign-up
</Link>
<Link href={app.urls.signIn} className="inline-flex items-center rounded-md border px-4 py-2 text-sm font-medium">
</Button>
<Button variant="secondary" onClick={async () => await app.redirectToSignIn()}>
Open hosted sign-in
</Link>
</Button>
</CardFooter>
</Card>
</div>

View File

@ -2,19 +2,16 @@
import { useStackApp, useUser } from '@stackframe/stack';
import { Button } from '@stackframe/stack-ui';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
export default function PageClient() {
const user = useUser();
const router = useRouter();
const app = useStackApp();
const authButtons = (
<div className='flex flex-col gap-5 justify-center items-center'>
<div className='flex gap-5'>
<Button onClick={() => router.push(app.urls.signIn)}>Sign In</Button>
<Button onClick={() => router.push('/handler/signup')}>Sign Up</Button>
<Button onClick={async () => await app.redirectToSignIn()}>Sign In</Button>
<Button onClick={async () => await app.redirectToSignUp()}>Sign Up</Button>
</div>
</div>
);
@ -23,9 +20,9 @@ export default function PageClient() {
<div className='flex flex-col items-center justify-center h-full w-full gap-10'>
{user ? (
<div className='flex flex-col gap-5 justify-center items-center'>
<Link href={app.urls.signOut}>
<Button variant="secondary" onClick={async () => await app.redirectToSignOut()}>
Sign Out
</Link>
</Button>
</div>
) : authButtons}
</div>

View File

@ -42,7 +42,7 @@
// setError('Please enter your password');
// return;
// }
// // this will redirect to app.urls.afterSignIn if successful, you can customize it in the StackServerApp constructor
// // this redirects to your configured post-sign-in destination on success
// const result = await app.signInWithCredential({ email, password });
// // It is better to handle each error code separately, but we will just show the error code directly for simplicity here
// if (result.status === 'error') {
@ -73,7 +73,7 @@ export default function CustomCredentialSignIn() {
const app = useStackApp();
const onSubmit = async () => {
// this will redirect to app.urls.afterSignIn if successful, you can customize it in the StackServerApp constructor
// this redirects to your configured post-sign-in destination on success
const result = await app.sendMagicLinkEmail(email);
// It is better to handle each error code separately, but we will just show the error code directly for simplicity here
if (result.status === 'error') {

View File

@ -24,7 +24,7 @@ export default function CustomCredentialSignUp() {
setError('Please enter your password');
return;
}
// this will redirect to app.urls.afterSignUp if successful, you can customize it in the StackServerApp constructor
// this redirects to your configured post-sign-up destination on success
const result = await app.signUpWithCredential({ email, password });
// It is better to handle each error code separately, but we will just show the error code directly for simplicity here
if (result.status === 'error') {

View File

@ -2,7 +2,6 @@
import { createSupabaseClient } from "@/utils/supabase-client";
import { useStackApp, useUser } from "@stackframe/stack";
import Link from "next/link";
import { useEffect, useState } from "react";
export default function Page() {
@ -28,9 +27,9 @@ export default function Page() {
<>
<p>You are signed in</p>
<p>User ID: {user.id}</p>
<Link href={app.urls.signOut}>Sign Out</Link>
<button onClick={async () => await app.redirectToSignOut()}>Sign Out</button>
</> :
<Link href={app.urls.signIn}>Sign In</Link>
<button onClick={async () => await app.redirectToSignIn()}>Sign In</button>
}
<h3>Supabase data</h3>
<ul>{listContent}</ul>

View File

@ -65,6 +65,8 @@ const availablePaths = {
onboarding: 'onboarding',
} as const;
const placeholderOrigin = "http://example.com";
const pathAliases = {
// also includes the uppercase and non-dashed versions
...Object.fromEntries(Object.entries(availablePaths).map(([key, value]) => [value, value])),
@ -258,14 +260,16 @@ export function StackHandlerClient(props: BaseHandlerProps & Partial<RouteProps>
if (isCrossDomainLocalOauthCallback) {
return;
}
const urlObject = new URL(url, window.location.origin);
const isLocalHandlerTarget = urlObject.origin === window.location.origin
&& (urlObject.pathname === handlerPath || urlObject.pathname.startsWith(`${handlerPath}/`));
const urlObject = new URL(url, placeholderOrigin);
const isHandlerPathTarget = urlObject.pathname === handlerPath || urlObject.pathname.startsWith(`${handlerPath}/`);
const isLocalHandlerTarget = typeof window === "undefined"
? isHandlerPathTarget
: urlObject.origin === window.location.origin && isHandlerPathTarget;
if (isLocalHandlerTarget) {
return;
}
const urlObj = new URL(url, "http://example.com");
const urlObj = new URL(url, placeholderOrigin);
for (const [key, value] of Object.entries(searchParams)) {
urlObj.searchParams.set(key, value);
}

View File

@ -58,7 +58,7 @@ import { StackClientApp, StackClientAppConstructorOptions, StackClientAppJson }
import { _StackAdminAppImplIncomplete } from "./admin-app-impl";
import { TokenObject, clientVersion, createCache, createCacheBySession, createEmptyTokenStore, getAnalyticsBaseUrl, getBaseUrl, getDefaultExtraRequestHeaders, getDefaultProjectId, getDefaultPublishableClientKey, getUrls, resolveConstructorOptions } from "./common";
import { EventTracker } from "./event-tracker";
import { crossDomainAuthQueryParams, getCrossDomainHandoffParamsFromCurrentUrl, planRedirectToHandler, resolveAppUrlsForCurrentPage } from "./redirect-page-urls";
import { crossDomainAuthQueryParams, getCrossDomainHandoffParamsFromCurrentUrl, planRedirectToHandler } from "./redirect-page-urls";
import type { CrossDomainHandoffParams } from "./redirect-page-urls";
import { AnalyticsOptions, SessionRecorder, analyticsOptionsFromJson, analyticsOptionsToJson } from "./session-replay";
@ -2244,20 +2244,7 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
}
get urls(): Readonly<ResolvedHandlerUrls> {
const resolved = getUrls(this._urlOptions, { projectId: this.projectId });
if (isReactServer || typeof window === "undefined") {
return resolved;
}
const currentUrl = new URL(window.location.href);
const localOAuthCallbackUrl = this._getLocalOAuthCallbackHandlerUrl();
const crossDomainHandoffParams = this._getCrossDomainHandoffParamsForUrlsGetter(currentUrl);
return resolveAppUrlsForCurrentPage({
resolvedUrls: resolved,
currentUrl,
crossDomainHandoffParams,
localOAuthCallbackUrl,
});
return getUrls(this._urlOptions, { projectId: this.projectId });
}
protected _prefetchCrossDomainHandoffParamsIfNeeded() {

View File

@ -52,6 +52,10 @@ export type StackClientApp<HasTokenStore extends boolean = boolean, ProjectId ex
*/
readonly version: string,
/**
* @deprecated `app.urls` is static and does not include runtime redirect-back parameters.
* For navigation, prefer `redirectToXyz()` methods (for example `redirectToSignIn()`).
*/
readonly urls: Readonly<ResolvedHandlerUrls>,
signInWithOAuth(provider: string, options?: { returnTo?: string }): Promise<void>,