@oxyhq/core 1.11.9 → 1.11.11

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 (63) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/AuthManager.js +158 -1
  3. package/dist/cjs/HttpService.js +13 -0
  4. package/dist/cjs/OxyServices.base.js +21 -0
  5. package/dist/cjs/crypto/keyManager.js +4 -6
  6. package/dist/cjs/crypto/polyfill.js +56 -12
  7. package/dist/cjs/crypto/signatureService.js +7 -4
  8. package/dist/cjs/mixins/OxyServices.fedcm.js +9 -4
  9. package/dist/cjs/mixins/OxyServices.managedAccounts.js +117 -0
  10. package/dist/cjs/mixins/OxyServices.popup.js +9 -5
  11. package/dist/cjs/mixins/OxyServices.utility.js +81 -2
  12. package/dist/cjs/mixins/index.js +2 -0
  13. package/dist/esm/.tsbuildinfo +1 -1
  14. package/dist/esm/AuthManager.js +158 -1
  15. package/dist/esm/HttpService.js +13 -0
  16. package/dist/esm/OxyServices.base.js +21 -0
  17. package/dist/esm/crypto/keyManager.js +4 -6
  18. package/dist/esm/crypto/polyfill.js +23 -12
  19. package/dist/esm/crypto/signatureService.js +7 -4
  20. package/dist/esm/mixins/OxyServices.fedcm.js +9 -4
  21. package/dist/esm/mixins/OxyServices.managedAccounts.js +114 -0
  22. package/dist/esm/mixins/OxyServices.popup.js +9 -5
  23. package/dist/esm/mixins/OxyServices.utility.js +81 -2
  24. package/dist/esm/mixins/index.js +2 -0
  25. package/dist/types/.tsbuildinfo +1 -1
  26. package/dist/types/AuthManager.d.ts +21 -0
  27. package/dist/types/HttpService.d.ts +3 -0
  28. package/dist/types/OxyServices.base.d.ts +17 -0
  29. package/dist/types/index.d.ts +1 -0
  30. package/dist/types/mixins/OxyServices.analytics.d.ts +2 -0
  31. package/dist/types/mixins/OxyServices.assets.d.ts +2 -0
  32. package/dist/types/mixins/OxyServices.auth.d.ts +2 -0
  33. package/dist/types/mixins/OxyServices.developer.d.ts +2 -0
  34. package/dist/types/mixins/OxyServices.devices.d.ts +2 -0
  35. package/dist/types/mixins/OxyServices.features.d.ts +5 -1
  36. package/dist/types/mixins/OxyServices.fedcm.d.ts +3 -0
  37. package/dist/types/mixins/OxyServices.karma.d.ts +2 -0
  38. package/dist/types/mixins/OxyServices.language.d.ts +2 -0
  39. package/dist/types/mixins/OxyServices.location.d.ts +2 -0
  40. package/dist/types/mixins/OxyServices.managedAccounts.d.ts +125 -0
  41. package/dist/types/mixins/OxyServices.payment.d.ts +2 -0
  42. package/dist/types/mixins/OxyServices.popup.d.ts +4 -0
  43. package/dist/types/mixins/OxyServices.privacy.d.ts +2 -0
  44. package/dist/types/mixins/OxyServices.redirect.d.ts +2 -0
  45. package/dist/types/mixins/OxyServices.security.d.ts +2 -0
  46. package/dist/types/mixins/OxyServices.topics.d.ts +2 -0
  47. package/dist/types/mixins/OxyServices.user.d.ts +2 -0
  48. package/dist/types/mixins/OxyServices.utility.d.ts +22 -0
  49. package/dist/types/models/interfaces.d.ts +2 -0
  50. package/package.json +1 -1
  51. package/src/AuthManager.ts +186 -4
  52. package/src/HttpService.ts +17 -0
  53. package/src/OxyServices.base.ts +23 -0
  54. package/src/crypto/keyManager.ts +4 -6
  55. package/src/crypto/polyfill.ts +23 -12
  56. package/src/crypto/signatureService.ts +7 -4
  57. package/src/index.ts +1 -0
  58. package/src/mixins/OxyServices.fedcm.ts +11 -4
  59. package/src/mixins/OxyServices.managedAccounts.ts +147 -0
  60. package/src/mixins/OxyServices.popup.ts +11 -5
  61. package/src/mixins/OxyServices.utility.ts +103 -2
  62. package/src/mixins/index.ts +2 -0
  63. package/src/models/interfaces.ts +3 -0
@@ -102,17 +102,106 @@ export class AuthManager {
102
102
  this.currentUser = null;
103
103
  this.refreshTimer = null;
104
104
  this.refreshPromise = null;
105
+ /** Tracks the access token this instance last knew about, for cross-tab adoption. */
106
+ this._lastKnownAccessToken = null;
107
+ /** BroadcastChannel for coordinating token refreshes across browser tabs. */
108
+ this._broadcastChannel = null;
109
+ /** Set to true when another tab broadcasts a successful refresh, so this tab can skip its own. */
110
+ this._otherTabRefreshed = false;
105
111
  this.oxyServices = oxyServices;
112
+ const crossTabSync = config.crossTabSync ?? (typeof BroadcastChannel !== 'undefined');
106
113
  this.config = {
107
114
  storage: config.storage ?? this.getDefaultStorage(),
108
115
  autoRefresh: config.autoRefresh ?? true,
109
116
  refreshBuffer: config.refreshBuffer ?? 5 * 60 * 1000, // 5 minutes
117
+ crossTabSync,
110
118
  };
111
119
  this.storage = this.config.storage;
112
120
  // Persist tokens to storage when HttpService refreshes them automatically
113
121
  this.oxyServices.httpService.onTokenRefreshed = (accessToken) => {
122
+ this._lastKnownAccessToken = accessToken;
114
123
  this.storage.setItem(STORAGE_KEYS.ACCESS_TOKEN, accessToken);
115
124
  };
125
+ // Setup cross-tab coordination in browser environments
126
+ if (this.config.crossTabSync) {
127
+ this._initBroadcastChannel();
128
+ }
129
+ }
130
+ /**
131
+ * Initialize BroadcastChannel for cross-tab token refresh coordination.
132
+ * Only called in browser environments where BroadcastChannel is available.
133
+ */
134
+ _initBroadcastChannel() {
135
+ if (typeof BroadcastChannel === 'undefined')
136
+ return;
137
+ try {
138
+ this._broadcastChannel = new BroadcastChannel('oxy_auth_sync');
139
+ this._broadcastChannel.onmessage = (event) => {
140
+ this._handleCrossTabMessage(event.data);
141
+ };
142
+ }
143
+ catch {
144
+ // BroadcastChannel not supported or blocked (e.g., opaque origins)
145
+ this._broadcastChannel = null;
146
+ }
147
+ }
148
+ /**
149
+ * Handle messages from other tabs about token refresh activity.
150
+ */
151
+ async _handleCrossTabMessage(message) {
152
+ if (!message || !message.type)
153
+ return;
154
+ switch (message.type) {
155
+ case 'tokens_refreshed': {
156
+ // Another tab successfully refreshed. Signal to cancel our pending refresh.
157
+ this._otherTabRefreshed = true;
158
+ // Adopt the new tokens from shared storage
159
+ const newToken = await this.storage.getItem(STORAGE_KEYS.ACCESS_TOKEN);
160
+ if (newToken && newToken !== this._lastKnownAccessToken) {
161
+ this._lastKnownAccessToken = newToken;
162
+ this.oxyServices.httpService.setTokens(newToken);
163
+ // Re-read session for updated expiry and schedule next refresh
164
+ const sessionJson = await this.storage.getItem(STORAGE_KEYS.SESSION);
165
+ if (sessionJson) {
166
+ try {
167
+ const session = JSON.parse(sessionJson);
168
+ if (session.expiresAt && this.config.autoRefresh) {
169
+ this.setupTokenRefresh(session.expiresAt);
170
+ }
171
+ }
172
+ catch {
173
+ // Ignore parse errors
174
+ }
175
+ }
176
+ }
177
+ break;
178
+ }
179
+ case 'signed_out': {
180
+ // Another tab signed out. Clear our local state to stay consistent.
181
+ if (this.refreshTimer) {
182
+ clearTimeout(this.refreshTimer);
183
+ this.refreshTimer = null;
184
+ }
185
+ this.refreshPromise = null;
186
+ this._lastKnownAccessToken = null;
187
+ this.oxyServices.httpService.setTokens('');
188
+ this.currentUser = null;
189
+ this.notifyListeners();
190
+ break;
191
+ }
192
+ // 'refresh_starting' is informational; we don't need to act on it currently
193
+ }
194
+ }
195
+ /**
196
+ * Broadcast a message to other tabs.
197
+ */
198
+ _broadcast(message) {
199
+ try {
200
+ this._broadcastChannel?.postMessage(message);
201
+ }
202
+ catch {
203
+ // Channel closed or unavailable
204
+ }
116
205
  }
117
206
  /**
118
207
  * Get default storage based on environment.
@@ -159,6 +248,7 @@ export class AuthManager {
159
248
  async handleAuthSuccess(session, method = 'credentials') {
160
249
  // Store tokens
161
250
  if (session.accessToken) {
251
+ this._lastKnownAccessToken = session.accessToken;
162
252
  await this.storage.setItem(STORAGE_KEYS.ACCESS_TOKEN, session.accessToken);
163
253
  this.oxyServices.httpService.setTokens(session.accessToken);
164
254
  }
@@ -223,6 +313,8 @@ export class AuthManager {
223
313
  }
224
314
  }
225
315
  async _doRefreshToken() {
316
+ // Reset the cross-tab flag before starting
317
+ this._otherTabRefreshed = false;
226
318
  // Get session info to find sessionId for token refresh
227
319
  const sessionJson = await this.storage.getItem(STORAGE_KEYS.SESSION);
228
320
  if (!sessionJson) {
@@ -239,8 +331,22 @@ export class AuthManager {
239
331
  console.error('AuthManager: Failed to parse session from storage.', err);
240
332
  return false;
241
333
  }
334
+ // Record the token we know about before attempting refresh
335
+ const tokenBeforeRefresh = this._lastKnownAccessToken;
336
+ // Broadcast that we're starting a refresh (informational for other tabs)
337
+ this._broadcast({ type: 'refresh_starting', sessionId, timestamp: Date.now() });
242
338
  try {
243
339
  await retryAsync(async () => {
340
+ // Before each attempt, check if another tab already refreshed
341
+ if (this._otherTabRefreshed) {
342
+ const adoptedToken = await this.storage.getItem(STORAGE_KEYS.ACCESS_TOKEN);
343
+ if (adoptedToken && adoptedToken !== tokenBeforeRefresh) {
344
+ // Another tab succeeded. Adopt its tokens and short-circuit.
345
+ this._lastKnownAccessToken = adoptedToken;
346
+ this.oxyServices.httpService.setTokens(adoptedToken);
347
+ return;
348
+ }
349
+ }
244
350
  const httpService = this.oxyServices.httpService;
245
351
  // Use session-based token endpoint which handles auto-refresh server-side
246
352
  const response = await httpService.request({
@@ -253,6 +359,7 @@ export class AuthManager {
253
359
  throw new Error('No access token in refresh response');
254
360
  }
255
361
  // Update access token in storage and HTTP client
362
+ this._lastKnownAccessToken = response.accessToken;
256
363
  await this.storage.setItem(STORAGE_KEYS.ACCESS_TOKEN, response.accessToken);
257
364
  this.oxyServices.httpService.setTokens(response.accessToken);
258
365
  // Update session expiry and schedule next refresh
@@ -270,6 +377,8 @@ export class AuthManager {
270
377
  this.setupTokenRefresh(response.expiresAt);
271
378
  }
272
379
  }
380
+ // Broadcast success so other tabs can adopt these tokens
381
+ this._broadcast({ type: 'tokens_refreshed', sessionId, timestamp: Date.now() });
273
382
  }, 2, // 2 retries = 3 total attempts
274
383
  1000, // 1s base delay with exponential backoff + jitter
275
384
  (error) => {
@@ -282,7 +391,41 @@ export class AuthManager {
282
391
  return true;
283
392
  }
284
393
  catch {
285
- // All retry attempts exhausted, clear session
394
+ // All retry attempts exhausted. Before clearing the session, check if
395
+ // another tab managed to refresh successfully while we were retrying.
396
+ // Since all tabs share the same storage (localStorage), a successful
397
+ // refresh from another tab will have written a different access token.
398
+ const currentStoredToken = await this.storage.getItem(STORAGE_KEYS.ACCESS_TOKEN);
399
+ if (currentStoredToken && currentStoredToken !== tokenBeforeRefresh) {
400
+ // Another tab refreshed successfully. Adopt its tokens instead of logging out.
401
+ this._lastKnownAccessToken = currentStoredToken;
402
+ this.oxyServices.httpService.setTokens(currentStoredToken);
403
+ // Restore user from storage in case it was updated
404
+ const userJson = await this.storage.getItem(STORAGE_KEYS.USER);
405
+ if (userJson) {
406
+ try {
407
+ this.currentUser = JSON.parse(userJson);
408
+ }
409
+ catch {
410
+ // Ignore parse errors
411
+ }
412
+ }
413
+ // Re-read session expiry and schedule next refresh
414
+ const updatedSessionJson = await this.storage.getItem(STORAGE_KEYS.SESSION);
415
+ if (updatedSessionJson) {
416
+ try {
417
+ const session = JSON.parse(updatedSessionJson);
418
+ if (session.expiresAt && this.config.autoRefresh) {
419
+ this.setupTokenRefresh(session.expiresAt);
420
+ }
421
+ }
422
+ catch {
423
+ // Ignore parse errors
424
+ }
425
+ }
426
+ return true;
427
+ }
428
+ // No other tab rescued us -- truly clear the session
286
429
  await this.clearSession();
287
430
  this.currentUser = null;
288
431
  this.notifyListeners();
@@ -324,8 +467,11 @@ export class AuthManager {
324
467
  }
325
468
  // Clear HTTP client tokens
326
469
  this.oxyServices.httpService.setTokens('');
470
+ this._lastKnownAccessToken = null;
327
471
  // Clear storage
328
472
  await this.clearSession();
473
+ // Notify other tabs so they also sign out
474
+ this._broadcast({ type: 'signed_out', timestamp: Date.now() });
329
475
  // Update state and notify
330
476
  this.currentUser = null;
331
477
  this.notifyListeners();
@@ -400,6 +546,7 @@ export class AuthManager {
400
546
  // Restore token to HTTP client
401
547
  const token = await this.storage.getItem(STORAGE_KEYS.ACCESS_TOKEN);
402
548
  if (token) {
549
+ this._lastKnownAccessToken = token;
403
550
  this.oxyServices.httpService.setTokens(token);
404
551
  }
405
552
  // Check session expiry
@@ -440,6 +587,16 @@ export class AuthManager {
440
587
  this.refreshTimer = null;
441
588
  }
442
589
  this.listeners.clear();
590
+ // Close BroadcastChannel
591
+ if (this._broadcastChannel) {
592
+ try {
593
+ this._broadcastChannel.close();
594
+ }
595
+ catch {
596
+ // Ignore close errors
597
+ }
598
+ this._broadcastChannel = null;
599
+ }
443
600
  }
444
601
  }
445
602
  /**
@@ -81,6 +81,8 @@ export class HttpService {
81
81
  this.tokenRefreshPromise = null;
82
82
  this.tokenRefreshCooldownUntil = 0;
83
83
  this._onTokenRefreshed = null;
84
+ // Acting-as identity for managed accounts
85
+ this._actingAsUserId = null;
84
86
  // Performance monitoring
85
87
  this.requestMetrics = {
86
88
  totalRequests: 0,
@@ -197,6 +199,10 @@ export class HttpService {
197
199
  hasNativeAppHeader: headers['X-Native-App'] === 'true',
198
200
  });
199
201
  }
202
+ // Add X-Acting-As header for managed account identity delegation
203
+ if (this._actingAsUserId) {
204
+ headers['X-Acting-As'] = this._actingAsUserId;
205
+ }
200
206
  // Merge custom headers if provided
201
207
  if (config.headers) {
202
208
  Object.entries(config.headers).forEach(([key, value]) => {
@@ -579,6 +585,13 @@ export class HttpService {
579
585
  async delete(url, config) {
580
586
  return this.request({ method: 'DELETE', url, ...config });
581
587
  }
588
+ // Acting-as identity management (managed accounts)
589
+ setActingAs(userId) {
590
+ this._actingAsUserId = userId;
591
+ }
592
+ getActingAs() {
593
+ return this._actingAsUserId;
594
+ }
582
595
  // Token management
583
596
  setTokens(accessToken, refreshToken = '') {
584
597
  this.tokenStore.setTokens(accessToken, refreshToken);
@@ -138,6 +138,27 @@ export class OxyServicesBase {
138
138
  getAccessToken() {
139
139
  return this.httpService.getAccessToken();
140
140
  }
141
+ /**
142
+ * Set the acting-as identity for managed accounts.
143
+ *
144
+ * When set, all subsequent API requests will include the `X-Acting-As` header,
145
+ * causing the server to attribute actions to the managed account. The
146
+ * authenticated user must be an authorized manager of the target account.
147
+ *
148
+ * Pass `null` to clear and revert to the authenticated user's own identity.
149
+ *
150
+ * @param userId - The managed account user ID, or null to clear
151
+ */
152
+ setActingAs(userId) {
153
+ this.httpService.setActingAs(userId);
154
+ }
155
+ /**
156
+ * Get the current acting-as identity (managed account user ID), or null
157
+ * if operating as the authenticated user's own identity.
158
+ */
159
+ getActingAs() {
160
+ return this.httpService.getActingAs();
161
+ }
141
162
  /**
142
163
  * Wait for authentication to be ready
143
164
  *
@@ -91,13 +91,11 @@ async function getSecureRandomBytes(length) {
91
91
  return Crypto.getRandomBytes(length);
92
92
  }
93
93
  // In Node.js, use Node's crypto module
94
- // Use Function constructor to prevent Metro bundler from statically analyzing this require
95
- // This ensures the require is only evaluated in Node.js runtime, not during Metro bundling
94
+ // Variable indirection prevents bundlers (Vite, webpack) from statically resolving this
96
95
  try {
97
- // eslint-disable-next-line @typescript-eslint/no-implied-eval
98
- const getCrypto = new Function('return require("crypto")');
99
- const crypto = getCrypto();
100
- return new Uint8Array(crypto.randomBytes(length));
96
+ const cryptoModuleName = 'crypto';
97
+ const nodeCrypto = await import(cryptoModuleName);
98
+ return new Uint8Array(nodeCrypto.randomBytes(length));
101
99
  }
102
100
  catch (error) {
103
101
  // Fallback to expo-crypto if Node crypto fails
@@ -27,27 +27,35 @@ if (!globalObject.Buffer) {
27
27
  }
28
28
  // Cache for expo-crypto module (lazy loaded only in React Native)
29
29
  let expoCryptoModule = null;
30
- let expoCryptoLoadAttempted = false;
31
- function getRandomBytesSync(byteCount) {
32
- if (!expoCryptoLoadAttempted) {
33
- expoCryptoLoadAttempted = true;
30
+ let expoCryptoLoadPromise = null;
31
+ /**
32
+ * Eagerly start loading expo-crypto. The module is cached once resolved so
33
+ * the synchronous getRandomValues shim can read from it immediately.
34
+ * Uses dynamic import with variable indirection to prevent ESM bundlers
35
+ * (Vite, webpack) from statically resolving the specifier.
36
+ */
37
+ function startExpoCryptoLoad() {
38
+ if (expoCryptoLoadPromise)
39
+ return;
40
+ expoCryptoLoadPromise = (async () => {
34
41
  try {
35
- // Only use require() in CJS environments (Metro/Node). In ESM (Vite/browser),
36
- // crypto.getRandomValues exists natively so this code path is never reached.
37
- if (typeof require !== 'undefined') {
38
- const moduleName = 'expo-crypto';
39
- expoCryptoModule = require(moduleName);
40
- }
42
+ const moduleName = 'expo-crypto';
43
+ expoCryptoModule = await import(moduleName);
41
44
  }
42
45
  catch {
43
46
  // expo-crypto not available — expected in non-RN environments
44
47
  }
45
- }
48
+ })();
49
+ }
50
+ function getRandomBytesSync(byteCount) {
51
+ // Kick off loading if not already started (should have been started at module init)
52
+ startExpoCryptoLoad();
46
53
  if (expoCryptoModule) {
47
54
  return expoCryptoModule.getRandomBytes(byteCount);
48
55
  }
49
56
  throw new Error('No crypto.getRandomValues implementation available. ' +
50
- 'In React Native, install expo-crypto.');
57
+ 'In React Native, install expo-crypto. ' +
58
+ 'If expo-crypto is installed, ensure the polyfill module is imported early enough for the async load to complete.');
51
59
  }
52
60
  const cryptoPolyfill = {
53
61
  getRandomValues(array) {
@@ -59,9 +67,12 @@ const cryptoPolyfill = {
59
67
  };
60
68
  // Only polyfill if crypto or crypto.getRandomValues is not available
61
69
  if (typeof globalObject.crypto === 'undefined') {
70
+ // Start loading expo-crypto eagerly so it is ready by the time getRandomValues is called
71
+ startExpoCryptoLoad();
62
72
  globalObject.crypto = cryptoPolyfill;
63
73
  }
64
74
  else if (typeof globalObject.crypto.getRandomValues !== 'function') {
75
+ startExpoCryptoLoad();
65
76
  globalObject.crypto.getRandomValues = cryptoPolyfill.getRandomValues;
66
77
  }
67
78
  export { Buffer };
@@ -63,11 +63,11 @@ export class SignatureService {
63
63
  .join('');
64
64
  }
65
65
  // In Node.js, use Node's crypto module
66
+ // Variable indirection prevents bundlers (Vite, webpack) from statically resolving this
66
67
  if (isNodeJS()) {
67
68
  try {
68
- // eslint-disable-next-line @typescript-eslint/no-implied-eval
69
- const getCrypto = new Function('return require("crypto")');
70
- const nodeCrypto = getCrypto();
69
+ const cryptoModuleName = 'crypto';
70
+ const nodeCrypto = await import(cryptoModuleName);
71
71
  return nodeCrypto.randomBytes(32).toString('hex');
72
72
  }
73
73
  catch {
@@ -134,7 +134,10 @@ export class SignatureService {
134
134
  // In React Native, use async verify instead
135
135
  throw new Error('verifySync should only be used in Node.js. Use verify() in React Native.');
136
136
  }
137
- // Use Function constructor to prevent Metro bundler from statically analyzing this require
137
+ // Intentionally using Function constructor here: this method is synchronous by design
138
+ // (Node.js backend hot-path) so we cannot use `await import()`. The Function constructor
139
+ // prevents Metro/bundlers from statically resolving the require. This is acceptable because
140
+ // verifySync is gated by isNodeJS() and will never execute in browser/RN environments.
138
141
  // eslint-disable-next-line @typescript-eslint/no-implied-eval
139
142
  const getCrypto = new Function('return require("crypto")');
140
143
  const crypto = getCrypto();
@@ -34,6 +34,11 @@ export function OxyServicesFedCMMixin(Base) {
34
34
  constructor(...args) {
35
35
  super(...args);
36
36
  }
37
+ resolveFedcmConfigUrl() {
38
+ return this.config.authWebUrl
39
+ ? `${this.config.authWebUrl}/fedcm.json`
40
+ : this.constructor.DEFAULT_CONFIG_URL;
41
+ }
37
42
  /**
38
43
  * Check if FedCM is supported in the current browser
39
44
  */
@@ -85,7 +90,7 @@ export function OxyServicesFedCMMixin(Base) {
85
90
  // Request credential from browser's native identity flow
86
91
  // mode: 'button' signals this is a user-gesture-initiated flow (Chrome 125+)
87
92
  const credential = await this.requestIdentityCredential({
88
- configURL: (this.config.authWebUrl ? `${this.config.authWebUrl}/fedcm.json` : this.constructor.DEFAULT_CONFIG_URL),
93
+ configURL: this.resolveFedcmConfigUrl(),
89
94
  clientId,
90
95
  nonce,
91
96
  context: options.context,
@@ -175,7 +180,7 @@ export function OxyServicesFedCMMixin(Base) {
175
180
  const nonce = this.generateNonce();
176
181
  debug.log('Silent SSO: Attempting silent mediation...', loginHint ? `(hint: ${loginHint})` : '');
177
182
  credential = await this.requestIdentityCredential({
178
- configURL: (this.config.authWebUrl ? `${this.config.authWebUrl}/fedcm.json` : this.constructor.DEFAULT_CONFIG_URL),
183
+ configURL: this.resolveFedcmConfigUrl(),
179
184
  clientId,
180
185
  nonce,
181
186
  loginHint,
@@ -389,7 +394,7 @@ export function OxyServicesFedCMMixin(Base) {
389
394
  if ('IdentityCredential' in window && 'disconnect' in window.IdentityCredential) {
390
395
  const clientId = this.getClientId();
391
396
  await window.IdentityCredential.disconnect({
392
- configURL: (this.config.authWebUrl ? `${this.config.authWebUrl}/fedcm.json` : this.constructor.DEFAULT_CONFIG_URL),
397
+ configURL: this.resolveFedcmConfigUrl(),
393
398
  clientId,
394
399
  accountHint: accountHint || '*',
395
400
  });
@@ -408,7 +413,7 @@ export function OxyServicesFedCMMixin(Base) {
408
413
  getFedCMConfig() {
409
414
  return {
410
415
  enabled: this.isFedCMSupported(),
411
- configURL: (this.config.authWebUrl ? `${this.config.authWebUrl}/fedcm.json` : this.constructor.DEFAULT_CONFIG_URL),
416
+ configURL: this.resolveFedcmConfigUrl(),
412
417
  clientId: this.getClientId(),
413
418
  };
414
419
  }
@@ -0,0 +1,114 @@
1
+ export function OxyServicesManagedAccountsMixin(Base) {
2
+ return class extends Base {
3
+ constructor(...args) {
4
+ super(...args);
5
+ }
6
+ /**
7
+ * Create a new managed account (sub-account).
8
+ *
9
+ * The server creates a User document with `isManagedAccount: true` and links
10
+ * it to the authenticated user as owner.
11
+ */
12
+ async createManagedAccount(data) {
13
+ try {
14
+ return await this.makeRequest('POST', '/managed-accounts', data, {
15
+ cache: false,
16
+ });
17
+ }
18
+ catch (error) {
19
+ throw this.handleError(error);
20
+ }
21
+ }
22
+ /**
23
+ * List all accounts the authenticated user manages.
24
+ */
25
+ async getManagedAccounts() {
26
+ try {
27
+ return await this.makeRequest('GET', '/managed-accounts', undefined, {
28
+ cache: true,
29
+ cacheTTL: 2 * 60 * 1000, // 2 minutes cache
30
+ });
31
+ }
32
+ catch (error) {
33
+ throw this.handleError(error);
34
+ }
35
+ }
36
+ /**
37
+ * Get details for a specific managed account.
38
+ */
39
+ async getManagedAccountDetails(accountId) {
40
+ try {
41
+ return await this.makeRequest('GET', `/managed-accounts/${accountId}`, undefined, {
42
+ cache: true,
43
+ cacheTTL: 2 * 60 * 1000,
44
+ });
45
+ }
46
+ catch (error) {
47
+ throw this.handleError(error);
48
+ }
49
+ }
50
+ /**
51
+ * Update a managed account's profile data.
52
+ * Requires owner or admin role.
53
+ */
54
+ async updateManagedAccount(accountId, data) {
55
+ try {
56
+ return await this.makeRequest('PUT', `/managed-accounts/${accountId}`, data, {
57
+ cache: false,
58
+ });
59
+ }
60
+ catch (error) {
61
+ throw this.handleError(error);
62
+ }
63
+ }
64
+ /**
65
+ * Delete a managed account permanently.
66
+ * Requires owner role.
67
+ */
68
+ async deleteManagedAccount(accountId) {
69
+ try {
70
+ await this.makeRequest('DELETE', `/managed-accounts/${accountId}`, undefined, {
71
+ cache: false,
72
+ });
73
+ }
74
+ catch (error) {
75
+ throw this.handleError(error);
76
+ }
77
+ }
78
+ /**
79
+ * Add a manager to a managed account.
80
+ * Requires owner or admin role on the account.
81
+ *
82
+ * @param accountId - The managed account to add the manager to
83
+ * @param userId - The user to grant management access
84
+ * @param role - The role to assign: 'admin' or 'editor'
85
+ */
86
+ async addManager(accountId, userId, role) {
87
+ try {
88
+ await this.makeRequest('POST', `/managed-accounts/${accountId}/managers`, { userId, role }, {
89
+ cache: false,
90
+ });
91
+ }
92
+ catch (error) {
93
+ throw this.handleError(error);
94
+ }
95
+ }
96
+ /**
97
+ * Remove a manager from a managed account.
98
+ * Requires owner role.
99
+ *
100
+ * @param accountId - The managed account
101
+ * @param userId - The manager to remove
102
+ */
103
+ async removeManager(accountId, userId) {
104
+ try {
105
+ await this.makeRequest('DELETE', `/managed-accounts/${accountId}/managers/${userId}`, undefined, {
106
+ cache: false,
107
+ });
108
+ }
109
+ catch (error) {
110
+ throw this.handleError(error);
111
+ }
112
+ }
113
+ };
114
+ }
@@ -29,6 +29,10 @@ export function OxyServicesPopupAuthMixin(Base) {
29
29
  constructor(...args) {
30
30
  super(...args);
31
31
  }
32
+ /** Resolve auth URL from config or static default (method, not getter — getters break in TS mixins) */
33
+ resolveAuthUrl() {
34
+ return this.config.authWebUrl || this.constructor.DEFAULT_AUTH_URL;
35
+ }
32
36
  /**
33
37
  * Sign in using popup window
34
38
  *
@@ -71,7 +75,7 @@ export function OxyServicesPopupAuthMixin(Base) {
71
75
  state,
72
76
  nonce,
73
77
  clientId: window.location.origin,
74
- redirectUri: `${(this.config.authWebUrl || this.constructor.DEFAULT_AUTH_URL)}/auth/callback`,
78
+ redirectUri: `${this.resolveAuthUrl()}/auth/callback`,
75
79
  });
76
80
  const popup = this.openCenteredPopup(authUrl, 'Oxy Sign In', width, height);
77
81
  if (!popup) {
@@ -159,7 +163,7 @@ export function OxyServicesPopupAuthMixin(Base) {
159
163
  iframe.style.width = '0';
160
164
  iframe.style.height = '0';
161
165
  iframe.style.border = 'none';
162
- const silentUrl = `${(this.config.authWebUrl || this.constructor.DEFAULT_AUTH_URL)}/auth/silent?` + `client_id=${encodeURIComponent(clientId)}&` + `nonce=${nonce}`;
166
+ const silentUrl = `${this.resolveAuthUrl()}/auth/silent?` + `client_id=${encodeURIComponent(clientId)}&` + `nonce=${nonce}`;
163
167
  iframe.src = silentUrl;
164
168
  document.body.appendChild(iframe);
165
169
  try {
@@ -210,7 +214,7 @@ export function OxyServicesPopupAuthMixin(Base) {
210
214
  reject(new OxyAuthenticationError('Authentication timeout'));
211
215
  }, timeout);
212
216
  const messageHandler = (event) => {
213
- const authUrl = (this.config.authWebUrl || this.constructor.DEFAULT_AUTH_URL);
217
+ const authUrl = this.resolveAuthUrl();
214
218
  // Log all messages for debugging
215
219
  if (event.data && typeof event.data === 'object' && event.data.type) {
216
220
  debug.log('Message received:', {
@@ -282,7 +286,7 @@ export function OxyServicesPopupAuthMixin(Base) {
282
286
  }, timeout);
283
287
  const messageHandler = (event) => {
284
288
  // Verify origin
285
- if (event.origin !== (this.config.authWebUrl || this.constructor.DEFAULT_AUTH_URL)) {
289
+ if (event.origin !== this.resolveAuthUrl()) {
286
290
  return;
287
291
  }
288
292
  const { type, session } = event.data;
@@ -305,7 +309,7 @@ export function OxyServicesPopupAuthMixin(Base) {
305
309
  * @private
306
310
  */
307
311
  buildAuthUrl(params) {
308
- const url = new URL(`${(this.config.authWebUrl || this.constructor.DEFAULT_AUTH_URL)}/${params.mode}`);
312
+ const url = new URL(`${this.resolveAuthUrl()}/${params.mode}`);
309
313
  url.searchParams.set('response_type', 'token');
310
314
  url.searchParams.set('client_id', params.clientId);
311
315
  url.searchParams.set('redirect_uri', params.redirectUri);