@oxyhq/core 1.11.21 → 1.11.23

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 (39) hide show
  1. package/README.md +6 -2
  2. package/dist/cjs/.tsbuildinfo +1 -1
  3. package/dist/cjs/HttpService.js +52 -0
  4. package/dist/cjs/OxyServices.base.js +16 -0
  5. package/dist/cjs/mixins/OxyServices.fedcm.js +0 -80
  6. package/dist/esm/.tsbuildinfo +1 -1
  7. package/dist/esm/HttpService.js +52 -0
  8. package/dist/esm/OxyServices.base.js +16 -0
  9. package/dist/esm/mixins/OxyServices.fedcm.js +0 -79
  10. package/dist/types/.tsbuildinfo +1 -1
  11. package/dist/types/HttpService.d.ts +31 -0
  12. package/dist/types/OxyServices.base.d.ts +14 -0
  13. package/dist/types/mixins/OxyServices.analytics.d.ts +1 -0
  14. package/dist/types/mixins/OxyServices.appData.d.ts +1 -0
  15. package/dist/types/mixins/OxyServices.assets.d.ts +1 -0
  16. package/dist/types/mixins/OxyServices.auth.d.ts +1 -0
  17. package/dist/types/mixins/OxyServices.contacts.d.ts +1 -0
  18. package/dist/types/mixins/OxyServices.developer.d.ts +1 -0
  19. package/dist/types/mixins/OxyServices.devices.d.ts +1 -0
  20. package/dist/types/mixins/OxyServices.features.d.ts +6 -1
  21. package/dist/types/mixins/OxyServices.fedcm.d.ts +1 -24
  22. package/dist/types/mixins/OxyServices.karma.d.ts +1 -0
  23. package/dist/types/mixins/OxyServices.language.d.ts +1 -0
  24. package/dist/types/mixins/OxyServices.location.d.ts +1 -0
  25. package/dist/types/mixins/OxyServices.managedAccounts.d.ts +1 -0
  26. package/dist/types/mixins/OxyServices.payment.d.ts +1 -0
  27. package/dist/types/mixins/OxyServices.popup.d.ts +1 -0
  28. package/dist/types/mixins/OxyServices.privacy.d.ts +1 -0
  29. package/dist/types/mixins/OxyServices.redirect.d.ts +1 -0
  30. package/dist/types/mixins/OxyServices.security.d.ts +1 -0
  31. package/dist/types/mixins/OxyServices.topics.d.ts +1 -0
  32. package/dist/types/mixins/OxyServices.user.d.ts +1 -0
  33. package/dist/types/mixins/OxyServices.utility.d.ts +1 -0
  34. package/package.json +1 -1
  35. package/src/HttpService.ts +53 -0
  36. package/src/OxyServices.base.ts +17 -0
  37. package/src/mixins/OxyServices.fedcm.ts +0 -83
  38. package/src/mixins/__tests__/fedcm.test.ts +0 -182
  39. package/src/mixins/__tests__/onTokensChanged.test.ts +130 -0
@@ -109,6 +109,16 @@ class HttpService {
109
109
  this.tokenRefreshPromise = null;
110
110
  this.tokenRefreshCooldownUntil = 0;
111
111
  this._onTokenRefreshed = null;
112
+ /**
113
+ * Fan-out listeners notified on EVERY access-token change on this instance:
114
+ * explicit `setTokens`, `clearTokens`, a successful silent refresh, and the
115
+ * internal 401-driven clear. Unlike the single-slot `_onTokenRefreshed`
116
+ * (owned by AuthManager for the refresh path only), this is a Set so multiple
117
+ * independent observers can mirror token state without clobbering each other.
118
+ *
119
+ * Each listener receives the resulting access token, or `null` when cleared.
120
+ */
121
+ this._tokenChangeListeners = new Set();
112
122
  // Acting-as identity for managed accounts
113
123
  this._actingAsUserId = null;
114
124
  // Performance monitoring
@@ -312,6 +322,7 @@ class HttpService {
312
322
  // Refresh failed or no token — clear tokens and stale CSRF
313
323
  this.tokenStore.clearTokens();
314
324
  this.tokenStore.clearCsrfToken();
325
+ this.notifyTokenChange();
315
326
  }
316
327
  // On 403 with CSRF error, clear cached token and retry once
317
328
  if (response.status === 403 && !config._isCsrfRetry) {
@@ -691,6 +702,7 @@ class HttpService {
691
702
  const { accessToken: newToken } = await response.json();
692
703
  this.tokenStore.setTokens(newToken);
693
704
  this._onTokenRefreshed?.(newToken);
705
+ this.notifyTokenChange();
694
706
  this.logger.debug('Token refreshed');
695
707
  return `Bearer ${newToken}`;
696
708
  }
@@ -756,6 +768,7 @@ class HttpService {
756
768
  // Token management
757
769
  setTokens(accessToken, refreshToken = '') {
758
770
  this.tokenStore.setTokens(accessToken, refreshToken);
771
+ this.notifyTokenChange();
759
772
  }
760
773
  set onTokenRefreshed(callback) {
761
774
  this._onTokenRefreshed = callback;
@@ -763,6 +776,45 @@ class HttpService {
763
776
  clearTokens() {
764
777
  this.tokenStore.clearTokens();
765
778
  this.tokenStore.clearCsrfToken();
779
+ this.notifyTokenChange();
780
+ }
781
+ /**
782
+ * Subscribe to access-token changes on this instance.
783
+ *
784
+ * Fires on every mutation of the access token — `setTokens`, `clearTokens`,
785
+ * a successful silent refresh, and the internal 401-driven clear — passing
786
+ * the resulting token (or `null` when cleared). Returns an unsubscribe
787
+ * function; call it on teardown to avoid leaks.
788
+ *
789
+ * This is the single hook downstream code (e.g. @oxyhq/services' OxyProvider)
790
+ * uses to keep an external token sink — such as the shared `oxyClient`
791
+ * singleton — in lockstep with the active session, regardless of which code
792
+ * path mutated the token.
793
+ */
794
+ addTokenChangeListener(listener) {
795
+ this._tokenChangeListeners.add(listener);
796
+ return () => {
797
+ this._tokenChangeListeners.delete(listener);
798
+ };
799
+ }
800
+ /**
801
+ * Notify all token-change listeners with the current access token.
802
+ * Listener exceptions are isolated so one bad subscriber cannot break token
803
+ * propagation to the others or to the calling auth flow.
804
+ * @internal
805
+ */
806
+ notifyTokenChange() {
807
+ if (this._tokenChangeListeners.size === 0)
808
+ return;
809
+ const accessToken = this.tokenStore.getAccessToken();
810
+ for (const listener of this._tokenChangeListeners) {
811
+ try {
812
+ listener(accessToken);
813
+ }
814
+ catch (error) {
815
+ this.logger.error('Token change listener threw:', error);
816
+ }
817
+ }
766
818
  }
767
819
  getAccessToken() {
768
820
  return this.tokenStore.getAccessToken();
@@ -113,6 +113,22 @@ class OxyServicesBase {
113
113
  this._cachedUserId = undefined;
114
114
  this._cachedAccessToken = null;
115
115
  }
116
+ /**
117
+ * Subscribe to access-token changes on this client.
118
+ *
119
+ * The listener fires on every access-token mutation — explicit
120
+ * `setTokens`/`clearTokens`, a successful silent refresh, and the internal
121
+ * 401-driven clear — receiving the resulting token, or `null` when cleared.
122
+ * Returns an unsubscribe function.
123
+ *
124
+ * Primary use: keeping an external token sink (e.g. the shared `oxyClient`
125
+ * singleton) in lockstep with whichever `OxyServices` instance actually owns
126
+ * the session, so imperative consumers reading the singleton always observe
127
+ * the live token regardless of the code path that changed it.
128
+ */
129
+ onTokensChanged(listener) {
130
+ return this.httpService.addTokenChangeListener(listener);
131
+ }
116
132
  /**
117
133
  * Get the current user ID from the access token.
118
134
  * Caches the decoded value and invalidates when the token changes.
@@ -1,6 +1,5 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.__resetSilentSSOMemoForTests = __resetSilentSSOMemoForTests;
4
3
  exports.OxyServicesFedCMMixin = OxyServicesFedCMMixin;
5
4
  exports.FedCMMixin = OxyServicesFedCMMixin;
6
5
  const OxyServices_errors_1 = require("../OxyServices.errors");
@@ -44,44 +43,6 @@ const FEDCM_LOGIN_HINT_KEY = 'oxy_fedcm_login_hint';
44
43
  let fedCMRequestInProgress = false;
45
44
  let fedCMRequestPromise = null;
46
45
  let currentMediationMode = null;
47
- /**
48
- * Page-load-persistent memo for SILENT FedCM sign-in.
49
- *
50
- * Silent SSO (`mediation: 'silent'`) is the one FedCM flow that runs WITHOUT a
51
- * user gesture — on app startup / provider mount. Multiple consumers
52
- * (`@oxyhq/auth`'s `WebOxyProvider` / `useWebSSO`, `@oxyhq/services`'
53
- * `useWebSSO`) can each mount and trigger it, and a remount storm (route churn,
54
- * React StrictMode double-invoke, error-boundary recovery) previously turned
55
- * into a `navigator.credentials.get` storm. This memo collapses every silent
56
- * attempt for a given `origin + baseURL` into AT MOST ONE browser credential
57
- * request per page load:
58
- *
59
- * - the FIRST silent call runs the real flow and stores its in-flight promise;
60
- * - concurrent silent calls share that same in-flight promise;
61
- * - once it settles, the memo retains the resolved value (a session OR `null`)
62
- * and every subsequent silent call returns it WITHOUT re-invoking the
63
- * browser.
64
- *
65
- * Keyed on `origin + baseURL` (not the OxyServices instance) so it survives
66
- * instance churn across remounts. Intentionally never cleared: only a fresh
67
- * page load — which starts a fresh module scope — can change the IdP session
68
- * state that silent mediation observes.
69
- *
70
- * This guard is SILENT-ONLY. Interactive flows (`signInWithFedCM`,
71
- * `mediation: 'optional'|'required'`, `mode: 'active'|'passive'`) must always
72
- * be able to re-prompt and are never memoized here.
73
- */
74
- const silentSSOMemo = new Map();
75
- /**
76
- * Test-only reset of the page-load silent-SSO memo. The memo is module-scoped
77
- * and never cleared at runtime (a fresh page load resets it naturally), but
78
- * tests sharing one module instance need to start from a clean slate.
79
- *
80
- * @internal
81
- */
82
- function __resetSilentSSOMemoForTests() {
83
- silentSSOMemo.clear();
84
- }
85
46
  /**
86
47
  * Federated Credential Management (FedCM) Authentication Mixin
87
48
  *
@@ -253,47 +214,6 @@ function OxyServicesFedCMMixin(Base) {
253
214
  debug.log('Silent SSO: FedCM not supported in this browser');
254
215
  return null;
255
216
  }
256
- // Page-load run-once guard. The first silent attempt for this
257
- // origin + API runs; concurrent callers share the in-flight promise; once
258
- // it settles, every later caller gets the memoized result (session OR
259
- // null) WITHOUT re-invoking `navigator.credentials.get`. This is the single
260
- // chokepoint for silent SSO across all consumers and remounts.
261
- const memoKey = this.silentSSOMemoKey();
262
- const existing = silentSSOMemo.get(memoKey);
263
- if (existing) {
264
- debug.log('Silent SSO: Returning memoized page-load result (no re-invocation)');
265
- return existing;
266
- }
267
- const attempt = this._performSilentSignInWithFedCM();
268
- silentSSOMemo.set(memoKey, attempt);
269
- return attempt;
270
- }
271
- /**
272
- * Build the page-load silent-SSO memo key from the current origin and the
273
- * configured API base URL. Two providers pointed at the same API from the
274
- * same origin share a single silent attempt per page load.
275
- *
276
- * @internal
277
- */
278
- silentSSOMemoKey() {
279
- const origin = typeof window !== 'undefined' ? window.location.origin : 'no-origin';
280
- let baseURL = '';
281
- try {
282
- baseURL = this.getBaseURL();
283
- }
284
- catch {
285
- baseURL = '';
286
- }
287
- return `${origin}|${baseURL}`;
288
- }
289
- /**
290
- * Perform the actual silent FedCM sign-in. Always wrapped by
291
- * {@link silentSignInWithFedCM}'s page-load memo — never call this directly
292
- * (doing so bypasses the run-once guard).
293
- *
294
- * @internal
295
- */
296
- async _performSilentSignInWithFedCM() {
297
217
  const clientId = this.getClientId();
298
218
  debug.log('Silent SSO: Starting for', clientId);
299
219
  // Only try silent mediation (no UI) - works if user previously consented.