diff --git a/apps/e2e/tests/js/cross-domain-auth.test.ts b/apps/e2e/tests/js/cross-domain-auth.test.ts index e5898ca0d..f48f0fa9e 100644 --- a/apps/e2e/tests/js/cross-domain-auth.test.ts +++ b/apps/e2e/tests/js/cross-domain-auth.test.ts @@ -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; diff --git a/claude/CLAUDE-KNOWLEDGE.md b/claude/CLAUDE-KNOWLEDGE.md index 577f7bfe3..5f8b55c58 100644 --- a/claude/CLAUDE-KNOWLEDGE.md +++ b/claude/CLAUDE-KNOWLEDGE.md @@ -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`. diff --git a/examples/demo/src/app/cross-domain-handoff/page.tsx b/examples/demo/src/app/cross-domain-handoff/page.tsx index 686d2e397..2b95bf086 100644 --- a/examples/demo/src/app/cross-domain-handoff/page.tsx +++ b/examples/demo/src/app/cross-domain-handoff/page.tsx @@ -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 }> = [ { 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() {
Signed in: {user ? "yes" : "no"}
Current URL: {currentUrl}
-
Sign-in URL: {app.urls.signIn}
-
OAuth callback URL: {app.urls.oauthCallback}
+
Sign-in route: /handler/sign-in
+
OAuth callback route: /handler/oauth-callback
- Cross-domain mode:{" "} - {isHostedSignIn ? "active (hosted sign-in URL is absolute)" : "inactive (sign-in URL is local/relative)"} + Cross-domain mode: driven by redirect methods and current URL state
diff --git a/examples/demo/src/app/page-client.tsx b/examples/demo/src/app/page-client.tsx index 11092fbb9..4d876ef31 100644 --- a/examples/demo/src/app/page-client.tsx +++ b/examples/demo/src/app/page-client.tsx @@ -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() { Try signing in/up with the buttons below! Also feel free to check out the things on the top right corner.
- - + +
); @@ -72,9 +70,9 @@ export default function PageClient() { Visit Stack Auth - + diff --git a/examples/demo/src/app/turnstile-signup/page-client.tsx b/examples/demo/src/app/turnstile-signup/page-client.tsx index a448702f1..4d46a291c 100644 --- a/examples/demo/src/app/turnstile-signup/page-client.tsx +++ b/examples/demo/src/app/turnstile-signup/page-client.tsx @@ -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 { 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() { - + + @@ -1052,12 +1057,12 @@ export default function TurnstileSignupPageClient() { Back to home - + + diff --git a/examples/docs-examples/src/app/page-client.tsx b/examples/docs-examples/src/app/page-client.tsx index f91c560ee..b9ac1a417 100644 --- a/examples/docs-examples/src/app/page-client.tsx +++ b/examples/docs-examples/src/app/page-client.tsx @@ -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 = (
- - + +
); @@ -23,9 +20,9 @@ export default function PageClient() {
{user ? (
- +
) : authButtons}
diff --git a/examples/docs-examples/src/app/signin/page.tsx b/examples/docs-examples/src/app/signin/page.tsx index f6652522d..5608256a2 100644 --- a/examples/docs-examples/src/app/signin/page.tsx +++ b/examples/docs-examples/src/app/signin/page.tsx @@ -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') { diff --git a/examples/docs-examples/src/app/signup/page.tsx b/examples/docs-examples/src/app/signup/page.tsx index 2429476c1..a22d483ba 100644 --- a/examples/docs-examples/src/app/signup/page.tsx +++ b/examples/docs-examples/src/app/signup/page.tsx @@ -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') { diff --git a/examples/supabase/app/page.tsx b/examples/supabase/app/page.tsx index dde58174e..bfeecef7f 100644 --- a/examples/supabase/app/page.tsx +++ b/examples/supabase/app/page.tsx @@ -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() { <>

You are signed in

User ID: {user.id}

- Sign Out + : - Sign In + }

Supabase data

diff --git a/packages/template/src/components-page/stack-handler-client.tsx b/packages/template/src/components-page/stack-handler-client.tsx index 21c12bac5..e0558df41 100644 --- a/packages/template/src/components-page/stack-handler-client.tsx +++ b/packages/template/src/components-page/stack-handler-client.tsx @@ -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 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); } diff --git a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts index 65cb67b36..2fa132dfd 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts @@ -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 { - 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() { diff --git a/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts b/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts index 74238d1ee..462fcf354 100644 --- a/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts +++ b/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts @@ -52,6 +52,10 @@ export type StackClientApp, signInWithOAuth(provider: string, options?: { returnTo?: string }): Promise,