@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
@@ -48,6 +48,17 @@ export interface AuthManagerConfig {
48
48
  autoRefresh?: boolean;
49
49
  /** Token refresh interval in milliseconds (default: 5 minutes before expiry) */
50
50
  refreshBuffer?: number;
51
+ /** Enable cross-tab coordination via BroadcastChannel (default: true in browsers) */
52
+ crossTabSync?: boolean;
53
+ }
54
+
55
+ /**
56
+ * Messages sent between tabs via BroadcastChannel for token refresh coordination.
57
+ */
58
+ interface CrossTabMessage {
59
+ type: 'refresh_starting' | 'tokens_refreshed' | 'signed_out';
60
+ sessionId?: string;
61
+ timestamp: number;
51
62
  }
52
63
 
53
64
  /**
@@ -145,21 +156,117 @@ export class AuthManager {
145
156
  private currentUser: MinimalUserData | null = null;
146
157
  private refreshTimer: ReturnType<typeof setTimeout> | null = null;
147
158
  private refreshPromise: Promise<boolean> | null = null;
148
- private config: Required<AuthManagerConfig>;
159
+ private config: Required<Omit<AuthManagerConfig, 'crossTabSync'>> & { crossTabSync: boolean };
160
+
161
+ /** Tracks the access token this instance last knew about, for cross-tab adoption. */
162
+ private _lastKnownAccessToken: string | null = null;
163
+
164
+ /** BroadcastChannel for coordinating token refreshes across browser tabs. */
165
+ private _broadcastChannel: BroadcastChannel | null = null;
166
+
167
+ /** Set to true when another tab broadcasts a successful refresh, so this tab can skip its own. */
168
+ private _otherTabRefreshed = false;
149
169
 
150
170
  constructor(oxyServices: OxyServices, config: AuthManagerConfig = {}) {
151
171
  this.oxyServices = oxyServices;
172
+ const crossTabSync = config.crossTabSync ?? (typeof BroadcastChannel !== 'undefined');
152
173
  this.config = {
153
174
  storage: config.storage ?? this.getDefaultStorage(),
154
175
  autoRefresh: config.autoRefresh ?? true,
155
176
  refreshBuffer: config.refreshBuffer ?? 5 * 60 * 1000, // 5 minutes
177
+ crossTabSync,
156
178
  };
157
179
  this.storage = this.config.storage;
158
180
 
159
181
  // Persist tokens to storage when HttpService refreshes them automatically
160
182
  this.oxyServices.httpService.onTokenRefreshed = (accessToken: string) => {
183
+ this._lastKnownAccessToken = accessToken;
161
184
  this.storage.setItem(STORAGE_KEYS.ACCESS_TOKEN, accessToken);
162
185
  };
186
+
187
+ // Setup cross-tab coordination in browser environments
188
+ if (this.config.crossTabSync) {
189
+ this._initBroadcastChannel();
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Initialize BroadcastChannel for cross-tab token refresh coordination.
195
+ * Only called in browser environments where BroadcastChannel is available.
196
+ */
197
+ private _initBroadcastChannel(): void {
198
+ if (typeof BroadcastChannel === 'undefined') return;
199
+
200
+ try {
201
+ this._broadcastChannel = new BroadcastChannel('oxy_auth_sync');
202
+ this._broadcastChannel.onmessage = (event: MessageEvent<CrossTabMessage>) => {
203
+ this._handleCrossTabMessage(event.data);
204
+ };
205
+ } catch {
206
+ // BroadcastChannel not supported or blocked (e.g., opaque origins)
207
+ this._broadcastChannel = null;
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Handle messages from other tabs about token refresh activity.
213
+ */
214
+ private async _handleCrossTabMessage(message: CrossTabMessage): Promise<void> {
215
+ if (!message || !message.type) return;
216
+
217
+ switch (message.type) {
218
+ case 'tokens_refreshed': {
219
+ // Another tab successfully refreshed. Signal to cancel our pending refresh.
220
+ this._otherTabRefreshed = true;
221
+
222
+ // Adopt the new tokens from shared storage
223
+ const newToken = await this.storage.getItem(STORAGE_KEYS.ACCESS_TOKEN);
224
+ if (newToken && newToken !== this._lastKnownAccessToken) {
225
+ this._lastKnownAccessToken = newToken;
226
+ this.oxyServices.httpService.setTokens(newToken);
227
+
228
+ // Re-read session for updated expiry and schedule next refresh
229
+ const sessionJson = await this.storage.getItem(STORAGE_KEYS.SESSION);
230
+ if (sessionJson) {
231
+ try {
232
+ const session = JSON.parse(sessionJson);
233
+ if (session.expiresAt && this.config.autoRefresh) {
234
+ this.setupTokenRefresh(session.expiresAt);
235
+ }
236
+ } catch {
237
+ // Ignore parse errors
238
+ }
239
+ }
240
+ }
241
+ break;
242
+ }
243
+
244
+ case 'signed_out': {
245
+ // Another tab signed out. Clear our local state to stay consistent.
246
+ if (this.refreshTimer) {
247
+ clearTimeout(this.refreshTimer);
248
+ this.refreshTimer = null;
249
+ }
250
+ this.refreshPromise = null;
251
+ this._lastKnownAccessToken = null;
252
+ this.oxyServices.httpService.setTokens('');
253
+ this.currentUser = null;
254
+ this.notifyListeners();
255
+ break;
256
+ }
257
+ // 'refresh_starting' is informational; we don't need to act on it currently
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Broadcast a message to other tabs.
263
+ */
264
+ private _broadcast(message: CrossTabMessage): void {
265
+ try {
266
+ this._broadcastChannel?.postMessage(message);
267
+ } catch {
268
+ // Channel closed or unavailable
269
+ }
163
270
  }
164
271
 
165
272
  /**
@@ -212,6 +319,7 @@ export class AuthManager {
212
319
  ): Promise<void> {
213
320
  // Store tokens
214
321
  if (session.accessToken) {
322
+ this._lastKnownAccessToken = session.accessToken;
215
323
  await this.storage.setItem(STORAGE_KEYS.ACCESS_TOKEN, session.accessToken);
216
324
  this.oxyServices.httpService.setTokens(session.accessToken);
217
325
  }
@@ -287,6 +395,9 @@ export class AuthManager {
287
395
  }
288
396
 
289
397
  private async _doRefreshToken(): Promise<boolean> {
398
+ // Reset the cross-tab flag before starting
399
+ this._otherTabRefreshed = false;
400
+
290
401
  // Get session info to find sessionId for token refresh
291
402
  const sessionJson = await this.storage.getItem(STORAGE_KEYS.SESSION);
292
403
  if (!sessionJson) {
@@ -298,14 +409,31 @@ export class AuthManager {
298
409
  const session = JSON.parse(sessionJson);
299
410
  sessionId = session.sessionId;
300
411
  if (!sessionId) return false;
301
- } catch (err) {
412
+ } catch (err) {
302
413
  console.error('AuthManager: Failed to parse session from storage.', err);
303
414
  return false;
304
415
  }
305
416
 
417
+ // Record the token we know about before attempting refresh
418
+ const tokenBeforeRefresh = this._lastKnownAccessToken;
419
+
420
+ // Broadcast that we're starting a refresh (informational for other tabs)
421
+ this._broadcast({ type: 'refresh_starting', sessionId, timestamp: Date.now() });
422
+
306
423
  try {
307
424
  await retryAsync(
308
425
  async () => {
426
+ // Before each attempt, check if another tab already refreshed
427
+ if (this._otherTabRefreshed) {
428
+ const adoptedToken = await this.storage.getItem(STORAGE_KEYS.ACCESS_TOKEN);
429
+ if (adoptedToken && adoptedToken !== tokenBeforeRefresh) {
430
+ // Another tab succeeded. Adopt its tokens and short-circuit.
431
+ this._lastKnownAccessToken = adoptedToken;
432
+ this.oxyServices.httpService.setTokens(adoptedToken);
433
+ return;
434
+ }
435
+ }
436
+
309
437
  const httpService = this.oxyServices.httpService as HttpService;
310
438
  // Use session-based token endpoint which handles auto-refresh server-side
311
439
  const response = await httpService.request<{ accessToken: string; expiresAt: string }>({
@@ -320,6 +448,7 @@ export class AuthManager {
320
448
  }
321
449
 
322
450
  // Update access token in storage and HTTP client
451
+ this._lastKnownAccessToken = response.accessToken;
323
452
  await this.storage.setItem(STORAGE_KEYS.ACCESS_TOKEN, response.accessToken);
324
453
  this.oxyServices.httpService.setTokens(response.accessToken);
325
454
 
@@ -329,7 +458,7 @@ export class AuthManager {
329
458
  const session = JSON.parse(sessionJson);
330
459
  session.expiresAt = response.expiresAt;
331
460
  await this.storage.setItem(STORAGE_KEYS.SESSION, JSON.stringify(session));
332
- } catch (err) {
461
+ } catch (err) {
333
462
  // Ignore parse errors for session update, but log for debugging.
334
463
  console.error('AuthManager: Failed to re-save session after token refresh.', err);
335
464
  }
@@ -338,6 +467,9 @@ export class AuthManager {
338
467
  this.setupTokenRefresh(response.expiresAt);
339
468
  }
340
469
  }
470
+
471
+ // Broadcast success so other tabs can adopt these tokens
472
+ this._broadcast({ type: 'tokens_refreshed', sessionId, timestamp: Date.now() });
341
473
  },
342
474
  2, // 2 retries = 3 total attempts
343
475
  1000, // 1s base delay with exponential backoff + jitter
@@ -350,7 +482,42 @@ export class AuthManager {
350
482
  );
351
483
  return true;
352
484
  } catch {
353
- // All retry attempts exhausted, clear session
485
+ // All retry attempts exhausted. Before clearing the session, check if
486
+ // another tab managed to refresh successfully while we were retrying.
487
+ // Since all tabs share the same storage (localStorage), a successful
488
+ // refresh from another tab will have written a different access token.
489
+ const currentStoredToken = await this.storage.getItem(STORAGE_KEYS.ACCESS_TOKEN);
490
+ if (currentStoredToken && currentStoredToken !== tokenBeforeRefresh) {
491
+ // Another tab refreshed successfully. Adopt its tokens instead of logging out.
492
+ this._lastKnownAccessToken = currentStoredToken;
493
+ this.oxyServices.httpService.setTokens(currentStoredToken);
494
+
495
+ // Restore user from storage in case it was updated
496
+ const userJson = await this.storage.getItem(STORAGE_KEYS.USER);
497
+ if (userJson) {
498
+ try {
499
+ this.currentUser = JSON.parse(userJson);
500
+ } catch {
501
+ // Ignore parse errors
502
+ }
503
+ }
504
+
505
+ // Re-read session expiry and schedule next refresh
506
+ const updatedSessionJson = await this.storage.getItem(STORAGE_KEYS.SESSION);
507
+ if (updatedSessionJson) {
508
+ try {
509
+ const session = JSON.parse(updatedSessionJson);
510
+ if (session.expiresAt && this.config.autoRefresh) {
511
+ this.setupTokenRefresh(session.expiresAt);
512
+ }
513
+ } catch {
514
+ // Ignore parse errors
515
+ }
516
+ }
517
+ return true;
518
+ }
519
+
520
+ // No other tab rescued us -- truly clear the session
354
521
  await this.clearSession();
355
522
  this.currentUser = null;
356
523
  this.notifyListeners();
@@ -394,10 +561,14 @@ export class AuthManager {
394
561
 
395
562
  // Clear HTTP client tokens
396
563
  this.oxyServices.httpService.setTokens('');
564
+ this._lastKnownAccessToken = null;
397
565
 
398
566
  // Clear storage
399
567
  await this.clearSession();
400
568
 
569
+ // Notify other tabs so they also sign out
570
+ this._broadcast({ type: 'signed_out', timestamp: Date.now() });
571
+
401
572
  // Update state and notify
402
573
  this.currentUser = null;
403
574
  this.notifyListeners();
@@ -479,6 +650,7 @@ export class AuthManager {
479
650
  // Restore token to HTTP client
480
651
  const token = await this.storage.getItem(STORAGE_KEYS.ACCESS_TOKEN);
481
652
  if (token) {
653
+ this._lastKnownAccessToken = token;
482
654
  this.oxyServices.httpService.setTokens(token);
483
655
  }
484
656
 
@@ -520,6 +692,16 @@ export class AuthManager {
520
692
  this.refreshTimer = null;
521
693
  }
522
694
  this.listeners.clear();
695
+
696
+ // Close BroadcastChannel
697
+ if (this._broadcastChannel) {
698
+ try {
699
+ this._broadcastChannel.close();
700
+ } catch {
701
+ // Ignore close errors
702
+ }
703
+ this._broadcastChannel = null;
704
+ }
523
705
  }
524
706
  }
525
707
 
@@ -131,6 +131,9 @@ export class HttpService {
131
131
  private tokenRefreshCooldownUntil: number = 0;
132
132
  private _onTokenRefreshed: ((accessToken: string) => void) | null = null;
133
133
 
134
+ // Acting-as identity for managed accounts
135
+ private _actingAsUserId: string | null = null;
136
+
134
137
  // Performance monitoring
135
138
  private requestMetrics = {
136
139
  totalRequests: 0,
@@ -291,6 +294,11 @@ export class HttpService {
291
294
  });
292
295
  }
293
296
 
297
+ // Add X-Acting-As header for managed account identity delegation
298
+ if (this._actingAsUserId) {
299
+ headers['X-Acting-As'] = this._actingAsUserId;
300
+ }
301
+
294
302
  // Merge custom headers if provided
295
303
  if (config.headers) {
296
304
  Object.entries(config.headers).forEach(([key, value]) => {
@@ -706,6 +714,15 @@ export class HttpService {
706
714
  return this.request<T>({ method: 'DELETE', url, ...config });
707
715
  }
708
716
 
717
+ // Acting-as identity management (managed accounts)
718
+ setActingAs(userId: string | null): void {
719
+ this._actingAsUserId = userId;
720
+ }
721
+
722
+ getActingAs(): string | null {
723
+ return this._actingAsUserId;
724
+ }
725
+
709
726
  // Token management
710
727
  setTokens(accessToken: string, refreshToken = ''): void {
711
728
  this.tokenStore.setTokens(accessToken, refreshToken);
@@ -182,6 +182,29 @@ export class OxyServicesBase {
182
182
  return this.httpService.getAccessToken();
183
183
  }
184
184
 
185
+ /**
186
+ * Set the acting-as identity for managed accounts.
187
+ *
188
+ * When set, all subsequent API requests will include the `X-Acting-As` header,
189
+ * causing the server to attribute actions to the managed account. The
190
+ * authenticated user must be an authorized manager of the target account.
191
+ *
192
+ * Pass `null` to clear and revert to the authenticated user's own identity.
193
+ *
194
+ * @param userId - The managed account user ID, or null to clear
195
+ */
196
+ public setActingAs(userId: string | null): void {
197
+ this.httpService.setActingAs(userId);
198
+ }
199
+
200
+ /**
201
+ * Get the current acting-as identity (managed account user ID), or null
202
+ * if operating as the authenticated user's own identity.
203
+ */
204
+ public getActingAs(): string | null {
205
+ return this.httpService.getActingAs();
206
+ }
207
+
185
208
  /**
186
209
  * Wait for authentication to be ready
187
210
  *
@@ -102,13 +102,11 @@ async function getSecureRandomBytes(length: number): Promise<Uint8Array> {
102
102
  }
103
103
 
104
104
  // In Node.js, use Node's crypto module
105
- // Use Function constructor to prevent Metro bundler from statically analyzing this require
106
- // This ensures the require is only evaluated in Node.js runtime, not during Metro bundling
105
+ // Variable indirection prevents bundlers (Vite, webpack) from statically resolving this
107
106
  try {
108
- // eslint-disable-next-line @typescript-eslint/no-implied-eval
109
- const getCrypto = new Function('return require("crypto")');
110
- const crypto = getCrypto();
111
- return new Uint8Array(crypto.randomBytes(length));
107
+ const cryptoModuleName = 'crypto';
108
+ const nodeCrypto = await import(cryptoModuleName);
109
+ return new Uint8Array(nodeCrypto.randomBytes(length));
112
110
  } catch (error) {
113
111
  // Fallback to expo-crypto if Node crypto fails
114
112
  const Crypto = await initExpoCrypto();
@@ -31,28 +31,36 @@ type CryptoLike = {
31
31
 
32
32
  // Cache for expo-crypto module (lazy loaded only in React Native)
33
33
  let expoCryptoModule: { getRandomBytes: (count: number) => Uint8Array } | null = null;
34
- let expoCryptoLoadAttempted = false;
34
+ let expoCryptoLoadPromise: Promise<void> | null = null;
35
35
 
36
- function getRandomBytesSync(byteCount: number): Uint8Array {
37
- if (!expoCryptoLoadAttempted) {
38
- expoCryptoLoadAttempted = true;
36
+ /**
37
+ * Eagerly start loading expo-crypto. The module is cached once resolved so
38
+ * the synchronous getRandomValues shim can read from it immediately.
39
+ * Uses dynamic import with variable indirection to prevent ESM bundlers
40
+ * (Vite, webpack) from statically resolving the specifier.
41
+ */
42
+ function startExpoCryptoLoad(): void {
43
+ if (expoCryptoLoadPromise) return;
44
+ expoCryptoLoadPromise = (async () => {
39
45
  try {
40
- // Only use require() in CJS environments (Metro/Node). In ESM (Vite/browser),
41
- // crypto.getRandomValues exists natively so this code path is never reached.
42
- if (typeof require !== 'undefined') {
43
- const moduleName = 'expo-crypto';
44
- expoCryptoModule = require(moduleName);
45
- }
46
+ const moduleName = 'expo-crypto';
47
+ expoCryptoModule = await import(moduleName);
46
48
  } catch {
47
49
  // expo-crypto not available — expected in non-RN environments
48
50
  }
49
- }
51
+ })();
52
+ }
53
+
54
+ function getRandomBytesSync(byteCount: number): Uint8Array {
55
+ // Kick off loading if not already started (should have been started at module init)
56
+ startExpoCryptoLoad();
50
57
  if (expoCryptoModule) {
51
58
  return expoCryptoModule.getRandomBytes(byteCount);
52
59
  }
53
60
  throw new Error(
54
61
  'No crypto.getRandomValues implementation available. ' +
55
- 'In React Native, install expo-crypto.'
62
+ 'In React Native, install expo-crypto. ' +
63
+ 'If expo-crypto is installed, ensure the polyfill module is imported early enough for the async load to complete.'
56
64
  );
57
65
  }
58
66
 
@@ -67,8 +75,11 @@ const cryptoPolyfill: CryptoLike = {
67
75
 
68
76
  // Only polyfill if crypto or crypto.getRandomValues is not available
69
77
  if (typeof globalObject.crypto === 'undefined') {
78
+ // Start loading expo-crypto eagerly so it is ready by the time getRandomValues is called
79
+ startExpoCryptoLoad();
70
80
  (globalObject as unknown as { crypto: CryptoLike }).crypto = cryptoPolyfill;
71
81
  } else if (typeof globalObject.crypto.getRandomValues !== 'function') {
82
+ startExpoCryptoLoad();
72
83
  (globalObject.crypto as CryptoLike).getRandomValues = cryptoPolyfill.getRandomValues;
73
84
  }
74
85
 
@@ -86,11 +86,11 @@ export class SignatureService {
86
86
  }
87
87
 
88
88
  // In Node.js, use Node's crypto module
89
+ // Variable indirection prevents bundlers (Vite, webpack) from statically resolving this
89
90
  if (isNodeJS()) {
90
91
  try {
91
- // eslint-disable-next-line @typescript-eslint/no-implied-eval
92
- const getCrypto = new Function('return require("crypto")');
93
- const nodeCrypto = getCrypto();
92
+ const cryptoModuleName = 'crypto';
93
+ const nodeCrypto = await import(cryptoModuleName);
94
94
  return nodeCrypto.randomBytes(32).toString('hex');
95
95
  } catch {
96
96
  // Fall through to Web Crypto API
@@ -162,7 +162,10 @@ export class SignatureService {
162
162
  // In React Native, use async verify instead
163
163
  throw new Error('verifySync should only be used in Node.js. Use verify() in React Native.');
164
164
  }
165
- // Use Function constructor to prevent Metro bundler from statically analyzing this require
165
+ // Intentionally using Function constructor here: this method is synchronous by design
166
+ // (Node.js backend hot-path) so we cannot use `await import()`. The Function constructor
167
+ // prevents Metro/bundlers from statically resolving the require. This is acceptable because
168
+ // verifySync is gated by isNodeJS() and will never execute in browser/RN environments.
166
169
  // eslint-disable-next-line @typescript-eslint/no-implied-eval
167
170
  const getCrypto = new Function('return require("crypto")');
168
171
  const crypto = getCrypto();
package/src/index.ts CHANGED
@@ -32,6 +32,7 @@ export type { PopupAuthOptions } from './mixins/OxyServices.popup';
32
32
  export type { RedirectAuthOptions } from './mixins/OxyServices.redirect';
33
33
  export type { ServiceTokenResponse } from './mixins/OxyServices.auth';
34
34
  export type { ServiceApp } from './mixins/OxyServices.utility';
35
+ export type { CreateManagedAccountInput, ManagedAccountManager, ManagedAccount } from './mixins/OxyServices.managedAccounts';
35
36
 
36
37
  // --- Crypto / Identity ---
37
38
  export { KeyManager, SignatureService, RecoveryPhraseService } from './crypto';
@@ -52,6 +52,13 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
52
52
  super(...(args as [any]));
53
53
  }
54
54
  public static readonly DEFAULT_CONFIG_URL = 'https://auth.oxy.so/fedcm.json';
55
+
56
+ public resolveFedcmConfigUrl(): string {
57
+ return this.config.authWebUrl
58
+ ? `${this.config.authWebUrl}/fedcm.json`
59
+ : (this.constructor as any).DEFAULT_CONFIG_URL;
60
+ }
61
+
55
62
  public static readonly FEDCM_TIMEOUT = 15000; // 15 seconds for interactive
56
63
  public static readonly FEDCM_SILENT_TIMEOUT = 3000; // 3 seconds for silent mediation
57
64
 
@@ -113,7 +120,7 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
113
120
  // Request credential from browser's native identity flow
114
121
  // mode: 'button' signals this is a user-gesture-initiated flow (Chrome 125+)
115
122
  const credential = await this.requestIdentityCredential({
116
- configURL: (this.config.authWebUrl ? `${this.config.authWebUrl}/fedcm.json` : (this.constructor as any).DEFAULT_CONFIG_URL),
123
+ configURL: this.resolveFedcmConfigUrl(),
117
124
  clientId,
118
125
  nonce,
119
126
  context: options.context,
@@ -216,7 +223,7 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
216
223
  debug.log('Silent SSO: Attempting silent mediation...', loginHint ? `(hint: ${loginHint})` : '');
217
224
 
218
225
  credential = await this.requestIdentityCredential({
219
- configURL: (this.config.authWebUrl ? `${this.config.authWebUrl}/fedcm.json` : (this.constructor as any).DEFAULT_CONFIG_URL),
226
+ configURL: this.resolveFedcmConfigUrl(),
220
227
  clientId,
221
228
  nonce,
222
229
  loginHint,
@@ -461,7 +468,7 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
461
468
  if ('IdentityCredential' in window && 'disconnect' in (window as any).IdentityCredential) {
462
469
  const clientId = this.getClientId();
463
470
  await (window as any).IdentityCredential.disconnect({
464
- configURL: (this.config.authWebUrl ? `${this.config.authWebUrl}/fedcm.json` : (this.constructor as any).DEFAULT_CONFIG_URL),
471
+ configURL: this.resolveFedcmConfigUrl(),
465
472
  clientId,
466
473
  accountHint: accountHint || '*',
467
474
  });
@@ -480,7 +487,7 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
480
487
  getFedCMConfig(): FedCMConfig {
481
488
  return {
482
489
  enabled: this.isFedCMSupported(),
483
- configURL: (this.config.authWebUrl ? `${this.config.authWebUrl}/fedcm.json` : (this.constructor as any).DEFAULT_CONFIG_URL),
490
+ configURL: this.resolveFedcmConfigUrl(),
484
491
  clientId: this.getClientId(),
485
492
  };
486
493
  }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Managed Accounts Methods Mixin
3
+ *
4
+ * Provides SDK methods for creating and managing sub-accounts (managed identities).
5
+ * Managed accounts are full User documents without passwords, accessible only
6
+ * by their owners/managers via the X-Acting-As header mechanism.
7
+ */
8
+ import type { User } from '../models/interfaces';
9
+ import type { OxyServicesBase } from '../OxyServices.base';
10
+
11
+ export interface CreateManagedAccountInput {
12
+ username: string;
13
+ name?: { first?: string; last?: string };
14
+ bio?: string;
15
+ avatar?: string;
16
+ }
17
+
18
+ export interface ManagedAccountManager {
19
+ userId: string;
20
+ role: 'owner' | 'admin' | 'editor';
21
+ addedAt: string;
22
+ addedBy?: string;
23
+ }
24
+
25
+ export interface ManagedAccount {
26
+ accountId: string;
27
+ ownerId: string;
28
+ managers: ManagedAccountManager[];
29
+ account?: User;
30
+ createdAt?: string;
31
+ updatedAt?: string;
32
+ }
33
+
34
+ export function OxyServicesManagedAccountsMixin<T extends typeof OxyServicesBase>(Base: T) {
35
+ return class extends Base {
36
+ constructor(...args: any[]) {
37
+ super(...(args as [any]));
38
+ }
39
+
40
+ /**
41
+ * Create a new managed account (sub-account).
42
+ *
43
+ * The server creates a User document with `isManagedAccount: true` and links
44
+ * it to the authenticated user as owner.
45
+ */
46
+ async createManagedAccount(data: CreateManagedAccountInput): Promise<ManagedAccount> {
47
+ try {
48
+ return await this.makeRequest<ManagedAccount>('POST', '/managed-accounts', data, {
49
+ cache: false,
50
+ });
51
+ } catch (error) {
52
+ throw this.handleError(error);
53
+ }
54
+ }
55
+
56
+ /**
57
+ * List all accounts the authenticated user manages.
58
+ */
59
+ async getManagedAccounts(): Promise<ManagedAccount[]> {
60
+ try {
61
+ return await this.makeRequest<ManagedAccount[]>('GET', '/managed-accounts', undefined, {
62
+ cache: true,
63
+ cacheTTL: 2 * 60 * 1000, // 2 minutes cache
64
+ });
65
+ } catch (error) {
66
+ throw this.handleError(error);
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Get details for a specific managed account.
72
+ */
73
+ async getManagedAccountDetails(accountId: string): Promise<ManagedAccount> {
74
+ try {
75
+ return await this.makeRequest<ManagedAccount>('GET', `/managed-accounts/${accountId}`, undefined, {
76
+ cache: true,
77
+ cacheTTL: 2 * 60 * 1000,
78
+ });
79
+ } catch (error) {
80
+ throw this.handleError(error);
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Update a managed account's profile data.
86
+ * Requires owner or admin role.
87
+ */
88
+ async updateManagedAccount(accountId: string, data: Partial<CreateManagedAccountInput>): Promise<ManagedAccount> {
89
+ try {
90
+ return await this.makeRequest<ManagedAccount>('PUT', `/managed-accounts/${accountId}`, data, {
91
+ cache: false,
92
+ });
93
+ } catch (error) {
94
+ throw this.handleError(error);
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Delete a managed account permanently.
100
+ * Requires owner role.
101
+ */
102
+ async deleteManagedAccount(accountId: string): Promise<void> {
103
+ try {
104
+ await this.makeRequest<void>('DELETE', `/managed-accounts/${accountId}`, undefined, {
105
+ cache: false,
106
+ });
107
+ } catch (error) {
108
+ throw this.handleError(error);
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Add a manager to a managed account.
114
+ * Requires owner or admin role on the account.
115
+ *
116
+ * @param accountId - The managed account to add the manager to
117
+ * @param userId - The user to grant management access
118
+ * @param role - The role to assign: 'admin' or 'editor'
119
+ */
120
+ async addManager(accountId: string, userId: string, role: 'admin' | 'editor'): Promise<void> {
121
+ try {
122
+ await this.makeRequest<void>('POST', `/managed-accounts/${accountId}/managers`, { userId, role }, {
123
+ cache: false,
124
+ });
125
+ } catch (error) {
126
+ throw this.handleError(error);
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Remove a manager from a managed account.
132
+ * Requires owner role.
133
+ *
134
+ * @param accountId - The managed account
135
+ * @param userId - The manager to remove
136
+ */
137
+ async removeManager(accountId: string, userId: string): Promise<void> {
138
+ try {
139
+ await this.makeRequest<void>('DELETE', `/managed-accounts/${accountId}/managers/${userId}`, undefined, {
140
+ cache: false,
141
+ });
142
+ } catch (error) {
143
+ throw this.handleError(error);
144
+ }
145
+ }
146
+ };
147
+ }