@hexclave/js 1.0.125 → 1.0.127

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 (99) hide show
  1. package/dist/{admin-app-impl-D9hrl67H.d.ts → admin-app-impl-8QLvfa8y.d.ts} +29 -13
  2. package/dist/admin-app-impl-8QLvfa8y.d.ts.map +1 -0
  3. package/dist/clickmap/clickmap-core.d.ts +1 -1
  4. package/dist/clickmap/index.d.ts +1 -1
  5. package/dist/{client-app-DLFxr4f7.d.ts → client-app-C7Tnf0n1.d.ts} +34 -6
  6. package/dist/client-app-C7Tnf0n1.d.ts.map +1 -0
  7. package/dist/{common-BDyEKSM6.d.ts → common-Dii3iHJR.d.ts} +15 -3
  8. package/dist/common-Dii3iHJR.d.ts.map +1 -0
  9. package/dist/{common-CRu2FsO9.d.ts → common-GgG4l8fG.d.ts} +2 -2
  10. package/dist/{common-CRu2FsO9.d.ts.map → common-GgG4l8fG.d.ts.map} +1 -1
  11. package/dist/dev-tool/dev-tool-core.d.ts +1 -1
  12. package/dist/dev-tool/index.d.ts +1 -1
  13. package/dist/esm/lib/hexclave-app/apps/implementations/admin-app-impl.d.ts +4 -4
  14. package/dist/esm/lib/hexclave-app/apps/implementations/admin-app-impl.d.ts.map +1 -1
  15. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.d.ts +18 -2
  16. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.d.ts.map +1 -1
  17. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.external-token-store.test.d.ts +1 -0
  18. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.external-token-store.test.js +382 -0
  19. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.external-token-store.test.js.map +1 -0
  20. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.init-analytics.test.d.ts +1 -0
  21. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.init-analytics.test.js +66 -0
  22. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.init-analytics.test.js.map +1 -0
  23. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.js +66 -14
  24. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.js.map +1 -1
  25. package/dist/esm/lib/hexclave-app/apps/implementations/common.js +2 -2
  26. package/dist/esm/lib/hexclave-app/apps/implementations/external-token-store-session-adapter.d.ts +31 -0
  27. package/dist/esm/lib/hexclave-app/apps/implementations/external-token-store-session-adapter.d.ts.map +1 -0
  28. package/dist/esm/lib/hexclave-app/apps/implementations/external-token-store-session-adapter.js +115 -0
  29. package/dist/esm/lib/hexclave-app/apps/implementations/external-token-store-session-adapter.js.map +1 -0
  30. package/dist/esm/lib/hexclave-app/apps/interfaces/client-app.d.ts +16 -1
  31. package/dist/esm/lib/hexclave-app/apps/interfaces/client-app.d.ts.map +1 -1
  32. package/dist/esm/lib/hexclave-app/apps/interfaces/client-app.js.map +1 -1
  33. package/dist/esm/lib/hexclave-app/common.d.ts +14 -2
  34. package/dist/esm/lib/hexclave-app/common.d.ts.map +1 -1
  35. package/dist/esm/lib/hexclave-app/common.js +17 -1
  36. package/dist/esm/lib/hexclave-app/common.js.map +1 -1
  37. package/dist/esm/lib/hexclave-app/index.d.ts +2 -2
  38. package/dist/esm/lib/hexclave-app/index.js +2 -2
  39. package/dist/esm/lib/hexclave-app/users/index.d.ts +14 -1
  40. package/dist/esm/lib/hexclave-app/users/index.d.ts.map +1 -1
  41. package/dist/esm/lib/hexclave-app/users/index.js.map +1 -1
  42. package/dist/{index-ZiEXZJBF.d.ts → index-BTa6Yu7N.d.ts} +2 -2
  43. package/dist/{index-ZiEXZJBF.d.ts.map → index-BTa6Yu7N.d.ts.map} +1 -1
  44. package/dist/{index-DLPccxVU.d.ts → index-DlOcbI9W.d.ts} +2 -2
  45. package/dist/{index-DLPccxVU.d.ts.map → index-DlOcbI9W.d.ts.map} +1 -1
  46. package/dist/index.d.ts +4 -4
  47. package/dist/lib/hexclave-app/apps/implementations/admin-app-impl.d.ts +1 -1
  48. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.d.ts +1 -1
  49. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.external-token-store.test.d.ts +1 -0
  50. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.external-token-store.test.js +381 -0
  51. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.external-token-store.test.js.map +1 -0
  52. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.init-analytics.test.d.ts +1 -0
  53. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.init-analytics.test.js +65 -0
  54. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.init-analytics.test.js.map +1 -0
  55. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.js +65 -13
  56. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.js.map +1 -1
  57. package/dist/lib/hexclave-app/apps/implementations/common.d.ts +1 -1
  58. package/dist/lib/hexclave-app/apps/implementations/common.js +2 -2
  59. package/dist/lib/hexclave-app/apps/implementations/external-token-store-session-adapter.d.ts +31 -0
  60. package/dist/lib/hexclave-app/apps/implementations/external-token-store-session-adapter.d.ts.map +1 -0
  61. package/dist/lib/hexclave-app/apps/implementations/external-token-store-session-adapter.js +117 -0
  62. package/dist/lib/hexclave-app/apps/implementations/external-token-store-session-adapter.js.map +1 -0
  63. package/dist/lib/hexclave-app/apps/implementations/index.d.ts +1 -1
  64. package/dist/lib/hexclave-app/apps/implementations/redirect-page-urls.d.ts +1 -1
  65. package/dist/lib/hexclave-app/apps/implementations/server-app-impl.d.ts +1 -1
  66. package/dist/lib/hexclave-app/apps/index.d.ts +1 -1
  67. package/dist/lib/hexclave-app/apps/interfaces/admin-app.d.ts +1 -1
  68. package/dist/lib/hexclave-app/apps/interfaces/client-app.d.ts +1 -1
  69. package/dist/lib/hexclave-app/apps/interfaces/client-app.js.map +1 -1
  70. package/dist/lib/hexclave-app/apps/interfaces/server-app.d.ts +1 -1
  71. package/dist/lib/hexclave-app/common.d.ts +2 -2
  72. package/dist/lib/hexclave-app/common.js +19 -0
  73. package/dist/lib/hexclave-app/common.js.map +1 -1
  74. package/dist/lib/hexclave-app/customers/index.d.ts +1 -1
  75. package/dist/lib/hexclave-app/data-vault/index.d.ts +1 -1
  76. package/dist/lib/hexclave-app/index.d.ts +4 -4
  77. package/dist/lib/hexclave-app/index.js +18 -0
  78. package/dist/lib/hexclave-app/projects/index.d.ts +1 -1
  79. package/dist/lib/hexclave-app/teams/index.d.ts +1 -1
  80. package/dist/lib/hexclave-app/url-targets.d.ts +1 -1
  81. package/dist/lib/hexclave-app/users/index.d.ts +1 -1
  82. package/dist/lib/hexclave-app/users/index.js.map +1 -1
  83. package/dist/pushed-config-error-overlay/index.d.ts +1 -1
  84. package/dist/{redirect-page-urls-C2CXwCcv.d.ts → redirect-page-urls-DCSFN6dg.d.ts} +2 -2
  85. package/dist/{redirect-page-urls-C2CXwCcv.d.ts.map → redirect-page-urls-DCSFN6dg.d.ts.map} +1 -1
  86. package/dist/{url-targets-BJgkwPGW.d.ts → url-targets-DYLcHumF.d.ts} +2 -2
  87. package/dist/{url-targets-BJgkwPGW.d.ts.map → url-targets-DYLcHumF.d.ts.map} +1 -1
  88. package/package.json +2 -2
  89. package/src/lib/hexclave-app/apps/implementations/client-app-impl.external-token-store.test.ts +425 -0
  90. package/src/lib/hexclave-app/apps/implementations/client-app-impl.init-analytics.test.ts +79 -0
  91. package/src/lib/hexclave-app/apps/implementations/client-app-impl.ts +124 -13
  92. package/src/lib/hexclave-app/apps/implementations/external-token-store-session-adapter.ts +190 -0
  93. package/src/lib/hexclave-app/apps/interfaces/client-app.ts +17 -0
  94. package/src/lib/hexclave-app/common.ts +33 -0
  95. package/src/lib/hexclave-app/index.ts +4 -0
  96. package/src/lib/hexclave-app/users/index.ts +13 -1
  97. package/dist/admin-app-impl-D9hrl67H.d.ts.map +0 -1
  98. package/dist/client-app-DLFxr4f7.d.ts.map +0 -1
  99. package/dist/common-BDyEKSM6.d.ts.map +0 -1
@@ -0,0 +1,79 @@
1
+
2
+ //===========================================
3
+ // THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template
4
+ //===========================================
5
+ import { KnownErrors } from "@hexclave/shared";
6
+ import { Result } from "@hexclave/shared/dist/utils/results";
7
+ import { afterEach, describe, expect, it, vi } from "vitest";
8
+ import { HexclaveClientApp, HexclaveClientAppConstructorOptions } from "../interfaces/client-app";
9
+
10
+ const baseOptions = {
11
+ baseUrl: "http://localhost:12345",
12
+ projectId: "00000000-0000-4000-8000-000000000000",
13
+ publishableClientKey: "stack-pk-test",
14
+ tokenStore: "memory",
15
+ redirectMethod: "none",
16
+ noAutomaticPrefetch: true,
17
+ } satisfies HexclaveClientAppConstructorOptions<true, string>;
18
+
19
+ const analyticsAppDisabledMessage = "initAnalytics() was called, but the Analytics app is not enabled on this Hexclave project";
20
+
21
+ // The backend's AnalyticsNotEnabled rejection only reaches the client through the event tracker's and session
22
+ // recorder's upload callbacks, which only exist in a browser; this simulates such a rejection arriving.
23
+ function simulateAnalyticsUploadResult(app: HexclaveClientApp, result: Result<Response, Error>) {
24
+ const handler: unknown = Reflect.get(app, "_handleAnalyticsUploadResult");
25
+ if (typeof handler !== "function") {
26
+ throw new Error("Expected HexclaveClientApp to have a _handleAnalyticsUploadResult method");
27
+ }
28
+ handler.call(app, result);
29
+ }
30
+
31
+ describe("HexclaveClientApp.initAnalytics", () => {
32
+ afterEach(() => {
33
+ vi.restoreAllMocks();
34
+ });
35
+
36
+ it("does not throw when analytics are configured", () => {
37
+ const app = new HexclaveClientApp(baseOptions);
38
+ expect(() => app.initAnalytics()).not.toThrow();
39
+ });
40
+
41
+ it("throws when analytics are disabled with the analytics option", () => {
42
+ const app = new HexclaveClientApp({ ...baseOptions, analytics: { enabled: false } });
43
+ expect(() => app.initAnalytics()).toThrow("`analytics.enabled` constructor option is set to false");
44
+ });
45
+
46
+ it("does not throw when only session replays are disabled", () => {
47
+ const app = new HexclaveClientApp({ ...baseOptions, analytics: { replays: { enabled: false } } });
48
+ expect(() => app.initAnalytics()).not.toThrow();
49
+ });
50
+
51
+ it("reports an error when the backend rejects uploads after initAnalytics() was called", () => {
52
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
53
+ const app = new HexclaveClientApp(baseOptions);
54
+ app.initAnalytics();
55
+
56
+ simulateAnalyticsUploadResult(app, Result.error(new KnownErrors.AnalyticsNotEnabled()));
57
+ // Repeated rejections must not report the error again.
58
+ simulateAnalyticsUploadResult(app, Result.error(new KnownErrors.AnalyticsNotEnabled()));
59
+
60
+ const reports = consoleError.mock.calls.filter((args) => args.some((arg) => String(arg).includes(analyticsAppDisabledMessage)));
61
+ expect(reports).toHaveLength(1);
62
+ });
63
+
64
+ it("throws from initAnalytics() when the backend already rejected uploads before it was called", () => {
65
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
66
+ const app = new HexclaveClientApp(baseOptions);
67
+
68
+ simulateAnalyticsUploadResult(app, Result.error(new KnownErrors.AnalyticsNotEnabled()));
69
+ expect(consoleError.mock.calls.some((args) => args.some((arg) => String(arg).includes(analyticsAppDisabledMessage)))).toBe(false);
70
+
71
+ expect(() => app.initAnalytics()).toThrow(analyticsAppDisabledMessage);
72
+ });
73
+
74
+ it("ignores upload failures unrelated to the Analytics app being disabled", () => {
75
+ const app = new HexclaveClientApp(baseOptions);
76
+ simulateAnalyticsUploadResult(app, Result.error(new Error("network error")));
77
+ expect(() => app.initAnalytics()).not.toThrow();
78
+ });
79
+ });
@@ -64,12 +64,13 @@ import { StackClientApp, StackClientAppConstructorOptions, StackClientAppJson }
64
64
  import { _HexclaveAdminAppImplIncomplete } from "./admin-app-impl";
65
65
  import { TokenObject, clientVersion, createCache, createCacheBySession, createEmptyTokenStore, getAnalyticsBaseUrl, getDefaultExtraRequestHeaders, getDefaultProjectId, getDefaultPublishableClientKey, getUrls, resolveApiUrls, resolveConstructorOptions } from "./common";
66
66
  import { EventTracker } from "./event-tracker";
67
+ import { ExternalTokenStoreSessionAdapter, isExternalTokenStore } from "./external-token-store-session-adapter";
67
68
  import { augmentUrlWithPersistedRedirectBackState, getRawAfterAuthReturnTo, saveRedirectBackStateFromUrl } from "./redirect-back-state";
68
69
  import { recordRedirectAndThrowIfLoopDetected } from "./redirect-loop-breaker";
69
70
  import type { CrossDomainHandoffParams } from "./redirect-page-urls";
70
71
  import { crossDomainAuthQueryParams, getCrossDomainHandoffParamsFromCurrentUrl, planRedirectToHandler } from "./redirect-page-urls";
71
72
  import { subscribeSessionRefresh } from "./session-refresh-subscription";
72
- import { AnalyticsOptions, SessionRecorder, analyticsOptionsFromJson, analyticsOptionsToJson, getSessionReplayOptions } from "./session-replay";
73
+ import { AnalyticsOptions, SessionRecorder, analyticsOptionsFromJson, analyticsOptionsToJson, getSessionReplayOptions, isAnalyticsNotEnabledError } from "./session-replay";
73
74
 
74
75
  export function stripBrowserActionQueryParam() {
75
76
  let stripAttemptsRemaining = 20;
@@ -202,6 +203,10 @@ function throwSetupError(error: HexclaveSetupError): never {
202
203
  throw error;
203
204
  }
204
205
 
206
+ function createAnalyticsAppDisabledOnProjectError(): Error {
207
+ return new Error("initAnalytics() was called, but the Analytics app is not enabled on this Hexclave project, so the backend rejects all analytics uploads. Enable the Analytics app in the Hexclave dashboard (or in hexclave.config.ts), or remove the initAnalytics() call if this is intentional.");
208
+ }
209
+
205
210
  function getRedirectHelperInstruction(handlerName: string): string {
206
211
  if (handlerName === "handler") {
207
212
  return "Use a page-specific redirect helper such as app.redirectToSignIn() instead.";
@@ -360,7 +365,10 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
360
365
  private readonly _analyticsOptions: AnalyticsOptions | undefined;
361
366
  private _sessionRecorder: SessionRecorder | null = null;
362
367
  private _eventTracker: EventTracker | null = null;
368
+ private _initAnalyticsCalled = false;
369
+ private _analyticsAppDisabledOnProject = false;
363
370
  private _pendingSignOut: Promise<void> | null = null;
371
+ private readonly _externalTokenStoreSessionAdapter: ExternalTokenStoreSessionAdapter;
364
372
 
365
373
  private __DEMO_ENABLE_SLIGHT_FETCH_DELAY = false;
366
374
  private readonly _ownedAdminApps = new DependenciesMap<[InternalSession, string], _HexclaveAdminAppImplIncomplete<false, string>>();
@@ -765,6 +773,13 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
765
773
  }
766
774
  });
767
775
  }
776
+ this._externalTokenStoreSessionAdapter = new ExternalTokenStoreSessionAdapter(
777
+ (sessionOptions) => this._interface.createSession(sessionOptions),
778
+ async (providerId, token) => {
779
+ const exchange = await this._interface.exchangeExternalAuthToken(providerId, token);
780
+ return Result.orThrow(exchange).accessToken;
781
+ },
782
+ );
768
783
 
769
784
  this._tokenStoreInit = resolvedOptions.tokenStore;
770
785
  this._redirectMethod = resolvedOptions.redirectMethod || (isBrowserLike() ? "window" : "none");
@@ -806,24 +821,28 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
806
821
  return anonUser._internalSession;
807
822
  };
808
823
 
809
- const analyticsEnabled = this._analyticsOptions?.enabled !== false;
824
+ const recordsAnalytics = this._getAnalyticsDisabledReason() == null && isBrowserLike();
810
825
 
811
826
  const sessionReplayOptions = getSessionReplayOptions(this._analyticsOptions);
812
- if (analyticsEnabled && isBrowserLike() && this._hasPersistentTokenStore() && sessionReplayOptions.enabled) {
827
+ if (recordsAnalytics && sessionReplayOptions.enabled) {
813
828
  this._sessionRecorder = new SessionRecorder({
814
829
  projectId: this.projectId,
815
830
  sendBatch: async (body, opts) => {
816
- return await this._interface.sendSessionReplayBatch(body, await getAnalyticsSession(), opts);
831
+ return this._handleAnalyticsUploadResult(
832
+ await this._interface.sendSessionReplayBatch(body, await getAnalyticsSession(), opts),
833
+ );
817
834
  },
818
835
  }, sessionReplayOptions);
819
836
  this._sessionRecorder.start();
820
837
  }
821
838
 
822
- if (analyticsEnabled && isBrowserLike() && this._hasPersistentTokenStore()) {
839
+ if (recordsAnalytics) {
823
840
  this._eventTracker = new EventTracker({
824
841
  projectId: this.projectId,
825
842
  sendBatch: async (body, opts) => {
826
- return await this._interface.sendAnalyticsEventBatch(body, await getAnalyticsSession(), opts);
843
+ return this._handleAnalyticsUploadResult(
844
+ await this._interface.sendAnalyticsEventBatch(body, await getAnalyticsSession(), opts),
845
+ );
827
846
  },
828
847
  });
829
848
  this._eventTracker.start();
@@ -1684,6 +1703,13 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
1684
1703
  default: {
1685
1704
  if (tokenStoreInit === null) {
1686
1705
  return createEmptyTokenStore();
1706
+ } else if (typeof tokenStoreInit === "object" && isExternalTokenStore(tokenStoreInit)) {
1707
+ return this._externalTokenStoreSessionAdapter.getOrCreateTokenStore(tokenStoreInit, (store, previousSessionKey) => {
1708
+ this._sessionsByTokenStoreAndSessionKey
1709
+ .get(store)
1710
+ ?.get(previousSessionKey)
1711
+ ?.suggestAccessTokenExpired();
1712
+ });
1687
1713
  } else if (typeof tokenStoreInit === "object" && "headers" in tokenStoreInit) {
1688
1714
  if (this._requestTokenStores.has(tokenStoreInit)) return this._requestTokenStores.get(tokenStoreInit)!;
1689
1715
 
@@ -1746,28 +1772,29 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
1746
1772
  * - So different token stores are separated and don't leak information between each other, eg. if the same user sends two requests to the same server they should get a different session object
1747
1773
  */
1748
1774
  private _sessionsByTokenStoreAndSessionKey = new WeakMap<Store<TokenObject>, Map<string, InternalSession>>();
1775
+
1776
+ private _getSessionKeyForTokenStore(tokenStore: Store<TokenObject>, tokenObj: TokenObject): string {
1777
+ return this._externalTokenStoreSessionAdapter.getSessionKey(tokenStore, tokenObj);
1778
+ }
1749
1779
  /**
1750
1780
  * @param overrideTokenObj The tokens to build the session for, if they haven't been written to the token store yet
1751
1781
  * (so we can warm a sign-in's session before the token store publishes it).
1752
1782
  */
1753
1783
  protected _getSessionFromTokenStore(tokenStore: Store<TokenObject>, overrideTokenObj?: TokenObject): InternalSession {
1754
1784
  const tokenObj = overrideTokenObj ?? tokenStore.get();
1755
- const sessionKey = InternalSession.calculateSessionKey(tokenObj);
1785
+ const sessionKey = this._getSessionKeyForTokenStore(tokenStore, tokenObj);
1756
1786
  const existing = sessionKey ? this._sessionsByTokenStoreAndSessionKey.get(tokenStore)?.get(sessionKey) : null;
1757
1787
  if (existing) return existing;
1758
1788
 
1759
- const session = this._interface.createSession({
1760
- refreshToken: tokenObj.refreshToken,
1761
- accessToken: tokenObj.accessToken,
1762
- });
1789
+ const session = this._externalTokenStoreSessionAdapter.createSessionForTokenStore(tokenStore, tokenObj, sessionKey);
1763
1790
  session.onAccessTokenChange((newAccessToken) => {
1764
- tokenStore.update((old) => InternalSession.calculateSessionKey(old) === sessionKey ? {
1791
+ tokenStore.update((old) => this._getSessionKeyForTokenStore(tokenStore, old) === sessionKey ? {
1765
1792
  ...old,
1766
1793
  accessToken: newAccessToken?.token ?? null
1767
1794
  } : old);
1768
1795
  });
1769
1796
  session.onInvalidate(() => {
1770
- tokenStore.update((old) => InternalSession.calculateSessionKey(old) === sessionKey ? {
1797
+ tokenStore.update((old) => this._getSessionKeyForTokenStore(tokenStore, old) === sessionKey ? {
1771
1798
  ...old,
1772
1799
  accessToken: null,
1773
1800
  refreshToken: null,
@@ -1796,6 +1823,11 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
1796
1823
  if (!("accessToken" in tokens) || !("refreshToken" in tokens)) {
1797
1824
  throw new HexclaveAssertionError("Invalid tokens object; can't sign in with this", { tokens });
1798
1825
  }
1826
+ // An external token store keys its session by the provider's session, so Hexclave refresh tokens from a
1827
+ // first-party sign-in would be written into (and then silently replaced by) the provider's session.
1828
+ if (this.isExternalAuthApp()) {
1829
+ throw new Error("This app authenticates through an external provider and cannot sign in with Hexclave tokens. Sign in through the provider instead.");
1830
+ }
1799
1831
  const attemptId = ++this._signInAttemptCounter;
1800
1832
  const tokenStore = this._getOrCreateTokenStore(await this._createCookieHelper());
1801
1833
 
@@ -2251,6 +2283,7 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
2251
2283
  emailAuthEnabled: crud.auth_with_email,
2252
2284
  otpAuthEnabled: crud.otp_auth_enabled,
2253
2285
  oauthProviders: crud.oauth_providers,
2286
+ externalAuthProviders: crud.external_auth_providers,
2254
2287
  passkeyAuthEnabled: crud.passkey_auth_enabled,
2255
2288
  isMultiFactorRequired: crud.requires_totp_mfa,
2256
2289
  isAnonymous: crud.is_anonymous,
@@ -3254,6 +3287,69 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
3254
3287
  return crud && this._currentUserFromCrud(crud, session);
3255
3288
  }
3256
3289
 
3290
+ isExternalAuthApp(): boolean {
3291
+ return typeof this._tokenStoreInit === "object"
3292
+ && this._tokenStoreInit != null
3293
+ && isExternalTokenStore(this._tokenStoreInit);
3294
+ }
3295
+
3296
+ initAnalytics(): void {
3297
+ // This doesn't start anything: the constructor already starts analytics capture (see
3298
+ // _initializeAutomaticSideEffects). Pages that never import the module constructing the client app record no
3299
+ // analytics, which is easy to miss when the app is only imported where auth is used. Having a dedicated function
3300
+ // to call on every tracked page makes that import explicit, and gives setup docs and AI prompts a concrete step
3301
+ // to point at. Since the caller has thereby declared that they expect analytics, we fail loudly whenever we know
3302
+ // analytics won't be recorded.
3303
+ this._initAnalyticsCalled = true;
3304
+
3305
+ const disabledReason = this._getAnalyticsDisabledReason();
3306
+ if (disabledReason != null) {
3307
+ throw new Error(`initAnalytics() was called, but this Hexclave app will not record analytics because ${disabledReason}. Remove the initAnalytics() call if this is intentional.`);
3308
+ }
3309
+ if (this._analyticsAppDisabledOnProject) {
3310
+ throw createAnalyticsAppDisabledOnProjectError();
3311
+ }
3312
+
3313
+ // Outside the browser (eg. during SSR of a client component), analytics are never recorded, but the same module
3314
+ // runs again in the browser where they are, so this isn't an error.
3315
+ if (isBrowserLike() && this._eventTracker == null) {
3316
+ throw new HexclaveAssertionError("initAnalytics() was called in the browser and _getAnalyticsDisabledReason() returned null, but the constructor did not start the event tracker. The two must agree on when analytics are recorded.");
3317
+ }
3318
+ }
3319
+
3320
+ /**
3321
+ * Returns why this app will never record analytics, or null if it records them whenever it runs in a browser. The
3322
+ * constructor and initAnalytics() both use this, so that the conditions under which analytics start and the
3323
+ * conditions under which initAnalytics() throws can't drift apart.
3324
+ *
3325
+ * Whether the Analytics app is enabled on the project is not part of this, as the client only learns that once the
3326
+ * backend rejects an upload; see _handleAnalyticsUploadResult.
3327
+ */
3328
+ private _getAnalyticsDisabledReason(): string | null {
3329
+ if (this._analyticsOptions?.enabled === false) {
3330
+ return "the `analytics.enabled` constructor option is set to false";
3331
+ }
3332
+ if (this._options.automaticSideEffects === false) {
3333
+ return "the `automaticSideEffects` constructor option is set to false, which also disables analytics";
3334
+ }
3335
+ if (!this._hasPersistentTokenStore()) {
3336
+ return "the `tokenStore` constructor option is null; analytics require a persistent token store such as \"cookie\" (or \"nextjs-cookie\" in Next.js)";
3337
+ }
3338
+ return null;
3339
+ }
3340
+
3341
+ private _handleAnalyticsUploadResult(result: Result<Response, Error>): Result<Response, Error> {
3342
+ if (result.status === "error" && isAnalyticsNotEnabledError(result.error) && !this._analyticsAppDisabledOnProject) {
3343
+ this._analyticsAppDisabledOnProject = true;
3344
+ // initAnalytics() usually runs at module load, long before the first upload, so it can't throw this itself. If
3345
+ // it gets called after this point instead, it throws synchronously (see initAnalytics()).
3346
+ if (this._initAnalyticsCalled) {
3347
+ captureError("initAnalytics", createAnalyticsAppDisabledOnProjectError());
3348
+ }
3349
+ }
3350
+ return result;
3351
+ }
3352
+
3257
3353
 
3258
3354
  _getTokenPartialUserFromSession(session: InternalSession, options: GetCurrentPartialUserOptions<HasTokenStore>): TokenPartialUser | null {
3259
3355
  const accessToken = session.getAccessTokenIfNotExpiredYet(0, null);
@@ -3269,6 +3365,7 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
3269
3365
  primaryEmail: accessToken.payload.email,
3270
3366
  displayName: accessToken.payload.name,
3271
3367
  primaryEmailVerified: accessToken.payload.email_verified,
3368
+ selectedTeamId: accessToken.payload.selected_team_id,
3272
3369
  isAnonymous,
3273
3370
  isMultiFactorRequired: accessToken.payload.requires_totp_mfa,
3274
3371
  isRestricted: accessToken.payload.is_restricted,
@@ -3281,11 +3378,16 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
3281
3378
  if (!auth) {
3282
3379
  return null;
3283
3380
  }
3381
+ const selectedTeamId = auth.selected_team_id ?? null;
3382
+ if (selectedTeamId !== null && typeof selectedTeamId !== "string") {
3383
+ throw new HexclaveAssertionError("Expected the Convex selected_team_id claim to be a string or null.");
3384
+ }
3284
3385
  return {
3285
3386
  id: auth.subject,
3286
3387
  displayName: auth.name ?? null,
3287
3388
  primaryEmail: auth.email ?? null,
3288
3389
  primaryEmailVerified: auth.email_verified as boolean,
3390
+ selectedTeamId,
3289
3391
  isAnonymous: auth.is_anonymous as boolean,
3290
3392
  isMultiFactorRequired: auth.requires_totp_mfa as boolean,
3291
3393
  isRestricted: auth.is_restricted as boolean,
@@ -3841,6 +3943,12 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
3841
3943
  const signOutOperation = (async () => {
3842
3944
  // Preserve the old behavior of serializing concurrent sign-outs without coupling them to unrelated stores.
3843
3945
  await previousSignOut;
3946
+ const externalTokenStore = this._externalTokenStoreSessionAdapter.getExternalTokenStoreForSession(session);
3947
+ if (externalTokenStore != null) {
3948
+ throw new HexclaveAssertionError(
3949
+ `Cannot sign out an externally authenticated session through Hexclave. Sign out through the ${externalTokenStore.providerId} provider instead.`,
3950
+ );
3951
+ }
3844
3952
  await this._interface.signOut(session);
3845
3953
  if (options?.redirectUrl) {
3846
3954
  await this._redirectTo({ url: options.redirectUrl, replace: true });
@@ -4018,6 +4126,9 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
4018
4126
  if (typeof this._redirectMethod !== "string") {
4019
4127
  throw new HexclaveAssertionError("Cannot serialize to JSON from an application with a non-string redirect method");
4020
4128
  }
4129
+ if (typeof this._tokenStoreInit === "object" && this._tokenStoreInit != null && isExternalTokenStore(this._tokenStoreInit)) {
4130
+ throw new HexclaveAssertionError("Cannot serialize an app with an external token store because its callbacks are not serializable. Construct this app in the client runtime instead.");
4131
+ }
4021
4132
 
4022
4133
  const publishableClientKey = "publishableClientKey" in this._interface.options
4023
4134
  ? this._interface.options.publishableClientKey
@@ -0,0 +1,190 @@
1
+
2
+ //===========================================
3
+ // THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template
4
+ //===========================================
5
+ import { KnownErrors } from "@hexclave/shared";
6
+ import type { ExternalAuthProviderId } from "@hexclave/shared/dist/interface/external-auth";
7
+ import { AccessToken, InternalSession } from "@hexclave/shared/dist/sessions";
8
+ import { throwErr } from "@hexclave/shared/dist/utils/errors";
9
+ import { Store } from "@hexclave/shared/dist/utils/stores";
10
+ import type { ExternalTokenStore } from "../../common";
11
+ import { createEmptyTokenStore, type TokenObject } from "./common";
12
+
13
+ type ExternalTokenStoreState = {
14
+ generation: number,
15
+ providerSessionId: string | null | undefined,
16
+ };
17
+
18
+ type SessionOptions = {
19
+ refreshToken: string | null,
20
+ accessToken?: string | null,
21
+ sessionKey?: string,
22
+ refreshAccessTokenWithoutRefreshTokenCallback?: () => Promise<AccessToken | null>,
23
+ };
24
+
25
+ export function isExternalTokenStore(value: object): value is ExternalTokenStore {
26
+ return "type" in value && value.type === "external";
27
+ }
28
+
29
+ export class ExternalTokenStoreSessionAdapter {
30
+ private readonly storesByExternalTokenStore = new WeakMap<ExternalTokenStore, Store<TokenObject>>();
31
+ private readonly externalTokenStoresByStore = new WeakMap<Store<TokenObject>, ExternalTokenStore>();
32
+ private readonly externalTokenStoresBySession = new WeakMap<InternalSession, ExternalTokenStore>();
33
+ private readonly states = new WeakMap<ExternalTokenStore, ExternalTokenStoreState>();
34
+
35
+ constructor(
36
+ private readonly createSession: (options: SessionOptions) => InternalSession,
37
+ private readonly exchangeToken: (providerId: ExternalAuthProviderId, token: string) => Promise<string>,
38
+ ) {}
39
+
40
+ getOrCreateTokenStore(
41
+ externalTokenStore: ExternalTokenStore,
42
+ onProviderChange: (store: Store<TokenObject>, previousSessionKey: string) => void,
43
+ ): Store<TokenObject> {
44
+ const existing = this.storesByExternalTokenStore.get(externalTokenStore);
45
+ if (existing != null) {
46
+ return existing;
47
+ }
48
+
49
+ const store = createEmptyTokenStore();
50
+ this.storesByExternalTokenStore.set(externalTokenStore, store);
51
+ this.externalTokenStoresByStore.set(store, externalTokenStore);
52
+ this.states.set(externalTokenStore, {
53
+ generation: 0,
54
+ providerSessionId: externalTokenStore.getSessionId?.(),
55
+ });
56
+ externalTokenStore.subscribe?.(() => {
57
+ const state = this.states.get(externalTokenStore) ?? throwErr("External token store state was not initialized");
58
+ // Derive the previous session's key from the recorded state, not from a live getSessionId()
59
+ // read: by the time this notification fires, the provider has already switched sessions, so a
60
+ // live read would compute the *new* key and the expiry hint would target the wrong session.
61
+ const previousSessionKey = this.buildSessionKey(externalTokenStore, state);
62
+ const providerSessionId = externalTokenStore.getSessionId?.();
63
+ if (externalTokenStore.getSessionId == null || providerSessionId !== state.providerSessionId) {
64
+ state.generation += 1;
65
+ state.providerSessionId = providerSessionId;
66
+ }
67
+ onProviderChange(store, previousSessionKey);
68
+ store.set({ accessToken: null, refreshToken: null });
69
+ });
70
+ return store;
71
+ }
72
+
73
+ getSessionKey(tokenStore: Store<TokenObject>, tokenObject: TokenObject): string {
74
+ const externalTokenStore = this.externalTokenStoresByStore.get(tokenStore);
75
+ if (externalTokenStore == null) {
76
+ return InternalSession.calculateSessionKey(tokenObject);
77
+ }
78
+
79
+ const state = this.states.get(externalTokenStore) ?? throwErr("External token store state was not initialized");
80
+ // Read the provider session id live (instead of from state) so a session switch is reflected
81
+ // immediately, even before the store's subscribe notification has fired.
82
+ return this.buildSessionKey(externalTokenStore, {
83
+ generation: state.generation,
84
+ providerSessionId: externalTokenStore.getSessionId?.(),
85
+ });
86
+ }
87
+
88
+ private buildSessionKey(externalTokenStore: ExternalTokenStore, state: ExternalTokenStoreState): string {
89
+ if (externalTokenStore.getSessionId == null) {
90
+ return `external-${externalTokenStore.providerId}-${state.generation}`;
91
+ }
92
+ return state.providerSessionId == null
93
+ ? "not-logged-in"
94
+ : `external-${externalTokenStore.providerId}-${state.providerSessionId}`;
95
+ }
96
+
97
+ createSessionForTokenStore(
98
+ tokenStore: Store<TokenObject>,
99
+ tokenObject: TokenObject,
100
+ sessionKey: string,
101
+ ): InternalSession {
102
+ const externalTokenStore = this.externalTokenStoresByStore.get(tokenStore);
103
+ let session: InternalSession;
104
+ if (externalTokenStore == null) {
105
+ session = this.createSession({
106
+ refreshToken: tokenObject.refreshToken,
107
+ accessToken: tokenObject.accessToken,
108
+ });
109
+ } else if (sessionKey === "not-logged-in") {
110
+ // A provider can clear its session before its token store notification arrives; never let
111
+ // that transiently expose the previous provider session's cached Hexclave token.
112
+ session = this.createSession({
113
+ refreshToken: null,
114
+ accessToken: null,
115
+ sessionKey,
116
+ });
117
+ } else {
118
+ session = this.createSession({
119
+ refreshToken: null,
120
+ accessToken: tokenObject.accessToken,
121
+ sessionKey,
122
+ refreshAccessTokenWithoutRefreshTokenCallback: async () => {
123
+ const state = this.states.get(externalTokenStore) ?? throwErr("External token store state was not initialized");
124
+ const expectedGeneration = state.generation;
125
+ const assertSessionIdentityUnchanged = () => {
126
+ const liveSessionKey = this.buildSessionKey(externalTokenStore, {
127
+ generation: state.generation,
128
+ providerSessionId: externalTokenStore.getSessionId?.(),
129
+ });
130
+ if (state.generation !== expectedGeneration || liveSessionKey !== sessionKey) {
131
+ throw new Error("The external provider session changed while exchanging a token; retrying with the current provider session.");
132
+ }
133
+ };
134
+ // Returning null from this callback permanently invalidates the cached InternalSession, so
135
+ // we must only do it when the provider session is definitively gone — for transient states
136
+ // we throw instead, which surfaces the failure to the current caller but lets the next
137
+ // request retry the exchange.
138
+ const attemptExchange = async (isRetry: boolean): Promise<AccessToken | null> => {
139
+ assertSessionIdentityUnchanged();
140
+ const externalToken = await externalTokenStore.getToken();
141
+ assertSessionIdentityUnchanged();
142
+ if (externalToken == null) {
143
+ if (externalTokenStore.getSessionId?.() != null) {
144
+ // The provider reports an active session but handed out no token; this is usually a
145
+ // transient state (eg. its SDK is still initializing), so don't treat it as signed out.
146
+ throw new Error("The external token store returned no token even though it reports an active provider session. This is usually transient (eg. the provider SDK is still initializing) and the exchange will be retried on the next request.");
147
+ }
148
+ return null;
149
+ }
150
+ try {
151
+ const accessToken = await this.exchangeToken(externalTokenStore.providerId, externalToken);
152
+ const validatedAccessToken = AccessToken.createIfValid(accessToken)
153
+ ?? throwErr("External authentication exchange returned an invalid Hexclave access token");
154
+ // An explicit session key bypasses InternalSession's identity check. Refuse to install
155
+ // a token if the provider switched accounts while the exchange was in flight instead
156
+ // of returning null, which would permanently invalidate the reusable session.
157
+ assertSessionIdentityUnchanged();
158
+ return validatedAccessToken;
159
+ } catch (error) {
160
+ if (!KnownErrors.InvalidExternalAuthToken.isInstance(error)) {
161
+ throw error;
162
+ }
163
+ // The server rejects provider tokens that expired in flight with the same error as
164
+ // genuinely bad ones, so retry once with a freshly fetched provider token: the provider
165
+ // SDK replaces an expired token. If the fresh token is rejected too, the cause is not
166
+ // transient (eg. an issuer or client-ID misconfiguration), and it does not mean the
167
+ // provider session ended either: the backend re-establishes a Hexclave session for any
168
+ // valid provider token, so a rejection never signals revocation. Propagate the error
169
+ // instead of returning null, which would permanently invalidate this session and make
170
+ // a misconfiguration look like a silent sign-out.
171
+ if (isRetry) {
172
+ throw error;
173
+ }
174
+ return await attemptExchange(true);
175
+ }
176
+ };
177
+ return await attemptExchange(false);
178
+ },
179
+ });
180
+ }
181
+ if (externalTokenStore != null) {
182
+ this.externalTokenStoresBySession.set(session, externalTokenStore);
183
+ }
184
+ return session;
185
+ }
186
+
187
+ getExternalTokenStoreForSession(session: InternalSession): ExternalTokenStore | undefined {
188
+ return this.externalTokenStoresBySession.get(session);
189
+ }
190
+ }
@@ -83,6 +83,23 @@ export type StackClientApp<HasTokenStore extends boolean = boolean, ProjectId ex
83
83
  * `redirectToSignIn()`, `redirectToSignUp()`, `redirectToSignOut()`, or `redirectToAccountSettings()`.
84
84
  */
85
85
  readonly urls: Readonly<ResolvedHandlerUrls>,
86
+ /** Whether this app delegates authentication to an external provider token store. */
87
+ isExternalAuthApp(): boolean,
88
+
89
+ /**
90
+ * Marks the current page as one that should record analytics (page views, clicks, session replays, and clickmaps).
91
+ *
92
+ * Analytics capture starts automatically when the client app is constructed in the browser, so this function doesn't
93
+ * start anything itself. Call it at the top level of a client-side module that is loaded on every page you want to
94
+ * track (for example, your app's root layout or entry file); the call guarantees that the module creating the client
95
+ * app is actually imported, and therefore constructed, on that page.
96
+ *
97
+ * Throws if this app will never record analytics because of its constructor options (`analytics.enabled: false`,
98
+ * `automaticSideEffects: false`, or a `null` token store). If the Analytics app turns out to be disabled on the
99
+ * project, an error is reported as soon as the backend rejects the first upload. Outside the browser (eg. during
100
+ * server-side rendering), it doesn't throw for that reason alone.
101
+ */
102
+ initAnalytics(): void,
86
103
 
87
104
  signInWithOAuth(provider: string, options?: { returnTo?: string }): Promise<void>,
88
105
  signInWithCredential(options: { email: string, password: string, noRedirect?: boolean }): Promise<Result<undefined, KnownErrors["EmailPasswordMismatch"] | KnownErrors["InvalidTotpCode"]>>,
@@ -3,6 +3,7 @@
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
5
  import { ProviderType } from "@hexclave/shared/dist/utils/oauth";
6
+ import type { ExternalAuthProviderId } from "@hexclave/shared/dist/interface/external-auth";
6
7
  import type { GenericQueryCtx, UserIdentity } from "convex/server";
7
8
  export type {
8
9
  DefaultHandlerUrlTarget,
@@ -87,6 +88,37 @@ export type RequestLike = {
87
88
  | Record<string, string | null>,
88
89
  };
89
90
 
91
+ export type ExternalTokenStore = {
92
+ readonly type: "external",
93
+ readonly providerId: ExternalAuthProviderId,
94
+ getSessionId?(): string | null,
95
+ getToken(): Promise<string | null>,
96
+ /** The adapter retains this subscription for its own lifetime and does not unsubscribe it. */
97
+ subscribe?(callback: () => void): () => void,
98
+ };
99
+
100
+ type ExternalTokenStoreOptions = Omit<ExternalTokenStore, "type" | "providerId">;
101
+
102
+ function createExternalTokenStore(providerId: ExternalAuthProviderId, options: ExternalTokenStoreOptions): ExternalTokenStore {
103
+ return {
104
+ ...options,
105
+ type: "external",
106
+ providerId,
107
+ };
108
+ }
109
+
110
+ export function clerkTokenStore(options: ExternalTokenStoreOptions): ExternalTokenStore {
111
+ return createExternalTokenStore("clerk-integration", options);
112
+ }
113
+
114
+ export function betterAuthTokenStore(options: ExternalTokenStoreOptions): ExternalTokenStore {
115
+ return createExternalTokenStore("better-auth-integration", options);
116
+ }
117
+
118
+ export function workosTokenStore(options: ExternalTokenStoreOptions): ExternalTokenStore {
119
+ return createExternalTokenStore("workos-integration", options);
120
+ }
121
+
90
122
  export type TokenStoreInit<HasTokenStore extends boolean = boolean> =
91
123
  HasTokenStore extends true ? (
92
124
  | "cookie"
@@ -94,6 +126,7 @@ export type TokenStoreInit<HasTokenStore extends boolean = boolean> =
94
126
  | "memory"
95
127
  | RequestLike
96
128
  | { accessToken: string, refreshToken: string }
129
+ | ExternalTokenStore
97
130
  )
98
131
  : HasTokenStore extends false ? null
99
132
  : TokenStoreInit<true> | TokenStoreInit<false>;
@@ -83,13 +83,17 @@ export type {
83
83
  } from "./internal-api-keys";
84
84
 
85
85
  export {
86
+ betterAuthTokenStore,
87
+ clerkTokenStore,
86
88
  hexclaveAppInternalsSymbol,
89
+ workosTokenStore,
87
90
  } from "./common";
88
91
  export {
89
92
  getPagePrompt,
90
93
  } from "./url-targets";
91
94
  export type {
92
95
  GetCurrentUserOptions,
96
+ ExternalTokenStore,
93
97
  /** @deprecated Use GetCurrentUserOptions instead */
94
98
  GetCurrentUserOptions as GetUserOptions,
95
99
  HandlerUrlOptions,