@explorins/pers-sdk 2.1.32 → 2.1.33

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 (70) hide show
  1. package/dist/analytics/api/analytics-api.d.ts +42 -1
  2. package/dist/analytics/api/analytics-api.d.ts.map +1 -1
  3. package/dist/analytics/models/index.d.ts +2 -2
  4. package/dist/analytics/models/index.d.ts.map +1 -1
  5. package/dist/analytics/services/analytics-service.d.ts +8 -1
  6. package/dist/analytics/services/analytics-service.d.ts.map +1 -1
  7. package/dist/analytics.cjs +1 -1
  8. package/dist/analytics.js +1 -1
  9. package/dist/chunks/{analytics-service-vm7B7LhS.js → analytics-service-CF7hSwhy.js} +53 -1
  10. package/dist/chunks/analytics-service-CF7hSwhy.js.map +1 -0
  11. package/dist/chunks/{analytics-service-CF9AsMQH.cjs → analytics-service-CRs9cpkg.cjs} +53 -1
  12. package/dist/chunks/analytics-service-CRs9cpkg.cjs.map +1 -0
  13. package/dist/chunks/{index-CVuttuU8.cjs → index-B_n8XiMt.cjs} +36 -11
  14. package/dist/chunks/index-B_n8XiMt.cjs.map +1 -0
  15. package/dist/chunks/{index-8y63MFOX.js → index-CgbzGLYT.js} +35 -12
  16. package/dist/chunks/index-CgbzGLYT.js.map +1 -0
  17. package/dist/chunks/{pers-sdk-DLgR_7OQ.js → pers-sdk-CySmOdzr.js} +398 -45
  18. package/dist/chunks/pers-sdk-CySmOdzr.js.map +1 -0
  19. package/dist/chunks/{pers-sdk-CDj1sZaP.cjs → pers-sdk-DgsYyo1X.cjs} +399 -44
  20. package/dist/chunks/pers-sdk-DgsYyo1X.cjs.map +1 -0
  21. package/dist/chunks/{tenant-manager-D9ihQLhf.js → tenant-manager-BQP25Alv.js} +2 -2
  22. package/dist/chunks/{tenant-manager-D9ihQLhf.js.map → tenant-manager-BQP25Alv.js.map} +1 -1
  23. package/dist/chunks/{tenant-manager-BdJYwIgL.cjs → tenant-manager-DeEuSlk7.cjs} +2 -2
  24. package/dist/chunks/{tenant-manager-BdJYwIgL.cjs.map → tenant-manager-DeEuSlk7.cjs.map} +1 -1
  25. package/dist/chunks/{web3-chain-service-DRoykR1u.js → web3-chain-service-BLrjcBTb.js} +2 -2
  26. package/dist/chunks/{web3-chain-service-DRoykR1u.js.map → web3-chain-service-BLrjcBTb.js.map} +1 -1
  27. package/dist/chunks/{web3-chain-service-CSxlvjMg.cjs → web3-chain-service-Bilgr4M8.cjs} +2 -2
  28. package/dist/chunks/{web3-chain-service-CSxlvjMg.cjs.map → web3-chain-service-Bilgr4M8.cjs.map} +1 -1
  29. package/dist/chunks/{web3-manager-NMLZ3pu7.js → web3-manager-BSvr7wJ7.js} +4 -4
  30. package/dist/chunks/{web3-manager-NMLZ3pu7.js.map → web3-manager-BSvr7wJ7.js.map} +1 -1
  31. package/dist/chunks/{web3-manager-DKHJrBYE.cjs → web3-manager-DbfqhJF4.cjs} +4 -4
  32. package/dist/chunks/{web3-manager-DKHJrBYE.cjs.map → web3-manager-DbfqhJF4.cjs.map} +1 -1
  33. package/dist/core/auth/default-auth-provider.d.ts +31 -0
  34. package/dist/core/auth/default-auth-provider.d.ts.map +1 -1
  35. package/dist/core/auth/dpop/dpop-manager.d.ts +12 -0
  36. package/dist/core/auth/dpop/dpop-manager.d.ts.map +1 -1
  37. package/dist/core/auth/indexed-db-storage.d.ts.map +1 -1
  38. package/dist/core/auth/refresh-manager.d.ts +34 -2
  39. package/dist/core/auth/refresh-manager.d.ts.map +1 -1
  40. package/dist/core/auth/services/auth-service.d.ts +54 -2
  41. package/dist/core/auth/services/auth-service.d.ts.map +1 -1
  42. package/dist/core/auth/token-storage.d.ts +6 -0
  43. package/dist/core/auth/token-storage.d.ts.map +1 -1
  44. package/dist/core/pers-api-client.d.ts.map +1 -1
  45. package/dist/core/utils/jwt.function.d.ts +23 -0
  46. package/dist/core/utils/jwt.function.d.ts.map +1 -1
  47. package/dist/core.cjs +6 -4
  48. package/dist/core.cjs.map +1 -1
  49. package/dist/core.js +4 -4
  50. package/dist/index.cjs +6 -4
  51. package/dist/index.cjs.map +1 -1
  52. package/dist/index.js +4 -4
  53. package/dist/managers/analytics-manager.d.ts +48 -1
  54. package/dist/managers/analytics-manager.d.ts.map +1 -1
  55. package/dist/node.cjs +4 -4
  56. package/dist/node.js +4 -4
  57. package/dist/package.json +5 -2
  58. package/dist/web3-chain.cjs +2 -2
  59. package/dist/web3-chain.js +2 -2
  60. package/dist/web3-manager.cjs +4 -4
  61. package/dist/web3-manager.js +4 -4
  62. package/dist/web3.cjs +4 -4
  63. package/dist/web3.js +4 -4
  64. package/package.json +5 -2
  65. package/dist/chunks/analytics-service-CF9AsMQH.cjs.map +0 -1
  66. package/dist/chunks/analytics-service-vm7B7LhS.js.map +0 -1
  67. package/dist/chunks/index-8y63MFOX.js.map +0 -1
  68. package/dist/chunks/index-CVuttuU8.cjs.map +0 -1
  69. package/dist/chunks/pers-sdk-CDj1sZaP.cjs.map +0 -1
  70. package/dist/chunks/pers-sdk-DLgR_7OQ.js.map +0 -1
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  var persShared = require('@explorins/pers-shared');
4
- var index = require('./index-CVuttuU8.cjs');
4
+ var index = require('./index-B_n8XiMt.cjs');
5
5
  var user = require('../user.cjs');
6
6
  var userStatus = require('../user-status.cjs');
7
7
  var tokenService = require('./token-service-C1xe11OX.cjs');
@@ -10,9 +10,9 @@ var campaign = require('../campaign.cjs');
10
10
  var redemptionService = require('./redemption-service-CVD2PzBO.cjs');
11
11
  var transactionRequest_builder = require('./transaction-request.builder-D8pIzjYD.cjs');
12
12
  var paymentService = require('./payment-service-Bkw7ZXev.cjs');
13
- var tenantManager = require('./tenant-manager-BdJYwIgL.cjs');
13
+ var tenantManager = require('./tenant-manager-DeEuSlk7.cjs');
14
14
  var paginationUtils = require('./pagination-utils-B2jRHMSO.cjs');
15
- var analyticsService = require('./analytics-service-CF9AsMQH.cjs');
15
+ var analyticsService = require('./analytics-service-CRs9cpkg.cjs');
16
16
  var donation = require('../donation.cjs');
17
17
  var triggerSource = require('../trigger-source.cjs');
18
18
 
@@ -239,6 +239,8 @@ function isExtendedProvider(provider) {
239
239
  * TOKEN_EXPIRED is NOT in this list - it's the normal "please refresh" case.
240
240
  *
241
241
  * Values validated against CommonErrorCodes from @explorins/pers-shared at compile time.
242
+ *
243
+ * SINGLE SOURCE OF TRUTH - use this constant everywhere, never duplicate these values.
242
244
  */
243
245
  const FATAL_AUTH_CODES = [
244
246
  'REFRESH_TOKEN_EXPIRED',
@@ -246,6 +248,13 @@ const FATAL_AUTH_CODES = [
246
248
  'INVALID_TOKEN',
247
249
  'TOKEN_REVOKED'
248
250
  ];
251
+ /**
252
+ * Check if an error message contains any fatal auth error code.
253
+ * Useful for detecting fatal errors in wrapped/stringified errors.
254
+ */
255
+ function isFatalAuthErrorInMessage(message) {
256
+ return FATAL_AUTH_CODES.some(code => message.includes(code));
257
+ }
249
258
  /**
250
259
  * Platform-agnostic authentication service
251
260
  * Handles login, token refresh, and storage operations
@@ -401,8 +410,53 @@ class AuthService {
401
410
  return FATAL_AUTH_CODES.includes(errorCode);
402
411
  }
403
412
  /**
404
- * Handle authentication failure with proper cleanup.
405
- * Called when backend returns fatal auth error codes or refresh fails.
413
+ * Comprehensive check if an error represents a DEFINITIVE auth failure.
414
+ * This is the SINGLE SOURCE OF TRUTH for determining if logout is required.
415
+ *
416
+ * Returns true for:
417
+ * - Known fatal error codes (REFRESH_TOKEN_EXPIRED, TOKEN_REVOKED, etc.)
418
+ * - Fatal error codes in error messages (wrapped errors)
419
+ * - 401/403 HTTP status codes
420
+ *
421
+ * Returns false for (retryable errors):
422
+ * - Network errors
423
+ * - Timeouts
424
+ * - 5xx server errors
425
+ * - TOKEN_EXPIRED (normal refresh trigger)
426
+ */
427
+ isDefinitiveAuthFailure(error) {
428
+ if (!error)
429
+ return false;
430
+ const errorAny = error;
431
+ // Check error code property against canonical list
432
+ if (errorAny?.code && this.isFatalAuthError(errorAny.code)) {
433
+ return true;
434
+ }
435
+ // Check if error message contains fatal keywords (fallback for wrapped errors)
436
+ const errorMessage = error instanceof Error ? error.message : String(error);
437
+ if (isFatalAuthErrorInMessage(errorMessage)) {
438
+ return true;
439
+ }
440
+ // Check HTTP status if available
441
+ const status = errorAny?.status || errorAny?.response?.status;
442
+ if (status === 401 || status === 403) {
443
+ return true;
444
+ }
445
+ // Everything else is considered retryable
446
+ return false;
447
+ }
448
+ /**
449
+ * Handle authentication failure with provider token recovery.
450
+ *
451
+ * Before logging out, attempts to recover the session using the stored
452
+ * provider token (Firebase JWT, etc.) if it's still valid.
453
+ *
454
+ * Recovery Flow:
455
+ * 1. Check if provider token exists and is not expired
456
+ * 2. Get the auth type (USER, BUSINESS, TENANT) to know which login to use
457
+ * 3. Attempt re-authentication with provider token
458
+ * 4. If successful: Session recovered, stay logged in
459
+ * 5. If failed: Clear tokens and emit AUTH_FAILED
406
460
  */
407
461
  async handleAuthFailure() {
408
462
  // Already failed - nothing to do
@@ -418,7 +472,7 @@ class AuthService {
418
472
  return await this.activeAuthFailurePromise;
419
473
  }
420
474
  // Create the failure promise to deduplicate concurrent calls
421
- this.activeAuthFailurePromise = this.performAuthFailure();
475
+ this.activeAuthFailurePromise = this.performAuthFailureWithRecovery();
422
476
  try {
423
477
  await this.activeAuthFailurePromise;
424
478
  }
@@ -426,6 +480,72 @@ class AuthService {
426
480
  this.activeAuthFailurePromise = null;
427
481
  }
428
482
  }
483
+ /**
484
+ * Attempts to recover session using provider token before failing.
485
+ */
486
+ async performAuthFailureWithRecovery() {
487
+ // Clear active refresh operations
488
+ this.activeRefreshPromise = null;
489
+ // Try to recover using provider token
490
+ const recovered = await this.attemptProviderTokenRecovery();
491
+ if (recovered) {
492
+ return; // Session recovered, don't fail
493
+ }
494
+ // Recovery failed - perform actual logout
495
+ await this.performAuthFailure();
496
+ }
497
+ /**
498
+ * Attempt to recover session using stored provider token.
499
+ * Extracts context (tenantId/businessId) from the current refresh token to ensure
500
+ * the recovered session maintains the same context.
501
+ *
502
+ * @returns true if session was successfully recovered
503
+ */
504
+ async attemptProviderTokenRecovery() {
505
+ const extended = this.extendedProvider;
506
+ if (!extended?.getProviderToken || !extended?.getAuthType) {
507
+ return false; // Provider doesn't support recovery
508
+ }
509
+ try {
510
+ const providerToken = await extended.getProviderToken();
511
+ if (!providerToken) {
512
+ return false;
513
+ }
514
+ // Check if provider token is still valid (with small margin)
515
+ if (index.isTokenExpired(providerToken, 30)) {
516
+ return false;
517
+ }
518
+ const authType = await extended.getAuthType();
519
+ if (!authType) {
520
+ return false;
521
+ }
522
+ // Extract context (tenantId/businessId) from the current refresh token
523
+ // This ensures multi-tenant/multi-business users recover to the same context
524
+ const refreshToken = await this.authProvider?.getRefreshToken?.();
525
+ const contextClaims = refreshToken ? index.decodeJwtPayload(refreshToken) : null;
526
+ // Re-authenticate using provider token based on auth type
527
+ // Pass the context from the previous session to avoid MULTIPLE_CONTEXT_SELECTION_REQUIRED
528
+ switch (authType) {
529
+ case persShared.AccountOwnerType.USER:
530
+ await this.loginUser(providerToken);
531
+ break;
532
+ case persShared.AccountOwnerType.BUSINESS:
533
+ await this.loginBusiness(providerToken, contextClaims?.businessId ? { businessId: contextClaims.businessId } : undefined);
534
+ break;
535
+ case persShared.AccountOwnerType.TENANT:
536
+ await this.loginTenantAdmin(providerToken, contextClaims?.tenantId ? { tenantId: contextClaims.tenantId } : undefined);
537
+ break;
538
+ default:
539
+ console.warn(`[AuthService] Unknown auth type for recovery: ${authType}`);
540
+ return false;
541
+ }
542
+ return true; // Recovery successful
543
+ }
544
+ catch (error) {
545
+ console.warn('[AuthService] Provider token recovery failed:', error);
546
+ return false;
547
+ }
548
+ }
429
549
  /**
430
550
  * Performs the actual auth failure cleanup
431
551
  */
@@ -505,16 +625,32 @@ class AuthService {
505
625
 
506
626
  /**
507
627
  * Token Refresh Manager
508
- * Simple container for token refresh logic with better organization
628
+ * Industry-standard token lifecycle management with:
629
+ * - Dynamic refresh margin based on token lifetime
630
+ * - Retry logic for transient failures
631
+ * - Only logout on definitive auth failures (not network errors)
509
632
  */
510
633
  class TokenRefreshManager {
511
634
  constructor(authService, authProvider) {
512
635
  this.authService = authService;
513
636
  this.authProvider = authProvider;
514
- this.tokenRefreshMarginSeconds = 120; // 2 minutes
637
+ // Industry standard: refresh when 25% of token lifetime remains
638
+ this.REFRESH_THRESHOLD_PERCENT = 0.25;
639
+ // Minimum margin regardless of token lifetime
640
+ this.MIN_REFRESH_MARGIN_SECONDS = 30;
641
+ // Maximum margin (cap for very long-lived tokens)
642
+ this.MAX_REFRESH_MARGIN_SECONDS = 300; // 5 minutes
643
+ // Validation cache to avoid redundant checks
515
644
  this.lastValidationTime = 0;
516
645
  this.validationCacheDurationMs = 30000; // 30 seconds
646
+ // Retry configuration for transient failures
647
+ this.MAX_RETRY_ATTEMPTS = 3;
648
+ this.RETRY_DELAY_MS = 1000;
517
649
  }
650
+ /**
651
+ * Ensures the access token is valid, refreshing if needed.
652
+ * Uses dynamic margin based on token lifetime (industry standard).
653
+ */
518
654
  async ensureValidToken() {
519
655
  const now = Date.now();
520
656
  // Skip validation if we checked recently (within cache duration)
@@ -526,35 +662,95 @@ class TokenRefreshManager {
526
662
  if (!token) {
527
663
  return;
528
664
  }
529
- if (index.isTokenExpired(token, this.tokenRefreshMarginSeconds)) {
530
- const refreshSuccess = await this.attemptInternalRefresh();
531
- if (!refreshSuccess) {
665
+ // Calculate dynamic refresh margin based on token lifetime
666
+ const marginSeconds = this.calculateRefreshMargin(token);
667
+ if (index.isTokenExpired(token, marginSeconds)) {
668
+ const result = await this.attemptRefreshWithRetry();
669
+ if (!result.success) {
670
+ if (result.retryable) {
671
+ // Transient error - don't logout, let next request retry
672
+ console.warn('[TokenRefreshManager] Refresh failed with retryable error, will retry on next request');
673
+ return;
674
+ }
675
+ // Definitive auth failure - logout required
532
676
  await this.authService.handleAuthFailure();
533
677
  }
534
678
  else {
535
- // Update validation time on successful refresh
679
+ // Refresh successful
536
680
  this.lastValidationTime = now;
537
681
  }
538
682
  }
539
683
  else {
540
- // Token is valid, update validation time
684
+ // Token is valid
541
685
  this.lastValidationTime = now;
542
686
  }
543
687
  }
544
688
  catch (error) {
545
- await this.authService.handleAuthFailure();
689
+ // Unexpected error - log but don't logout (conservative approach)
690
+ console.error('[TokenRefreshManager] Unexpected error during token validation:', error);
546
691
  }
547
692
  }
548
- async attemptInternalRefresh() {
549
- try {
550
- await this.authService.refreshAccessToken();
551
- return true;
693
+ /**
694
+ * Calculate refresh margin dynamically based on token lifetime.
695
+ * Industry standard: refresh when 25% of lifetime remains.
696
+ */
697
+ calculateRefreshMargin(token) {
698
+ const ttlSeconds = index.getTokenTimeToLive(token);
699
+ if (ttlSeconds === null || ttlSeconds <= 0) {
700
+ // Can't determine TTL, use minimum margin
701
+ return this.MIN_REFRESH_MARGIN_SECONDS;
552
702
  }
553
- catch (error) {
554
- // Don't call handleAuthFailure here - let the caller decide
555
- // This prevents duplicate auth failure handling in race conditions
556
- return false;
703
+ // Calculate 25% of remaining lifetime
704
+ const dynamicMargin = Math.floor(ttlSeconds * this.REFRESH_THRESHOLD_PERCENT);
705
+ // Clamp between min and max
706
+ return Math.max(this.MIN_REFRESH_MARGIN_SECONDS, Math.min(dynamicMargin, this.MAX_REFRESH_MARGIN_SECONDS));
707
+ }
708
+ /**
709
+ * Attempt token refresh with retry logic for transient failures.
710
+ * Only gives up on definitive auth errors.
711
+ */
712
+ async attemptRefreshWithRetry() {
713
+ let lastError;
714
+ for (let attempt = 1; attempt <= this.MAX_RETRY_ATTEMPTS; attempt++) {
715
+ try {
716
+ await this.authService.refreshAccessToken();
717
+ return { success: true, retryable: false };
718
+ }
719
+ catch (error) {
720
+ lastError = error instanceof Error ? error : new Error(String(error));
721
+ // Check if this is a definitive auth failure (non-retryable)
722
+ if (this.isDefinitiveAuthFailure(error)) {
723
+ console.warn(`[TokenRefreshManager] Definitive auth failure: ${lastError.message}`);
724
+ return { success: false, retryable: false, error: lastError };
725
+ }
726
+ // Retryable error - wait and try again
727
+ if (attempt < this.MAX_RETRY_ATTEMPTS) {
728
+ await this.delay(this.RETRY_DELAY_MS * attempt); // Exponential backoff
729
+ }
730
+ }
557
731
  }
732
+ // All retries exhausted - return as retryable so we don't logout
733
+ console.warn(`[TokenRefreshManager] All ${this.MAX_RETRY_ATTEMPTS} refresh attempts failed`);
734
+ return { success: false, retryable: true, error: lastError };
735
+ }
736
+ /**
737
+ * Determine if an error is a definitive auth failure that requires logout.
738
+ * Network errors, timeouts, 5xx errors are NOT definitive - they're retryable.
739
+ *
740
+ * Delegates to AuthService.isDefinitiveAuthFailure() - the SINGLE SOURCE OF TRUTH.
741
+ */
742
+ isDefinitiveAuthFailure(error) {
743
+ return this.authService.isDefinitiveAuthFailure(error);
744
+ }
745
+ delay(ms) {
746
+ return new Promise(resolve => setTimeout(resolve, ms));
747
+ }
748
+ /**
749
+ * @deprecated Use ensureValidToken() instead
750
+ */
751
+ async attemptInternalRefresh() {
752
+ const result = await this.attemptRefreshWithRetry();
753
+ return result.success;
558
754
  }
559
755
  }
560
756
 
@@ -702,6 +898,22 @@ class AuthTokenManager {
702
898
  this.cache = {};
703
899
  await this.storage.clear();
704
900
  }
901
+ /**
902
+ * Clears only auth tokens (access, refresh, provider, authType), preserving DPoP keys.
903
+ * Use this when DPoP keys are regenerated - we want to invalidate auth tokens
904
+ * that were bound to old keys, but keep the newly generated DPoP keys.
905
+ */
906
+ async clearAuthTokens() {
907
+ // Clear cache
908
+ this.cache = {};
909
+ // Remove all auth tokens and auth type, NOT DPoP keys
910
+ await Promise.all([
911
+ this.storage.remove(AUTH_STORAGE_KEYS.ACCESS_TOKEN),
912
+ this.storage.remove(AUTH_STORAGE_KEYS.REFRESH_TOKEN),
913
+ this.storage.remove(AUTH_STORAGE_KEYS.PROVIDER_TOKEN),
914
+ this.storage.remove(AUTH_STORAGE_KEYS.AUTH_TYPE),
915
+ ]);
916
+ }
705
917
  async hasAccessToken() {
706
918
  // Use cached value if available to avoid storage read
707
919
  if (this.cache.accessToken !== undefined) {
@@ -732,13 +944,12 @@ class IndexedDBTokenStorage {
732
944
  constructor() {
733
945
  this.supportsObjects = true;
734
946
  this.dbPromise = null;
735
- if (typeof indexedDB === 'undefined') {
736
- console.warn('IndexedDB is not available in this environment');
737
- }
947
+ // IndexedDB availability is checked lazily on first operation
738
948
  }
739
949
  getDB() {
740
- if (this.dbPromise)
950
+ if (this.dbPromise) {
741
951
  return this.dbPromise;
952
+ }
742
953
  this.dbPromise = new Promise((resolve, reject) => {
743
954
  if (typeof indexedDB === 'undefined') {
744
955
  return reject(new Error('IndexedDB not supported'));
@@ -766,8 +977,12 @@ class IndexedDBTokenStorage {
766
977
  const transaction = db.transaction(IndexedDBTokenStorage.STORE_NAME, 'readonly');
767
978
  const store = transaction.objectStore(IndexedDBTokenStorage.STORE_NAME);
768
979
  const request = store.get(key);
769
- request.onsuccess = () => resolve(request.result || null);
770
- request.onerror = () => reject(request.error);
980
+ request.onsuccess = () => {
981
+ resolve(request.result || null);
982
+ };
983
+ request.onerror = () => {
984
+ reject(request.error);
985
+ };
771
986
  });
772
987
  }
773
988
  catch (e) {
@@ -963,6 +1178,8 @@ class DPoPManager {
963
1178
  constructor(storage, cryptoProvider, callbacks) {
964
1179
  this.storage = storage;
965
1180
  this.memoryKeyPair = null;
1181
+ /** Pending key pair promise to prevent concurrent generation race conditions */
1182
+ this.pendingKeyPair = null;
966
1183
  this.cryptoProvider = cryptoProvider || new WebDPoPCryptoProvider();
967
1184
  this.callbacks = callbacks || {};
968
1185
  }
@@ -971,8 +1188,9 @@ class DPoPManager {
971
1188
  * Useful for detecting if a key regeneration would be needed.
972
1189
  */
973
1190
  async hasStoredKeys() {
974
- if (this.memoryKeyPair)
1191
+ if (this.memoryKeyPair) {
975
1192
  return true;
1193
+ }
976
1194
  const storedPublic = await this.storage.get(DPOP_STORAGE_KEYS.PUBLIC);
977
1195
  const storedPrivate = await this.storage.get(DPOP_STORAGE_KEYS.PRIVATE);
978
1196
  return !!(storedPublic && storedPrivate);
@@ -986,8 +1204,35 @@ class DPoPManager {
986
1204
  * match refresh token binding" errors.
987
1205
  */
988
1206
  async ensureKeyPair() {
989
- if (this.memoryKeyPair)
1207
+ // Fast path: already have keys in memory
1208
+ if (this.memoryKeyPair) {
1209
+ return this.memoryKeyPair;
1210
+ }
1211
+ // Prevent concurrent key generation race condition:
1212
+ // If another call is already generating keys, wait for it instead of starting a new one
1213
+ if (this.pendingKeyPair) {
1214
+ return this.pendingKeyPair;
1215
+ }
1216
+ // Create a promise that subsequent callers will wait on
1217
+ this.pendingKeyPair = this.loadOrGenerateKeyPair();
1218
+ try {
1219
+ const keyPair = await this.pendingKeyPair;
1220
+ return keyPair;
1221
+ }
1222
+ finally {
1223
+ // Clear pending promise so future calls can proceed
1224
+ this.pendingKeyPair = null;
1225
+ }
1226
+ }
1227
+ /**
1228
+ * Internal method that actually loads or generates keys.
1229
+ * Called only once per concurrent batch of ensureKeyPair() calls.
1230
+ */
1231
+ async loadOrGenerateKeyPair() {
1232
+ // Double-check in case keys were set while waiting
1233
+ if (this.memoryKeyPair) {
990
1234
  return this.memoryKeyPair;
1235
+ }
991
1236
  const storedPublic = await this.storage.get(DPOP_STORAGE_KEYS.PUBLIC);
992
1237
  const storedPrivate = await this.storage.get(DPOP_STORAGE_KEYS.PRIVATE);
993
1238
  if (storedPublic && storedPrivate) {
@@ -999,18 +1244,23 @@ class DPoPManager {
999
1244
  return this.memoryKeyPair;
1000
1245
  }
1001
1246
  catch (e) {
1002
- console.warn('[DPoPManager] Corrupted DPoP keys in storage, regenerating.');
1247
+ console.warn('[DPoPManager] Corrupted DPoP keys in storage, regenerating');
1003
1248
  }
1004
1249
  }
1005
1250
  // Generate new key pair
1006
1251
  // IMPORTANT: This invalidates any existing refresh tokens bound to the old keys
1007
- console.info('[DPoPManager] Generating new DPoP key pair');
1008
- // Adaptation: If storage supports raw objects (like IndexedDB or Native Keychain),
1252
+ // Adaptation: If storage supports raw objects (like Native Keychain on iOS/Android),
1009
1253
  // we can generate Non-Extractable keys for maximum security.
1010
- // If storage is text-only (LocalStorage), we must use Extractable keys to serialize them.
1254
+ // If storage is text-only (LocalStorage) OR IndexedDB (CryptoKey doesn't survive reload),
1255
+ // we must use Extractable keys to serialize them as JWK.
1256
+ //
1257
+ // NOTE: IndexedDB CAN store CryptoKey objects, but they don't survive page reloads -
1258
+ // they come back as empty/corrupted objects. Only native secure storage can persist
1259
+ // non-extractable keys. Frontend should set supportsObjects: false for web storage.
1011
1260
  const useHighSecurity = !!this.storage.supportsObjects;
1261
+ const extractable = !useHighSecurity;
1012
1262
  const keyPair = await this.cryptoProvider.generateKeyPair({
1013
- extractable: !useHighSecurity
1263
+ extractable
1014
1264
  });
1015
1265
  // Save to storage
1016
1266
  await this.storage.set(DPOP_STORAGE_KEYS.PUBLIC, keyPair.publicKey);
@@ -1044,6 +1294,21 @@ class DPoPManager {
1044
1294
  }
1045
1295
  return this.cryptoProvider.signProof(payload, keyPair);
1046
1296
  }
1297
+ /**
1298
+ * Generate a short fingerprint of the public key for debugging
1299
+ * This helps identify if the same key is being used across operations
1300
+ */
1301
+ getPublicKeyFingerprint(publicKey) {
1302
+ try {
1303
+ // Use the 'x' coordinate of the EC key as a fingerprint
1304
+ const x = publicKey.x || '';
1305
+ const y = publicKey.y || '';
1306
+ return `${x.substring(0, 8)}...${y.substring(0, 4)}`;
1307
+ }
1308
+ catch {
1309
+ return 'unknown';
1310
+ }
1311
+ }
1047
1312
  /**
1048
1313
  * Clears DPoP keys (e.g. on logout)
1049
1314
  */
@@ -1092,13 +1357,15 @@ class DefaultAuthProvider {
1092
1357
  this.tokenManager = new AuthTokenManager(storage);
1093
1358
  if (this.dpopEnabled) {
1094
1359
  this.dpopManager = new DPoPManager(storage, config.dpop?.cryptoProvider, {
1095
- // When DPoP keys are regenerated (corrupted/missing), the refresh token
1096
- // bound to the old keys becomes invalid. Clear it to prevent
1097
- // "DPoP proof does not match refresh token binding" errors.
1360
+ // When DPoP keys are regenerated (corrupted/missing), auth tokens
1361
+ // bound to the old keys become invalid. Clear only auth tokens, NOT DPoP keys.
1362
+ // Access token has cnf claim binding it to the old public key.
1363
+ // Refresh token has DPoP binding to the old key pair.
1364
+ // NOTE: Do NOT call clearAllTokens() here - it would wipe the freshly saved DPoP keys!
1098
1365
  onKeysRegenerated: () => {
1099
- console.info('[DefaultAuthProvider] DPoP keys regenerated, clearing invalid refresh token');
1100
- this.tokenManager.clearRefreshToken().catch(err => {
1101
- console.warn('[DefaultAuthProvider] Failed to clear refresh token:', err);
1366
+ // Clear only auth tokens, preserve DPoP keys (they were just regenerated and saved)
1367
+ this.tokenManager.clearAuthTokens().catch(err => {
1368
+ console.warn('[DefaultAuthProvider] Failed to clear tokens after key regeneration:', err);
1102
1369
  });
1103
1370
  }
1104
1371
  });
@@ -1240,6 +1507,41 @@ class DefaultAuthProvider {
1240
1507
  return null;
1241
1508
  }
1242
1509
  }
1510
+ /**
1511
+ * Store the provider token (Firebase JWT, Auth0 token, etc.)
1512
+ * Used for session recovery when refresh tokens expire.
1513
+ *
1514
+ * NOTE: This is called AUTOMATICALLY by loginWithToken(), so you typically
1515
+ * don't need to call this manually. The only reason to call this is if:
1516
+ *
1517
+ * 1. Your auth provider (Firebase, Auth0) refreshes tokens in the background
1518
+ * 2. You want to proactively update the stored token for better recovery chances
1519
+ *
1520
+ * The stored provider token is used as a fallback when:
1521
+ * - PERS refresh token expires (e.g., after 30 days of inactivity)
1522
+ * - SDK attempts recovery using the stored provider token
1523
+ * - If provider token is still valid → session recovered automatically
1524
+ * - If provider token is also expired → user must re-authenticate
1525
+ *
1526
+ * @example
1527
+ * // Optional: Update stored token when Firebase refreshes it
1528
+ * firebase.auth().onIdTokenChanged(async (user) => {
1529
+ * if (user) {
1530
+ * const freshToken = await user.getIdToken();
1531
+ * await sdk.auth.setProviderToken(freshToken);
1532
+ * }
1533
+ * });
1534
+ */
1535
+ async setProviderToken(token) {
1536
+ await this.tokenManager.setProviderToken(token);
1537
+ }
1538
+ /**
1539
+ * Get the stored provider token (Firebase JWT, Auth0 token, etc.)
1540
+ * Used internally for session recovery when refresh tokens expire.
1541
+ */
1542
+ async getProviderToken() {
1543
+ return this.tokenManager.getProviderToken();
1544
+ }
1243
1545
  }
1244
1546
 
1245
1547
  /**
@@ -1255,7 +1557,7 @@ class DefaultAuthProvider {
1255
1557
  /** SDK package name */
1256
1558
  const SDK_NAME = "@explorins/pers-sdk";
1257
1559
  /** SDK version - injected from package.json at build time */
1258
- const SDK_VERSION = "2.1.32";
1560
+ const SDK_VERSION = "2.1.33";
1259
1561
  /** Full SDK identifier for headers */
1260
1562
  const SDK_USER_AGENT = `${SDK_NAME}/${SDK_VERSION}`;
1261
1563
 
@@ -1404,7 +1706,9 @@ class PersApiClient {
1404
1706
  // Handle 401 errors centrally through AuthService
1405
1707
  if (status === 401) {
1406
1708
  const backendError = index.ErrorUtils.extractBackendErrorDetails(error);
1407
- // Check for fatal auth errors that require immediate logout
1709
+ // Check for FATAL auth error CODES (not just 401 status)
1710
+ // Fatal codes: REFRESH_TOKEN_EXPIRED, TOKEN_REVOKED, etc. → immediate logout
1711
+ // Non-fatal codes: TOKEN_EXPIRED → try refresh first
1408
1712
  if (this.authService.isFatalAuthError(backendError.code)) {
1409
1713
  // Definitive auth failure - no retry, session is invalid
1410
1714
  await this.authService.handleAuthFailure();
@@ -7531,6 +7835,55 @@ class AnalyticsManager {
7531
7835
  async getCampaignClaimAnalytics(request) {
7532
7836
  return this.analyticsService.getCampaignClaimAnalytics(request);
7533
7837
  }
7838
+ /**
7839
+ * Get redemption redeem analytics with aggregation
7840
+ *
7841
+ * Retrieves aggregated redemption redeem data with flexible grouping and metrics.
7842
+ * Perfect for charts, dashboards, and redeem pattern analysis across redemptions,
7843
+ * businesses, and time periods.
7844
+ *
7845
+ * @param request - Analytics request with filters, groupBy, and metrics
7846
+ * @returns Promise resolving to redemption redeem analytics data
7847
+ *
7848
+ * @example Redeems per redemption
7849
+ * ```typescript
7850
+ * const analytics = await sdk.analytics.getRedemptionRedeemAnalytics({
7851
+ * groupBy: ['redemptionId'],
7852
+ * metrics: ['count'],
7853
+ * sortBy: 'count',
7854
+ * sortOrder: 'DESC',
7855
+ * limit: 10
7856
+ * });
7857
+ *
7858
+ * console.log('Top redemptions by redeems:', analytics.results);
7859
+ * ```
7860
+ *
7861
+ * @example Redeems by status over time
7862
+ * ```typescript
7863
+ * const analytics = await sdk.analytics.getRedemptionRedeemAnalytics({
7864
+ * groupBy: ['month', 'status'],
7865
+ * metrics: ['count'],
7866
+ * sortBy: 'month',
7867
+ * sortOrder: 'DESC',
7868
+ * startDate: new Date('2026-01-01'),
7869
+ * endDate: new Date('2026-12-31')
7870
+ * });
7871
+ * ```
7872
+ *
7873
+ * @example Redeems by business
7874
+ * ```typescript
7875
+ * const analytics = await sdk.analytics.getRedemptionRedeemAnalytics({
7876
+ * filters: { status: 'COMPLETED' },
7877
+ * groupBy: ['businessId'],
7878
+ * metrics: ['count'],
7879
+ * sortBy: 'count',
7880
+ * sortOrder: 'DESC'
7881
+ * });
7882
+ * ```
7883
+ */
7884
+ async getRedemptionRedeemAnalytics(request) {
7885
+ return this.analyticsService.getRedemptionRedeemAnalytics(request);
7886
+ }
7534
7887
  /**
7535
7888
  * Get user analytics with engagement metrics
7536
7889
  *
@@ -10327,6 +10680,7 @@ exports.DPOP_STORAGE_KEYS = DPOP_STORAGE_KEYS;
10327
10680
  exports.DPoPManager = DPoPManager;
10328
10681
  exports.DefaultAuthProvider = DefaultAuthProvider;
10329
10682
  exports.DonationManager = DonationManager;
10683
+ exports.FATAL_AUTH_CODES = FATAL_AUTH_CODES;
10330
10684
  exports.FileApi = FileApi;
10331
10685
  exports.FileManager = FileManager;
10332
10686
  exports.FileService = FileService;
@@ -10357,5 +10711,6 @@ exports.buildApiRoot = buildApiRoot;
10357
10711
  exports.buildWalletEventsWsUrl = buildWalletEventsWsUrl;
10358
10712
  exports.createPersEventsClient = createPersEventsClient;
10359
10713
  exports.createPersSDK = createPersSDK;
10714
+ exports.isFatalAuthErrorInMessage = isFatalAuthErrorInMessage;
10360
10715
  exports.mergeWithDefaults = mergeWithDefaults;
10361
- //# sourceMappingURL=pers-sdk-CDj1sZaP.cjs.map
10716
+ //# sourceMappingURL=pers-sdk-DgsYyo1X.cjs.map