@hexclave/js 1.0.18 → 1.0.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/dev-tool/dev-tool-styles.js +7 -14
  2. package/dist/dev-tool/dev-tool-styles.js.map +1 -1
  3. package/dist/esm/dev-tool/dev-tool-styles.js +7 -14
  4. package/dist/esm/dev-tool/dev-tool-styles.js.map +1 -1
  5. package/dist/esm/lib/hexclave-app/apps/implementations/admin-app-impl.d.ts.map +1 -1
  6. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js +34 -5
  7. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js.map +1 -1
  8. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.d.ts +1 -0
  9. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.d.ts.map +1 -1
  10. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.js +28 -7
  11. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.js.map +1 -1
  12. package/dist/esm/lib/hexclave-app/apps/implementations/common.js +1 -1
  13. package/dist/esm/lib/hexclave-app/apps/implementations/event-tracker.d.ts +1 -0
  14. package/dist/esm/lib/hexclave-app/apps/implementations/event-tracker.d.ts.map +1 -1
  15. package/dist/esm/lib/hexclave-app/apps/implementations/event-tracker.js +15 -1
  16. package/dist/esm/lib/hexclave-app/apps/implementations/event-tracker.js.map +1 -1
  17. package/dist/esm/lib/hexclave-app/apps/implementations/event-tracker.test.js +31 -0
  18. package/dist/esm/lib/hexclave-app/apps/implementations/event-tracker.test.js.map +1 -1
  19. package/dist/esm/lib/hexclave-app/apps/implementations/server-app-impl.js +2 -2
  20. package/dist/esm/lib/hexclave-app/apps/implementations/server-app-impl.js.map +1 -1
  21. package/dist/esm/lib/hexclave-app/apps/implementations/session-replay.d.ts +6 -4
  22. package/dist/esm/lib/hexclave-app/apps/implementations/session-replay.d.ts.map +1 -1
  23. package/dist/esm/lib/hexclave-app/apps/implementations/session-replay.js +21 -2
  24. package/dist/esm/lib/hexclave-app/apps/implementations/session-replay.js.map +1 -1
  25. package/dist/esm/lib/hexclave-app/apps/implementations/session-replay.test.js +64 -2
  26. package/dist/esm/lib/hexclave-app/apps/implementations/session-replay.test.js.map +1 -1
  27. package/dist/esm/lib/hexclave-app/apps/interfaces/client-app.d.ts +5 -4
  28. package/dist/esm/lib/hexclave-app/apps/interfaces/client-app.d.ts.map +1 -1
  29. package/dist/esm/lib/hexclave-app/apps/interfaces/client-app.js.map +1 -1
  30. package/dist/esm/lib/hexclave-app/project-configs/index.d.ts +7 -0
  31. package/dist/esm/lib/hexclave-app/project-configs/index.d.ts.map +1 -1
  32. package/dist/esm/lib/hexclave-app/projects/index.d.ts.map +1 -1
  33. package/dist/esm/lib/hexclave-app/projects/index.js +1 -1
  34. package/dist/esm/lib/hexclave-app/projects/index.js.map +1 -1
  35. package/dist/lib/hexclave-app/apps/implementations/admin-app-impl.d.ts.map +1 -1
  36. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js +34 -5
  37. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js.map +1 -1
  38. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.d.ts +1 -0
  39. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.d.ts.map +1 -1
  40. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.js +27 -6
  41. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.js.map +1 -1
  42. package/dist/lib/hexclave-app/apps/implementations/common.js +1 -1
  43. package/dist/lib/hexclave-app/apps/implementations/event-tracker.d.ts +1 -0
  44. package/dist/lib/hexclave-app/apps/implementations/event-tracker.d.ts.map +1 -1
  45. package/dist/lib/hexclave-app/apps/implementations/event-tracker.js +15 -1
  46. package/dist/lib/hexclave-app/apps/implementations/event-tracker.js.map +1 -1
  47. package/dist/lib/hexclave-app/apps/implementations/event-tracker.test.js +31 -0
  48. package/dist/lib/hexclave-app/apps/implementations/event-tracker.test.js.map +1 -1
  49. package/dist/lib/hexclave-app/apps/implementations/server-app-impl.js +2 -2
  50. package/dist/lib/hexclave-app/apps/implementations/server-app-impl.js.map +1 -1
  51. package/dist/lib/hexclave-app/apps/implementations/session-replay.d.ts +6 -4
  52. package/dist/lib/hexclave-app/apps/implementations/session-replay.d.ts.map +1 -1
  53. package/dist/lib/hexclave-app/apps/implementations/session-replay.js +21 -1
  54. package/dist/lib/hexclave-app/apps/implementations/session-replay.js.map +1 -1
  55. package/dist/lib/hexclave-app/apps/implementations/session-replay.test.js +62 -0
  56. package/dist/lib/hexclave-app/apps/implementations/session-replay.test.js.map +1 -1
  57. package/dist/lib/hexclave-app/apps/interfaces/client-app.d.ts +5 -4
  58. package/dist/lib/hexclave-app/apps/interfaces/client-app.d.ts.map +1 -1
  59. package/dist/lib/hexclave-app/apps/interfaces/client-app.js.map +1 -1
  60. package/dist/lib/hexclave-app/project-configs/index.d.ts +7 -0
  61. package/dist/lib/hexclave-app/project-configs/index.d.ts.map +1 -1
  62. package/dist/lib/hexclave-app/projects/index.d.ts.map +1 -1
  63. package/dist/lib/hexclave-app/projects/index.js +1 -1
  64. package/dist/lib/hexclave-app/projects/index.js.map +1 -1
  65. package/package.json +2 -2
  66. package/src/dev-tool/dev-tool-styles.ts +7 -14
  67. package/src/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.ts +36 -0
  68. package/src/lib/hexclave-app/apps/implementations/client-app-impl.ts +47 -7
  69. package/src/lib/hexclave-app/apps/implementations/event-tracker.test.ts +41 -0
  70. package/src/lib/hexclave-app/apps/implementations/event-tracker.ts +16 -0
  71. package/src/lib/hexclave-app/apps/implementations/server-app-impl.ts +2 -2
  72. package/src/lib/hexclave-app/apps/implementations/session-replay.test.ts +85 -2
  73. package/src/lib/hexclave-app/apps/implementations/session-replay.ts +24 -3
  74. package/src/lib/hexclave-app/apps/interfaces/client-app.ts +5 -4
  75. package/src/lib/hexclave-app/project-configs/index.ts +8 -0
  76. package/src/lib/hexclave-app/projects/index.ts +13 -11
@@ -65,7 +65,7 @@ import { EventTracker } from "./event-tracker";
65
65
  import type { CrossDomainHandoffParams } from "./redirect-page-urls";
66
66
  import { crossDomainAuthQueryParams, getCrossDomainHandoffParamsFromCurrentUrl, planRedirectToHandler } from "./redirect-page-urls";
67
67
  import { subscribeSessionRefresh } from "./session-refresh-subscription";
68
- import { AnalyticsOptions, SessionRecorder, analyticsOptionsFromJson, analyticsOptionsToJson } from "./session-replay";
68
+ import { AnalyticsOptions, SessionRecorder, analyticsOptionsFromJson, analyticsOptionsToJson, getSessionReplayOptions } from "./session-replay";
69
69
 
70
70
  import { mountClickmapOverlay } from "../../../../clickmap";
71
71
  import { mountDevTool } from "../../../../dev-tool";
@@ -84,6 +84,38 @@ const nestedCrossDomainAuthQueryParams = {
84
84
  afterCallbackRedirectUrl: "after_callback_redirect_url",
85
85
  } as const;
86
86
 
87
+ function getRedirectHelperInstruction(handlerName: string): string {
88
+ if (handlerName === "handler") {
89
+ return "Use a page-specific redirect helper such as app.redirectToSignIn() instead.";
90
+ }
91
+ const redirectMethodName = `redirectTo${handlerName.slice(0, 1).toUpperCase()}${handlerName.slice(1)}`;
92
+ return `Use app.${redirectMethodName}() instead.`;
93
+ }
94
+
95
+ function createUrlsForPublicAccess(options: {
96
+ urls: ResolvedHandlerUrls,
97
+ projectId: string,
98
+ }): Readonly<ResolvedHandlerUrls> {
99
+ const hostedUrlNames = new Set(
100
+ Object.entries(options.urls)
101
+ .filter(([, url]) => isHostedHandlerUrlForProject({ url, projectId: options.projectId }))
102
+ .map(([handlerName]) => handlerName),
103
+ );
104
+
105
+ return new Proxy(options.urls, {
106
+ get(target, property, receiver) {
107
+ if (typeof property === "string" && hostedUrlNames.has(property)) {
108
+ throw new Error(
109
+ `app.urls.${property} cannot be used when this app is configured to use hosted components. ` +
110
+ "`app.urls` is static and does not include the runtime redirect-back, cross-domain auth, or sign-out state required by hosted components. " +
111
+ getRedirectHelperInstruction(property),
112
+ );
113
+ }
114
+ return Reflect.get(target, property, receiver);
115
+ },
116
+ });
117
+ }
118
+
87
119
  const oauthCallbackResponseQueryParams = ["code", "state", "error", "error_description", "errorCode", "message", "details"] as const;
88
120
 
89
121
  const allClientApps = new Map<string, [checkString: string | undefined, app: StackClientApp<any, any>]>();
@@ -629,13 +661,14 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
629
661
 
630
662
  const analyticsEnabled = this._analyticsOptions?.enabled !== false;
631
663
 
632
- if (analyticsEnabled && isBrowserLike() && this._hasPersistentTokenStore() && this._analyticsOptions?.replays?.enabled === true) {
664
+ const sessionReplayOptions = getSessionReplayOptions(this._analyticsOptions);
665
+ if (analyticsEnabled && isBrowserLike() && this._hasPersistentTokenStore() && sessionReplayOptions.enabled) {
633
666
  this._sessionRecorder = new SessionRecorder({
634
667
  projectId: this.projectId,
635
668
  sendBatch: async (body, opts) => {
636
669
  return await this._interface.sendSessionReplayBatch(body, await getAnalyticsSession(), opts);
637
670
  },
638
- }, this._analyticsOptions.replays);
671
+ }, sessionReplayOptions);
639
672
  this._sessionRecorder.start();
640
673
  }
641
674
 
@@ -1629,7 +1662,7 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
1629
1662
  teamId: crud.id,
1630
1663
  email: options.email,
1631
1664
  session,
1632
- callbackUrl: options.callbackUrl ?? constructRedirectUrl(app.urls.teamInvitation, "callbackUrl"),
1665
+ callbackUrl: options.callbackUrl ?? constructRedirectUrl(app._getUrls().teamInvitation, "callbackUrl"),
1633
1666
  });
1634
1667
  await app._teamInvitationsCache.refresh([session, crud.id]);
1635
1668
  },
@@ -1681,7 +1714,7 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
1681
1714
  async sendVerificationEmail(options?: { callbackUrl?: string }) {
1682
1715
  await app._interface.sendCurrentUserContactChannelVerificationEmail(
1683
1716
  crud.id,
1684
- options?.callbackUrl || constructRedirectUrl(app.urls.emailVerification, "callbackUrl"),
1717
+ options?.callbackUrl || constructRedirectUrl(app._getUrls().emailVerification, "callbackUrl"),
1685
1718
  session
1686
1719
  );
1687
1720
  },
@@ -1958,7 +1991,7 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
1958
1991
  {
1959
1992
  provider,
1960
1993
  redirectUrl: app._getOAuthCallbackRedirectUri(),
1961
- errorRedirectUrl: app.urls.error,
1994
+ errorRedirectUrl: app._getUrls().error,
1962
1995
  providerScope: mergeScopeStrings(scopeString, (app._oauthScopesOnSignIn[provider as ProviderType] ?? []).join(" ")),
1963
1996
  },
1964
1997
  session,
@@ -2045,7 +2078,7 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
2045
2078
  }
2046
2079
  return await app._interface.sendVerificationEmail(
2047
2080
  crud.primary_email,
2048
- options?.callbackUrl ?? constructRedirectUrl(app.urls.emailVerification, "callbackUrl"),
2081
+ options?.callbackUrl ?? constructRedirectUrl(app._getUrls().emailVerification, "callbackUrl"),
2049
2082
  session
2050
2083
  );
2051
2084
  },
@@ -2436,6 +2469,13 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
2436
2469
  }
2437
2470
 
2438
2471
  get urls(): Readonly<ResolvedHandlerUrls> {
2472
+ return createUrlsForPublicAccess({
2473
+ urls: this._getUrls(),
2474
+ projectId: this.projectId,
2475
+ });
2476
+ }
2477
+
2478
+ protected _getUrls(): Readonly<ResolvedHandlerUrls> {
2439
2479
  return getUrls(this._urlOptions, { projectId: this.projectId });
2440
2480
  }
2441
2481
 
@@ -389,4 +389,45 @@ describe("EventTracker", () => {
389
389
  tracker.stop();
390
390
  }
391
391
  });
392
+
393
+ it("silently disables when server responds with ANALYTICS_NOT_ENABLED", async () => {
394
+ vi.useFakeTimers();
395
+ document.body.innerHTML = "<button>Click me</button>";
396
+
397
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
398
+ const sentBodies: string[] = [];
399
+ const tracker = new EventTracker({
400
+ projectId: "internal",
401
+ sendBatch: async (body) => {
402
+ sentBodies.push(body);
403
+ return Result.ok(new Response(
404
+ JSON.stringify({ code: "ANALYTICS_NOT_ENABLED", error: "Analytics is not enabled for this project." }),
405
+ {
406
+ status: 400,
407
+ headers: { "x-stack-known-error": "ANALYTICS_NOT_ENABLED" },
408
+ },
409
+ ));
410
+ },
411
+ });
412
+
413
+ try {
414
+ tracker.start();
415
+
416
+ // First flush sends the initial page-view event; server rejects it.
417
+ await advancePastFlush();
418
+ expect(sentBodies).toHaveLength(1);
419
+
420
+ // No console.warn should have been emitted.
421
+ expect(warnSpy).not.toHaveBeenCalled();
422
+
423
+ // After disabling, new events should not accumulate or trigger further
424
+ // flushes.
425
+ document.querySelector("button")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
426
+ await advancePastFlush();
427
+ expect(sentBodies).toHaveLength(1);
428
+ } finally {
429
+ tracker.stop();
430
+ warnSpy.mockRestore();
431
+ }
432
+ });
392
433
  });
@@ -112,6 +112,7 @@ type TrackedEvent = {
112
112
  export class EventTracker {
113
113
  private _started = false;
114
114
  private _cancelled = false;
115
+ private _disabled = false;
115
116
  private _detachListeners: (() => void) | null = null;
116
117
  private _flushTimer: ReturnType<typeof setInterval> | null = null;
117
118
  private _events: TrackedEvent[] = [];
@@ -177,6 +178,7 @@ export class EventTracker {
177
178
  }
178
179
 
179
180
  private _pushEvent(event: TrackedEvent) {
181
+ if (this._disabled) return;
180
182
  this._events.push(event);
181
183
  this._approxBytes += JSON.stringify(event).length;
182
184
  if (this._events.length >= MAX_EVENTS_PER_BATCH || this._approxBytes >= MAX_APPROX_BYTES_PER_BATCH) {
@@ -468,6 +470,8 @@ export class EventTracker {
468
470
  }
469
471
 
470
472
  private async _flush(options: { keepalive: boolean }) {
473
+ if (this._disabled) return;
474
+
471
475
  // A keepalive flush means the page is unloading — a click still awaiting
472
476
  // dead-click classification led to that unload, so it is alive by
473
477
  // definition and ships unmarked.
@@ -504,6 +508,18 @@ export class EventTracker {
504
508
  }
505
509
 
506
510
  if (!res.data.ok) {
511
+ // If the server tells us analytics is not enabled for this project,
512
+ // silently disable the tracker — no point retrying or warning the user.
513
+ const knownError = res.data.headers.get("x-hexclave-known-error") ?? res.data.headers.get("x-stack-known-error");
514
+ if (knownError === "ANALYTICS_NOT_ENABLED") {
515
+ this._disabled = true;
516
+ if (this._flushTimer !== null) {
517
+ clearInterval(this._flushTimer);
518
+ this._flushTimer = null;
519
+ }
520
+ this._teardown();
521
+ return;
522
+ }
507
523
  console.warn("EventTracker flush failed:", res.data.status, await res.data.text());
508
524
  }
509
525
  }
@@ -356,7 +356,7 @@ export class _HexclaveServerAppImplIncomplete<HasTokenStore extends boolean, Pro
356
356
  isPrimary: crud.is_primary,
357
357
  usedForAuth: crud.used_for_auth,
358
358
  async sendVerificationEmail(options?: { callbackUrl?: string }) {
359
- await app._interface.sendServerContactChannelVerificationEmail(userId, crud.id, options?.callbackUrl ?? constructRedirectUrl(app.urls.emailVerification, "callbackUrl"));
359
+ await app._interface.sendServerContactChannelVerificationEmail(userId, crud.id, options?.callbackUrl ?? constructRedirectUrl(app._getUrls().emailVerification, "callbackUrl"));
360
360
  },
361
361
  async update(data: ServerContactChannelUpdateOptions) {
362
362
  await app._interface.updateServerContactChannel(userId, crud.id, serverContactChannelUpdateOptionsToCrud(data));
@@ -922,7 +922,7 @@ export class _HexclaveServerAppImplIncomplete<HasTokenStore extends boolean, Pro
922
922
  await app._interface.sendServerTeamInvitation({
923
923
  teamId: crud.id,
924
924
  email: options.email,
925
- callbackUrl: options.callbackUrl ?? constructRedirectUrl(app.urls.teamInvitation, "callbackUrl"),
925
+ callbackUrl: options.callbackUrl ?? constructRedirectUrl(app._getUrls().teamInvitation, "callbackUrl"),
926
926
  });
927
927
  await app._serverTeamInvitationsCache.refresh([crud.id]);
928
928
  },
@@ -2,8 +2,23 @@
2
2
  //===========================================
3
3
  // THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template
4
4
  //===========================================
5
- import { describe, expect, it } from "vitest";
6
- import { analyticsOptionsFromJson, analyticsOptionsToJson } from "./session-replay";
5
+ // @vitest-environment jsdom
6
+
7
+ import { describe, expect, it, vi } from "vitest";
8
+ import { Result } from "@hexclave/shared/dist/utils/results";
9
+ import { analyticsOptionsFromJson, analyticsOptionsToJson, getSessionReplayOptions, SessionRecorder } from "./session-replay";
10
+
11
+ describe("session replay options", () => {
12
+ it("enables replays by default", () => {
13
+ expect(getSessionReplayOptions(undefined).enabled).toBe(true);
14
+ expect(getSessionReplayOptions({}).enabled).toBe(true);
15
+ expect(getSessionReplayOptions({ replays: {} }).enabled).toBe(true);
16
+ });
17
+
18
+ it("preserves explicit replay opt-out", () => {
19
+ expect(getSessionReplayOptions({ replays: { enabled: false } }).enabled).toBe(false);
20
+ });
21
+ });
7
22
 
8
23
  describe("analytics option JSON conversion", () => {
9
24
  it("preserves top-level analytics options when serializing replay block classes", () => {
@@ -31,3 +46,71 @@ describe("analytics option JSON conversion", () => {
31
46
  expect(roundTripped?.replays?.blockClass).toEqual(/stack-sensitive/u);
32
47
  });
33
48
  });
49
+
50
+ describe("SessionRecorder flush", () => {
51
+ it("silently disables when server responds with ANALYTICS_NOT_ENABLED", async () => {
52
+ vi.useFakeTimers();
53
+
54
+ // Seed localStorage with a valid session so _flush doesn't fail on getOrRotateSession
55
+ const storageKey = `hexclave:session-replay:v1:test-project`;
56
+ localStorage.setItem(storageKey, JSON.stringify({
57
+ session_id: "test-session",
58
+ created_at_ms: Date.now(),
59
+ last_activity_ms: Date.now(),
60
+ }));
61
+
62
+ const sentBodies: string[] = [];
63
+ const recorder = new SessionRecorder(
64
+ {
65
+ projectId: "test-project",
66
+ sendBatch: async (body) => {
67
+ sentBodies.push(body);
68
+ return Result.ok(new Response(
69
+ JSON.stringify({ code: "ANALYTICS_NOT_ENABLED", error: "Analytics is not enabled for this project." }),
70
+ {
71
+ status: 400,
72
+ headers: { "x-stack-known-error": "ANALYTICS_NOT_ENABLED" },
73
+ },
74
+ ));
75
+ },
76
+ },
77
+ {},
78
+ );
79
+
80
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
81
+
82
+ try {
83
+ // Inject an event directly into the recorder's buffer to test flush behavior
84
+ // without needing rrweb. We access private fields for testing purposes.
85
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
86
+ (recorder as any)._events = [{ type: 2, timestamp: Date.now(), data: {} }];
87
+
88
+ // Manually trigger a tick (which calls _flush)
89
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
90
+ (recorder as any)._tick();
91
+ await vi.advanceTimersByTimeAsync(0);
92
+
93
+ // One batch should have been sent
94
+ expect(sentBodies).toHaveLength(1);
95
+
96
+ // No console.warn about "SessionRecorder flush failed" should have been emitted
97
+ const flushWarnings = warnSpy.mock.calls.filter(
98
+ (args) => typeof args[0] === "string" && args[0].includes("SessionRecorder")
99
+ );
100
+ expect(flushWarnings).toHaveLength(0);
101
+
102
+ // After disabling, pushing new events and triggering another tick should not send
103
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
104
+ (recorder as any)._events = [{ type: 3, timestamp: Date.now(), data: {} }];
105
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
106
+ (recorder as any)._tick();
107
+ await vi.advanceTimersByTimeAsync(0);
108
+ expect(sentBodies).toHaveLength(1);
109
+ } finally {
110
+ recorder.stop();
111
+ warnSpy.mockRestore();
112
+ localStorage.removeItem(storageKey);
113
+ vi.useRealTimers();
114
+ }
115
+ });
116
+ });
@@ -11,7 +11,7 @@ export type AnalyticsReplayOptions = {
11
11
  /**
12
12
  * Whether session replays are enabled.
13
13
  *
14
- * @default false
14
+ * @default true
15
15
  */
16
16
  enabled?: boolean,
17
17
  /**
@@ -44,12 +44,19 @@ export type AnalyticsOptions = {
44
44
  */
45
45
  enabled?: boolean,
46
46
  /**
47
- * Options for session replay recording. Replays are disabled by default;
48
- * set `enabled: true` to opt in.
47
+ * Options for session replay recording. Replays are enabled by default;
48
+ * set `enabled: false` to opt out.
49
49
  */
50
50
  replays?: AnalyticsReplayOptions,
51
51
  };
52
52
 
53
+ export function getSessionReplayOptions(analyticsOptions: AnalyticsOptions | undefined): AnalyticsReplayOptions {
54
+ return {
55
+ ...analyticsOptions?.replays,
56
+ enabled: analyticsOptions?.replays?.enabled ?? true,
57
+ };
58
+ }
59
+
53
60
  /**
54
61
  * Converts AnalyticsOptions to a JSON-safe representation.
55
62
  * RegExp blockClass values are serialized as `{ __regexp, __flags }` objects.
@@ -161,6 +168,7 @@ export type SessionRecorderDeps = {
161
168
  export class SessionRecorder {
162
169
  private _started = false;
163
170
  private _cancelled = false;
171
+ private _disabled = false;
164
172
  private _stopRecording: (() => void) | null = null;
165
173
  private _detachListeners: (() => void) | null = null;
166
174
  private _flushTimer: ReturnType<typeof setInterval> | null = null;
@@ -228,6 +236,7 @@ export class SessionRecorder {
228
236
  }
229
237
 
230
238
  private async _flush(options: { keepalive: boolean }) {
239
+ if (this._disabled) return;
231
240
  if (this._events.length === 0) return;
232
241
  // Prevent concurrent in-flight HTTP requests. When a flush is already
233
242
  // in-flight, a second batch could race on the server (both call
@@ -265,6 +274,18 @@ export class SessionRecorder {
265
274
  }
266
275
 
267
276
  if (!res.data.ok) {
277
+ // If the server tells us analytics is not enabled for this project,
278
+ // silently disable the recorder — no point retrying or warning the user.
279
+ const knownError = res.data.headers.get("x-hexclave-known-error") ?? res.data.headers.get("x-stack-known-error");
280
+ if (knownError === "ANALYTICS_NOT_ENABLED") {
281
+ this._disabled = true;
282
+ if (this._flushTimer !== null) {
283
+ clearInterval(this._flushTimer);
284
+ this._flushTimer = null;
285
+ }
286
+ this._stopCurrentRecording();
287
+ return;
288
+ }
268
289
  captureWarning("SessionRecorder.flush", new Error(`SessionRecorder flush failed: ${res.data.status} ${await res.data.text()}`));
269
290
  }
270
291
  } finally {
@@ -40,8 +40,8 @@ export type StackClientAppConstructorOptions<HasTokenStore extends boolean, Proj
40
40
  noAutomaticPrefetch?: boolean,
41
41
 
42
42
  /**
43
- * Options for analytics and session recording. Replays are disabled by default;
44
- * set `{ replays: { enabled: true } }` to opt in.
43
+ * Options for analytics and session recording. Replays are enabled by default;
44
+ * set `{ replays: { enabled: false } }` to opt out.
45
45
  */
46
46
  analytics?: AnalyticsOptions,
47
47
  } & (
@@ -68,8 +68,9 @@ export type StackClientApp<HasTokenStore extends boolean = boolean, ProjectId ex
68
68
  readonly version: string,
69
69
 
70
70
  /**
71
- * @deprecated `app.urls` is static and does not include runtime redirect-back parameters.
72
- * For navigation, prefer `redirectToXyz()` methods (for example `redirectToSignIn()`).
71
+ * @deprecated Do not use `app.urls` for navigation. It is static and does not include runtime redirect-back,
72
+ * cross-domain auth, or sign-out state. Use the matching `redirectToXyz()` method instead, for example
73
+ * `redirectToSignIn()`, `redirectToSignUp()`, `redirectToSignOut()`, or `redirectToAccountSettings()`.
73
74
  */
74
75
  readonly urls: Readonly<ResolvedHandlerUrls>,
75
76
 
@@ -76,6 +76,14 @@ export type AdminOAuthProviderConfig = {
76
76
  microsoftTenantId?: string,
77
77
  appleBundleIds?: string[],
78
78
  }
79
+ | {
80
+ type: 'custom_oidc',
81
+ clientId: string,
82
+ clientSecret: string,
83
+ issuerUrl: string,
84
+ scope?: string,
85
+ displayName?: string,
86
+ }
79
87
  ) & OAuthProviderConfig;
80
88
 
81
89
  export type AdminProjectConfigUpdateOptions = {
@@ -176,17 +176,19 @@ export function adminProjectUpdateOptionsToCrud(options: AdminProjectUpdateOptio
176
176
  domain: d.domain,
177
177
  handler_path: d.handlerPath
178
178
  })),
179
- oauth_providers: options.config?.oauthProviders?.map((p) => ({
180
- id: p.id as any,
181
- type: p.type,
182
- ...(p.type === 'standard' && {
183
- client_id: p.clientId,
184
- client_secret: p.clientSecret,
185
- facebook_config_id: p.facebookConfigId,
186
- microsoft_tenant_id: p.microsoftTenantId,
187
- apple_bundle_ids: p.appleBundleIds,
188
- }),
189
- })),
179
+ oauth_providers: options.config?.oauthProviders
180
+ ?.filter((p): p is Exclude<typeof p, { type: 'custom_oidc' }> => p.type !== 'custom_oidc')
181
+ .map((p) => ({
182
+ id: p.id as any,
183
+ type: p.type,
184
+ ...(p.type === 'standard' && {
185
+ client_id: p.clientId,
186
+ client_secret: p.clientSecret,
187
+ facebook_config_id: p.facebookConfigId,
188
+ microsoft_tenant_id: p.microsoftTenantId,
189
+ apple_bundle_ids: p.appleBundleIds,
190
+ }),
191
+ })),
190
192
  email_config: options.config?.emailConfig && (
191
193
  options.config.emailConfig.type === 'shared' ? {
192
194
  type: 'shared',