From f4c13db07949a45e84f7a07786978018446c07ff Mon Sep 17 00:00:00 2001 From: BilalG1 Date: Thu, 11 Jun 2026 17:37:05 -0700 Subject: [PATCH 1/2] fix(sdk): stop nested cross-domain auth from restarting the redirect chain on the hosted domain (#1581) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What users see Setting up a new project with hosted components, clicking **Sign in** sometimes throws the browser into a redirect ping-pong between the app and the hosted components site — anywhere from 5 to 9+ cross-domain redirects — before the sign-in page finally renders. Reproduced live against production: ![redirect loop demo](https://gist.githubusercontent.com/BilalG1/888feed849ef0bc1f73c4609bfd71662/raw/redirect-loop.gif) Captured redirect chain from that recording (one line per navigation, ~1 per second): ``` localhost:3000/ ← click "Sign in" HOSTED /handler/sign-in?...nested_refresh_token_id ← start session handoff localhost:3000/?redirect_uri=...&state=S1 ← bounce: "prove the session" HOSTED /handler/sign-in?...&code=... ← code delivered... then RESTART ↩ localhost:3000/?redirect_uri=...&state=S2 ← bounce again (fresh state!) HOSTED /handler/sign-in?...&code=... ← code delivered... RESTART ↩ localhost:3000/?redirect_uri=...&state=S3 ← again HOSTED /handler/sign-in?...&code=... ← again localhost:3000/?redirect_uri=...&state=S4 ← again HOSTED /handler/sign-in?...&code=... ← exchange finally wins the race HOSTED /handler/sign-in (clean URL) ← sign-in form renders ``` The designed handshake is only 3 cross-domain redirects. Everything past that is one bug restarting the chain over and over. ## The bug When a page on the hosted domain loads with a one-time `code`, the `StackClientApp` constructor schedules **two** async startup flows back-to-back: 1. `callOAuthCallback` — which **synchronously strips `code` + `state` from the URL** (`history.replaceState`) before starting its network token exchange, and 2. `_maybeHandleNestedCrossDomainAuth` — which has a guard for exactly this situation ("a real OAuth callback wins"), implemented as *"is `code`+`state` in the URL?"* Flow 1 runs first. By the time flow 2 reads `window.location`, the params it's guarding on are already gone — so it concludes no OAuth callback is happening, sees the (un-stripped) nested handoff marker, and bounces back to the app domain to request a *new* code, cancelling the in-flight exchange: ```mermaid sequenceDiagram participant A as Your app (a.com) participant B as Hosted sign-in (b.com) A->>B: 1. go to sign-in ("I have session X") B->>A: 2. "prove it" (state, code_challenge) A->>B: 3. one-time code for session X Note over B: callOAuthCallback strips code+state from URL,
starts token exchange (network) Note over B: nested handler runs next, checks URL for code+state…
already gone → guard defeated ❌ B->>A: 2'. "prove it" AGAIN (fresh state) — exchange cancelled A->>B: 3'. another one-time code Note over B: …loop repeats until the exchange happens to
finish before the re-bounce navigation commits ``` Whether each cycle escapes is a coin flip between two competing navigations (the exchange's success redirect vs. the handler's re-bounce), which is why the loop count varies run to run and the issue reproduces so inconsistently. ## The fix Capture the URL once, at construction time — before anything can mutate it — and let the nested handler consult that snapshot in addition to the live URL: - The constructor now captures `new URL(window.location.href)` when scheduling the nested-auth resolution and passes it in. - `_maybeHandleNestedCrossDomainAuth(urlAtConstructionTime?)` stands down if **either** the live URL **or** the construction-time URL carries `code` + `state`. A stripped callback still counts as a callback, so the handler no longer re-bounces while the exchange is in flight. Every other path is unchanged: the handler still reads all of its working params from the live URL (the strip never touches the nested params), hop-1/hop-2 pages have no `code` in either snapshot, and ordinary social-login callbacks never had this race (the component-driven flow strips long after the handler has run). Note this fix removes the *restarts*. The remaining 3-redirect baseline for signed-out users is a separate design issue (the analytics-created anonymous session triggering the handoff at all) and is intentionally out of scope here. ## Tests - New: `does not re-bounce nested cross-domain auth after the OAuth callback consumed code+state from the URL` — pins both guards (mutation-tested: reverting either fix line fails it). - New: `passes the construction-time URL to the nested cross-domain auth handler` — pins the eager capture; fails if the URL is read lazily at handler run time. - Full cross-domain suite passes (the `signOut` timeout in that file is a pre-existing flake on `dev`, reproducible without this change). --- ## Summary by cubic Fixes a race in nested cross-domain auth that caused repeated redirects between the app and the hosted sign-in. We now snapshot the URL at construction so OAuth callbacks are respected even after `code` and `state` are stripped. - **Bug Fixes** - Capture `window.location` at construction and pass it to `_maybeHandleNestedCrossDomainAuth`. - Handler stands down if `code` and `state` exist in the live or captured URL. - Stops the redirect ping‑pong; the 3‑redirect baseline remains unchanged. - Keeps reading nested params from the live URL; no other paths changed. - Adds tests to pin the race and the construction‑time URL behavior. Written for commit f312baa54cbce2624dc9528581f1246fb7e4fb05. Summary will update on new commits. Review in cubic ## Summary by CodeRabbit * **Bug Fixes** * Improved cross-domain OAuth authentication handling to prevent unnecessary redirects after OAuth callback processing. * **Tests** * Added test coverage for nested cross-domain OAuth authentication scenarios. --- .../client-app-impl.cross-domain.test.ts | 102 ++++++++++++++++++ .../apps/implementations/client-app-impl.ts | 12 ++- 2 files changed, 112 insertions(+), 2 deletions(-) diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.ts b/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.ts index 86566be44..57fb05809 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.ts @@ -325,6 +325,108 @@ describe("StackClientApp cross-domain auth", () => { expect(refreshedRawRefreshTokens).toEqual(["new-refresh-token"]); }); + it("does not re-bounce nested cross-domain auth after the OAuth callback consumed code+state from the URL", async () => { + const projectId = "00000000-0000-4000-8000-000000000008"; + const previousWindow = globalThis.window; + const previousDocument = globalThis.document; + + const strippedUrl = new URL(`https://${projectId}.example-stack-hosted.test/handler/sign-in`); + strippedUrl.searchParams.set("stack_nested_cross_domain_auth_refresh_token_id", "source-refresh-token-id"); + strippedUrl.searchParams.set("stack_nested_cross_domain_auth_callback_url", "https://demo.stack-auth.com/"); + const urlAtConstructionTime = new URL(strippedUrl); + urlAtConstructionTime.searchParams.set("code", "one-time-code"); + urlAtConstructionTime.searchParams.set("state", "nested-oauth-state"); + + // Construct before installing the window mock so the constructor does not schedule its own + // nested-auth resolution; the assertions below drive the handler explicitly. + const clientApp = new StackClientApp({ + baseUrl: "http://localhost:12345", + projectId, + publishableClientKey: "stack-pk-test", + tokenStore: "memory", + redirectMethod: "window", + noAutomaticPrefetch: true, + }); + + globalThis.document = createMockDocument(); + globalThis.window = { + location: { + href: strippedUrl.toString(), + replace: () => { + throw new Error("INTENTIONAL_TEST_ABORT"); + }, + }, + } as any; + + vi.spyOn(clientApp as any, "_fetchCurrentRefreshTokenIdIfSignedIn").mockResolvedValue(null); + vi.spyOn(clientApp as any, "_getCrossDomainHandoffParamsForRedirect").mockResolvedValue({ + state: "fresh-nested-state", + codeChallenge: "fresh-nested-code-challenge", + }); + vi.spyOn(clientApp as any, "_isTrusted").mockResolvedValue(true); + + try { + // Without the construction-time URL, the handler re-bounces (location.replace aborts). + await expect((clientApp as any)._maybeHandleNestedCrossDomainAuth()).rejects.toThrowError("INTENTIONAL_TEST_ABORT"); + // With it, the in-flight OAuth callback wins and the handler stands down. + await expect((clientApp as any)._maybeHandleNestedCrossDomainAuth(urlAtConstructionTime)).resolves.toBe(false); + // The live-URL guard must also stand down on its own when code+state are still present. + (globalThis.window as any).location.href = urlAtConstructionTime.toString(); + await expect((clientApp as any)._maybeHandleNestedCrossDomainAuth()).resolves.toBe(false); + } finally { + globalThis.window = previousWindow; + globalThis.document = previousDocument; + } + }); + + it("passes the construction-time URL to the nested cross-domain auth handler", async () => { + const projectId = "00000000-0000-4000-8000-000000000009"; + const previousWindow = globalThis.window; + const previousDocument = globalThis.document; + + const callbackUrl = new URL(`https://${projectId}.example-stack-hosted.test/handler/sign-in`); + callbackUrl.searchParams.set("stack_nested_cross_domain_auth_refresh_token_id", "source-refresh-token-id"); + callbackUrl.searchParams.set("code", "one-time-code"); + callbackUrl.searchParams.set("state", "nested-oauth-state"); + const strippedUrl = new URL(callbackUrl); + strippedUrl.searchParams.delete("code"); + strippedUrl.searchParams.delete("state"); + + globalThis.document = createMockDocument(); + globalThis.window = { + location: { + href: callbackUrl.toString(), + }, + } as any; + + const nestedAuthSpy = vi.spyOn(StackClientApp.prototype as any, "_maybeHandleNestedCrossDomainAuth").mockResolvedValue(false); + + try { + new StackClientApp({ + baseUrl: "http://localhost:12345", + projectId, + publishableClientKey: "stack-pk-test", + tokenStore: "memory", + redirectMethod: "window", + noAutomaticPrefetch: true, + }); + + // Simulate consumeOAuthCallbackQueryParams stripping code+state before microtasks run. + (globalThis.window as any).location.href = strippedUrl.toString(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(nestedAuthSpy).toHaveBeenCalledTimes(1); + const urlArgument = nestedAuthSpy.mock.calls[0][0] as URL; + expect(urlArgument).toBeInstanceOf(URL); + expect(urlArgument.searchParams.get("code")).toBe("one-time-code"); + expect(urlArgument.searchParams.get("state")).toBe("nested-oauth-state"); + } finally { + nestedAuthSpy.mockRestore(); + globalThis.window = previousWindow; + globalThis.document = previousDocument; + } + }); + it("uses direct sign-out instead of hosted sign-out redirects when code execution is available", async () => { const clientApp = new StackClientApp({ baseUrl: "http://localhost:12345", diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.ts index b26d68011..2154336f4 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.ts @@ -718,8 +718,12 @@ export class _HexclaveClientAppImplIncomplete { - await this._maybeHandleNestedCrossDomainAuth(); + await this._maybeHandleNestedCrossDomainAuth(urlAtConstructionTime); }); } @@ -890,11 +894,15 @@ export class _HexclaveClientAppImplIncomplete { + protected async _maybeHandleNestedCrossDomainAuth(urlAtConstructionTime?: URL): Promise { if (typeof window === "undefined") return false; const currentUrl = new URL(window.location.href); // A real OAuth callback wins over nested handoff detection on the final return to b.com. + // The OAuth callback resolution strips `code` and `state` from the live URL before this + // runs, so the check must also consult the URL captured at construction time — otherwise + // we'd re-bounce to the source domain while the token exchange is still in flight. if (currentUrl.searchParams.has("code") && currentUrl.searchParams.has("state")) return false; + if (urlAtConstructionTime != null && urlAtConstructionTime.searchParams.has("code") && urlAtConstructionTime.searchParams.has("state")) return false; const refreshTokenId = currentUrl.searchParams.get(nestedCrossDomainAuthQueryParams.refreshTokenId); if (refreshTokenId == null) return false; From 64eeedce9fc39c5076348efd04b4aadcc9da6534 Mon Sep 17 00:00:00 2001 From: BilalG1 Date: Thu, 11 Jun 2026 17:37:11 -0700 Subject: [PATCH 2/2] Fix dev CI: docker prune missing template + cross-domain test failures (#1582) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unbreaks the test workflows that have been red on every dev push since June 4. All root causes trace to direct pushes whose own CI runs already failed (`8b78587da`, `c60016226`, `59daf1321`). > Note: this PR originally also fixed the "Docker Server Build and Push" workflow (missing `@hexclave/template` in the Dockerfiles' `turbo prune` scope), but dev picked up the identical fix via 59daf1321 while this was open, so the Dockerfile changes dropped out after merging dev back in. ## 1. E2E cross-domain spies — broken since `c60016226` (June 4) `c60016226` renamed `_getCurrentRefreshTokenIdIfSignedIn` → `_fetchCurrentRefreshTokenIdIfSignedIn` in the SDK and template unit tests, but missed the eight `vi.spyOn` calls in `apps/e2e/tests/js/cross-domain-auth.test.ts`. `vi.spyOn` throws on missing properties → all 8 tests failed with `_getCurrentRefreshTokenIdIfSignedIn does not exist`. **Fix:** rename the spies. ## 2. signOut test timeout in all 5 SDK packages — broken since `c60016226` (June 4) The refresh-cookie test added in the same commit writes to a cookie token store, which queues a background trusted-parent-domain lookup. That lookup fetches the unreachable test `baseUrl` with retries while holding `storeLock`'s read lock (via `AsyncStore.setAsync`), so the later signOut test deadlocks on `storeLock.withWriteLock` inside `_signOut` and hits the 5s vitest timeout (passes in isolation, fails when the file runs in order). **Fix:** stub `_getTrustedParentDomain` in the cookie test so the queued task settles immediately. ## 3. "does not await pending auth resolutions" — premise broken by `8b78587da` (June 4), masked by the spy rename `8b78587da` added `nonHostedHandlerNames`, making `afterSignIn` resolve to a local URL instead of the hosted domain. The test redirected to `afterSignIn` from a callback page expecting the nested cross-domain auth params path to run — but the redirect became same-origin, so `_fetchCurrentRefreshTokenIdIfSignedIn` is (correctly) never called. This was invisible until fix 1 above unmasked it. **Fix:** redirect to `accountSettings` (still hosted, so still cross-origin), preserving the test's intent: the session lookup during nested-param construction must not await pending auth resolutions. ## 4. internal-metrics e2e snapshots — broken on dev by `59daf1321` The analytics overview filters PR reshaped the metrics endpoint response (added `bounce_rate`, daily/hourly breakdown arrays, `top_browsers`/`devices`/`operating_systems`/`regions`; slimmed the zero-filled daily fallback arrays) and updated the backend unit-test snapshots, but left the e2e snapshots stale — its own dev run fails these two tests identically. **Fix:** update `__snapshots__/internal-metrics.test.ts.snap`, reconstructed from the CI diff with every context line verified against the old snapshot, and the new fields cross-checked against the route change in 59daf1321. ## Verification - `client-app-impl.cross-domain.test.ts`: 7/7 in `packages/template` and the regenerated `packages/js` copy (signOut: 5s timeout → 10ms). - `tests/js/cross-domain-auth.test.ts`: 18/18 locally (fully mocked, no backend needed). - Lint + typecheck pass for the touched packages. - The metrics snapshot can only be fully confirmed by CI (needs the live backend). ## Out of scope "Run setup tests with custom base port" also intermittently fails unrelated test files at exactly 60s under runner load (last green May 5), and the local-emulator run had one `payments/switch-plans` flake — pre-existing flakiness not addressed here. --- .../internal-metrics.test.ts.snap | 1182 ++++++----------- apps/e2e/tests/js/cross-domain-auth.test.ts | 26 +- .../client-app-impl.cross-domain.test.ts | 6 + 3 files changed, 440 insertions(+), 774 deletions(-) diff --git a/apps/e2e/tests/backend/endpoints/api/v1/__snapshots__/internal-metrics.test.ts.snap b/apps/e2e/tests/backend/endpoints/api/v1/__snapshots__/internal-metrics.test.ts.snap index 3e6e392ad..8bd520ef5 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/__snapshots__/internal-metrics.test.ts.snap +++ b/apps/e2e/tests/backend/endpoints/api/v1/__snapshots__/internal-metrics.test.ts.snap @@ -8,6 +8,7 @@ NiceResponse { "analytics_overview": { "anonymous_visitors_fallback": 0, "avg_session_seconds": 0, + "bounce_rate": 0, "daily_anonymous_visitors_fallback": [ { "activity": 0, @@ -134,258 +135,10 @@ NiceResponse { "date": , }, ], - "daily_clicks": [ - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - ], - "daily_page_views": [ - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - ], + "daily_avg_session_seconds": [], + "daily_bounce_rate": [], + "daily_clicks": [], + "daily_page_views": [], "daily_revenue": [ { "date": , @@ -543,137 +296,19 @@ NiceResponse { "refund_cents": 0, }, ], - "daily_visitors": [ - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - ], + "daily_visitors": [], + "hourly_active_users": [], + "hourly_page_views": [], + "hourly_visitors": [], "online_live": 0, "recent_replays": 0, "revenue_per_visitor": 0, + "top_browsers": [], + "top_devices": [], + "top_operating_systems": [], "top_referrers": [], "top_region": null, + "top_regions": [], "total_replays": 0, "total_revenue_cents": 0, "visitors": 0, @@ -2279,6 +1914,202 @@ NiceResponse { "recent_emails": [], "total_emails": 0, }, + "hourly_active_users": [ + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + ], + "hourly_users": [ + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + ], "live_users": , "login_methods": [], "payments_overview": { @@ -2462,6 +2293,7 @@ NiceResponse { "analytics_overview": { "anonymous_visitors_fallback": 0, "avg_session_seconds": 0, + "bounce_rate": 0, "daily_anonymous_visitors_fallback": [ { "activity": 0, @@ -2588,258 +2420,10 @@ NiceResponse { "date": , }, ], - "daily_clicks": [ - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - ], - "daily_page_views": [ - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - ], + "daily_avg_session_seconds": [], + "daily_bounce_rate": [], + "daily_clicks": [], + "daily_page_views": [], "daily_revenue": [ { "date": , @@ -2997,141 +2581,19 @@ NiceResponse { "refund_cents": 0, }, ], - "daily_visitors": [ - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - { - "activity": 0, - "date": , - }, - ], - "online_live": 10, + "daily_visitors": [], + "hourly_active_users": [], + "hourly_page_views": [], + "hourly_visitors": [], + "online_live": 0, "recent_replays": 0, "revenue_per_visitor": 0, + "top_browsers": [], + "top_devices": [], + "top_operating_systems": [], "top_referrers": [], - "top_region": { - "count": 10, - "country_code": null, - "region_code": null, - }, + "top_region": null, + "top_regions": [], "total_replays": 0, "total_revenue_cents": 0, "visitors": 0, @@ -4828,6 +4290,202 @@ NiceResponse { ], "total_emails": 15, }, + "hourly_active_users": [ + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 10, + "date": , + }, + ], + "hourly_users": [ + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 9, + "date": , + }, + ], "live_users": , "login_methods": [ { diff --git a/apps/e2e/tests/js/cross-domain-auth.test.ts b/apps/e2e/tests/js/cross-domain-auth.test.ts index 599ba699a..ed970daf6 100644 --- a/apps/e2e/tests/js/cross-domain-auth.test.ts +++ b/apps/e2e/tests/js/cross-domain-auth.test.ts @@ -293,8 +293,8 @@ it("does not await pending auth resolutions when post-callback redirect adds nes await withHostedDomainSuffix(async () => { const projectId = "13131313-1313-4313-8313-131313131313"; const clientApp = createClientApp(projectId); - const getCurrentRefreshTokenIdIfSignedInSpy = vi - .spyOn(clientApp as any, "_getCurrentRefreshTokenIdIfSignedIn") + const fetchCurrentRefreshTokenIdIfSignedInSpy = vi + .spyOn(clientApp as any, "_fetchCurrentRefreshTokenIdIfSignedIn") .mockResolvedValue(null); const previousWindow = globalThis.window; @@ -310,8 +310,10 @@ it("does not await pending auth resolutions when post-callback redirect adds nes } as any; try { + // accountSettings (unlike afterSignIn & co, which resolve to local URLs) still lives on the + // hosted domain, so it exercises the nested cross-domain auth params path. await expect((clientApp as any)._redirectToHandler( - "afterSignIn", + "accountSettings", { replace: true }, { awaitPendingAuthResolutions: false }, )).rejects.toThrowError("INTENTIONAL_TEST_ABORT"); @@ -320,9 +322,9 @@ it("does not await pending auth resolutions when post-callback redirect adds nes globalThis.document = previousDocument; } - expect(getCurrentRefreshTokenIdIfSignedInSpy).toHaveBeenCalledWith({ + expect(fetchCurrentRefreshTokenIdIfSignedInSpy).toHaveBeenCalledWith(expect.objectContaining({ awaitPendingAuthResolutions: false, - }); + })); }); }); @@ -446,7 +448,7 @@ it("adds nested cross-domain auth params when redirecting signed-in users to hos const currentHref = `${localRedirectUrl}/dashboard?tab=settings`; const clientApp = createClientApp(projectId); - vi.spyOn(clientApp as any, "_getCurrentRefreshTokenIdIfSignedIn").mockResolvedValue(refreshTokenId); + vi.spyOn(clientApp as any, "_fetchCurrentRefreshTokenIdIfSignedIn").mockResolvedValue(refreshTokenId); const previousWindow = globalThis.window; const previousDocument = globalThis.document; @@ -484,7 +486,7 @@ it("adds nested cross-domain auth params for other cross-domain handler redirect const currentHref = `${localRedirectUrl}/private-page`; const clientApp = createClientApp(projectId); - vi.spyOn(clientApp as any, "_getCurrentRefreshTokenIdIfSignedIn").mockResolvedValue(refreshTokenId); + vi.spyOn(clientApp as any, "_fetchCurrentRefreshTokenIdIfSignedIn").mockResolvedValue(refreshTokenId); const previousWindow = globalThis.window; const previousDocument = globalThis.document; @@ -524,7 +526,7 @@ it("starts nested cross-domain auth from the target domain", async ({ expect }) const previousDocument = globalThis.document; let redirectedUrl = ""; - vi.spyOn(clientApp as any, "_getCurrentRefreshTokenIdIfSignedIn").mockResolvedValue(null); + vi.spyOn(clientApp as any, "_fetchCurrentRefreshTokenIdIfSignedIn").mockResolvedValue(null); vi.spyOn(clientApp as any, "_getCrossDomainHandoffParamsForRedirect").mockResolvedValue({ state: "nested-state", codeChallenge: "nested-code-challenge", @@ -588,7 +590,7 @@ it("carries hosted sign-in return state on the nested OAuth redirect URI", async const previousDocument = globalThis.document; let redirectedUrl = ""; - vi.spyOn(clientApp as any, "_getCurrentRefreshTokenIdIfSignedIn").mockResolvedValue(null); + vi.spyOn(clientApp as any, "_fetchCurrentRefreshTokenIdIfSignedIn").mockResolvedValue(null); vi.spyOn(clientApp as any, "_getCrossDomainHandoffParamsForRedirect").mockResolvedValue({ state: "nested-state", codeChallenge: "nested-code-challenge", @@ -649,7 +651,7 @@ it("continues nested cross-domain auth on the source domain", async ({ expect }) const createCrossDomainAuthRedirectUrlSpy = vi .spyOn(clientApp as any, "_createCrossDomainAuthRedirectUrl") .mockResolvedValue(crossDomainRedirect); - vi.spyOn(clientApp as any, "_getCurrentRefreshTokenIdIfSignedIn").mockResolvedValue(sourceRefreshTokenId); + vi.spyOn(clientApp as any, "_fetchCurrentRefreshTokenIdIfSignedIn").mockResolvedValue(sourceRefreshTokenId); globalThis.document = createMockDocument(); globalThis.window = { @@ -721,7 +723,7 @@ it("rejects nested cross-domain auth when the callback URL is untrusted", async const previousWindow = globalThis.window; const previousDocument = globalThis.document; - vi.spyOn(clientApp as any, "_getCurrentRefreshTokenIdIfSignedIn").mockResolvedValue(null); + vi.spyOn(clientApp as any, "_fetchCurrentRefreshTokenIdIfSignedIn").mockResolvedValue(null); vi.spyOn(clientApp as any, "_isTrusted").mockResolvedValue(false); globalThis.document = createMockDocument(); @@ -753,7 +755,7 @@ it("rejects nested cross-domain auth when the source session does not match", as const previousWindow = globalThis.window; const previousDocument = globalThis.document; const createCrossDomainAuthRedirectUrlSpy = vi.spyOn(clientApp as any, "_createCrossDomainAuthRedirectUrl"); - vi.spyOn(clientApp as any, "_getCurrentRefreshTokenIdIfSignedIn").mockResolvedValue("different-source-session"); + vi.spyOn(clientApp as any, "_fetchCurrentRefreshTokenIdIfSignedIn").mockResolvedValue("different-source-session"); globalThis.document = createMockDocument(); globalThis.window = { diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.ts b/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.ts index 57fb05809..4d10a7a34 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.ts @@ -283,6 +283,12 @@ describe("StackClientApp cross-domain auth", () => { const originalFetchNewAccessToken = Reflect.get(clientInterface, "fetchNewAccessToken"); const refreshedRawRefreshTokens: string[] = []; + // Cookie-store writes queue a background trusted-parent-domain lookup. Without this stub, that + // lookup fetches the (unreachable) baseUrl with retries while holding the global store lock, + // which starves any later test that needs the write lock (e.g. signOut). Not restored on + // purpose: queued tasks can still run after this test body finishes. + vi.spyOn(clientApp as any, "_getTrustedParentDomain").mockResolvedValue(null); + try { const getBrowserCookieTokenStore = Reflect.get(clientApp, "_getBrowserCookieTokenStore"); if (typeof getBrowserCookieTokenStore !== "function") {