@explorins/pers-sdk-react-native 2.2.0-beta.1 → 2.2.1

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.
package/dist/index.js CHANGED
@@ -3216,6 +3216,12 @@ const EntityTypes = {
3216
3216
  SIGNING_ACCOUNT: 'signing_account',
3217
3217
  API_KEY: 'api_key',
3218
3218
  WEBHOOK: 'webhook',
3219
+ // Service domains
3220
+ ANALYTICS: 'analytics',
3221
+ CHAIN: 'chain',
3222
+ FILE: 'file',
3223
+ JWKS: 'jwks',
3224
+ PROJECT: 'project',
3219
3225
  };
3220
3226
  /**
3221
3227
  * Error-specific domains (extends EntityTypes)
@@ -3726,6 +3732,9 @@ const CommonErrorCodes = {
3726
3732
  TOKEN_NOT_ACTIVE: 'TOKEN_NOT_ACTIVE',
3727
3733
  TOKEN_ID_REQUIRED: 'TOKEN_ID_REQUIRED',
3728
3734
  TOKEN_TYPE_NOT_FOUND: 'TOKEN_TYPE_NOT_FOUND',
3735
+ TOKEN_UNIT_NOT_FOUND: 'TOKEN_UNIT_NOT_FOUND',
3736
+ NFT_EXPIRED: 'NFT_EXPIRED',
3737
+ NFT_BUSINESS_RESTRICTION: 'NFT_BUSINESS_RESTRICTION',
3729
3738
  // Wallet domain
3730
3739
  WALLET_MISSING_SIGNING: 'WALLET_MISSING_SIGNING',
3731
3740
  WALLET_NOT_FOUND: 'WALLET_NOT_FOUND',
@@ -3765,6 +3774,9 @@ const CommonErrorCodes = {
3765
3774
  CAMPAIGN_DAILY_LIMIT_REACHED: 'CAMPAIGN_DAILY_LIMIT_REACHED',
3766
3775
  CAMPAIGN_GLOBAL_LIMIT_REACHED: 'CAMPAIGN_GLOBAL_LIMIT_REACHED',
3767
3776
  CAMPAIGN_GLOBAL_DAILY_LIMIT_REACHED: 'CAMPAIGN_GLOBAL_DAILY_LIMIT_REACHED',
3777
+ CAMPAIGN_NOT_FOUND: 'CAMPAIGN_NOT_FOUND',
3778
+ GEOGRAPHIC_RESTRICTION: 'GEOGRAPHIC_RESTRICTION',
3779
+ LOCATION_VERIFICATION_REQUIRED: 'LOCATION_VERIFICATION_REQUIRED',
3768
3780
  // Trigger domain
3769
3781
  TRIGGER_NOT_FOUND: 'TRIGGER_NOT_FOUND',
3770
3782
  // Trigger Source domain
@@ -3780,6 +3792,12 @@ const CommonErrorCodes = {
3780
3792
  EXTERNAL_SERVICE_ERROR: 'EXTERNAL_SERVICE_ERROR',
3781
3793
  AUTHENTICATION_REQUIRED: 'AUTHENTICATION_REQUIRED',
3782
3794
  AUTHORIZATION_FAILED: 'AUTHORIZATION_FAILED',
3795
+ CONFIGURATION_MISSING: 'CONFIGURATION_MISSING',
3796
+ // Redemption domain
3797
+ USER_STATUS_RESTRICTED: 'USER_STATUS_RESTRICTED',
3798
+ REDEMPTION_ALREADY_PROCESSING: 'REDEMPTION_ALREADY_PROCESSING',
3799
+ MAX_REDEMPTION: 'MAX_REDEMPTION',
3800
+ USER_LIMIT: 'USER_LIMIT',
3783
3801
  // Authentication errors
3784
3802
  INVALID_TOKEN: 'INVALID_TOKEN',
3785
3803
  TOKEN_EXPIRED: 'TOKEN_EXPIRED',
@@ -3787,6 +3805,8 @@ const CommonErrorCodes = {
3787
3805
  REFRESH_TOKEN_EXPIRED: 'REFRESH_TOKEN_EXPIRED',
3788
3806
  REFRESH_TOKEN_REVOKED: 'REFRESH_TOKEN_REVOKED',
3789
3807
  FIREBASE_AUTH_ERROR: 'FIREBASE_AUTH_ERROR',
3808
+ MULTIPLE_CONTEXT_SELECTION_REQUIRED: 'MULTIPLE_CONTEXT_SELECTION_REQUIRED',
3809
+ ADMIN_TENANT_REQUIRED: 'ADMIN_TENANT_REQUIRED',
3790
3810
  // Generic fallbacks
3791
3811
  INTERNAL_ERROR: 'INTERNAL_ERROR',
3792
3812
  VALIDATION_ERROR: 'VALIDATION_ERROR',
@@ -3812,26 +3832,49 @@ const CommonErrorCodes = {
3812
3832
  * Native implementation without external dependencies for optimal performance
3813
3833
  */
3814
3834
  /**
3815
- * Check if JWT token is expired or expiring within margin
3835
+ * Decode JWT payload without verification
3816
3836
  * @param token JWT token (with or without Bearer prefix)
3817
- * @param margin Safety margin in seconds before expiry (default: 60)
3837
+ * @returns Decoded payload or null if invalid
3818
3838
  */
3819
- const isTokenExpired = (token, margin = 60) => {
3839
+ const decodeJwtPayload = (token) => {
3820
3840
  const cleanToken = token.replace(/^Bearer\s+/i, '');
3821
3841
  if (!cleanToken || cleanToken.split('.').length !== 3) {
3822
- return true;
3842
+ return null;
3823
3843
  }
3824
3844
  try {
3825
- const payload = JSON.parse(atob(cleanToken.split('.')[1]));
3826
- if (!payload.exp)
3827
- return false;
3828
- const currentTime = Math.floor(Date.now() / 1000);
3829
- return payload.exp < (currentTime + margin);
3845
+ return JSON.parse(atob(cleanToken.split('.')[1]));
3830
3846
  }
3831
3847
  catch {
3832
- return true;
3848
+ return null;
3833
3849
  }
3834
3850
  };
3851
+ /**
3852
+ * Check if JWT token is expired or expiring within margin
3853
+ * @param token JWT token (with or without Bearer prefix)
3854
+ * @param margin Safety margin in seconds before expiry (default: 60)
3855
+ */
3856
+ const isTokenExpired = (token, margin = 60) => {
3857
+ const payload = decodeJwtPayload(token);
3858
+ if (!payload)
3859
+ return true;
3860
+ if (!payload.exp)
3861
+ return false;
3862
+ const currentTime = Math.floor(Date.now() / 1000);
3863
+ return payload.exp < (currentTime + margin);
3864
+ };
3865
+ /**
3866
+ * Get remaining time-to-live for a JWT token in seconds
3867
+ * @param token JWT token (with or without Bearer prefix)
3868
+ * @returns Remaining TTL in seconds, or null if invalid/expired
3869
+ */
3870
+ const getTokenTimeToLive = (token) => {
3871
+ const payload = decodeJwtPayload(token);
3872
+ if (!payload?.exp)
3873
+ return null;
3874
+ const currentTime = Math.floor(Date.now() / 1000);
3875
+ const ttl = payload.exp - currentTime;
3876
+ return ttl > 0 ? ttl : null;
3877
+ };
3835
3878
 
3836
3879
  /**
3837
3880
  * PERS SDK Error Handling - Optimized for Performance
@@ -3860,12 +3903,12 @@ class ErrorUtils {
3860
3903
  return false;
3861
3904
  const err = error;
3862
3905
  // Check for explicit token expired indicators
3863
- if (err?.code === 'TOKEN_EXPIRED' || err?.errorCode === 'TOKEN_EXPIRED') {
3906
+ if (err?.code === CommonErrorCodes.TOKEN_EXPIRED || err?.errorCode === CommonErrorCodes.TOKEN_EXPIRED) {
3864
3907
  return true;
3865
3908
  }
3866
3909
  // Check in response data
3867
3910
  const apiError = err?.response?.data || err;
3868
- if (apiError?.code === 'TOKEN_EXPIRED' || apiError?.errorCode === 'TOKEN_EXPIRED') {
3911
+ if (apiError?.code === CommonErrorCodes.TOKEN_EXPIRED || apiError?.errorCode === CommonErrorCodes.TOKEN_EXPIRED) {
3869
3912
  return true;
3870
3913
  }
3871
3914
  // Any 401 error is treated as token expiration
@@ -3935,7 +3978,9 @@ class ErrorUtils {
3935
3978
  // Try extraction strategies in priority order
3936
3979
  const candidates = [
3937
3980
  this.parseHttpClientError(error), // React Native format
3938
- httpError.response?.data, // Axios/fetch format
3981
+ httpError.response && typeof httpError.response === 'object'
3982
+ ? httpError.response.data
3983
+ : undefined, // Axios/fetch format
3939
3984
  httpError.error, // Angular HttpErrorResponse
3940
3985
  httpError // Direct error object
3941
3986
  ];
@@ -3954,8 +3999,9 @@ class ErrorUtils {
3954
3999
  * Check if object has structured error fields from backend (StructuredError)
3955
4000
  */
3956
4001
  static hasStructuredErrorFields(obj) {
3957
- return obj && typeof obj === 'object' &&
3958
- ((obj.code && obj.category) || (obj.status && (obj.detail || obj.message || obj.title)));
4002
+ return obj !== null && typeof obj === 'object' &&
4003
+ (('code' in obj && 'category' in obj) ||
4004
+ ('status' in obj && ('detail' in obj || 'message' in obj || 'title' in obj)));
3959
4005
  }
3960
4006
  /**
3961
4007
  * Create fallback error structure when no structured error found
@@ -3966,9 +4012,9 @@ class ErrorUtils {
3966
4012
  title: 'Request failed',
3967
4013
  message: this.getMessage(error),
3968
4014
  detail: this.getMessage(error),
3969
- code: 'REQUEST_FAILED',
4015
+ code: CommonErrorCodes.TECHNICAL_ERROR,
3970
4016
  category: exports.ErrorCategory.TECHNICAL,
3971
- domain: 'api',
4017
+ domain: ErrorDomains.EXTERNAL,
3972
4018
  retryable: this.isRetryable(error)
3973
4019
  };
3974
4020
  }
@@ -4047,7 +4093,7 @@ class PersApiError extends Error {
4047
4093
  this.method = method;
4048
4094
  this.name = 'PersApiError';
4049
4095
  // Use backend error details directly
4050
- this.code = backendError?.code || 'API_ERROR';
4096
+ this.code = backendError?.code || CommonErrorCodes.TECHNICAL_ERROR;
4051
4097
  this.domain = backendError?.domain || ErrorDomains.EXTERNAL;
4052
4098
  this.category = backendError?.category || exports.ErrorCategory.TECHNICAL;
4053
4099
  this.retryable = backendError?.retryable ?? (statusCode ? statusCode >= 500 : false);
@@ -4071,7 +4117,7 @@ class AuthenticationError extends PersApiError {
4071
4117
  class NetworkError extends Error {
4072
4118
  constructor(message) {
4073
4119
  super(message);
4074
- this.code = SdkErrorCodes.NETWORK_ERROR;
4120
+ this.code = CommonErrorCodes.EXTERNAL_SERVICE_ERROR;
4075
4121
  this.domain = ErrorDomains.EXTERNAL;
4076
4122
  this.category = exports.ErrorCategory.INFRASTRUCTURE;
4077
4123
  this.retryable = true;
@@ -5119,6 +5165,58 @@ class TokenApi {
5119
5165
  }
5120
5166
  return this.apiClient.get(url);
5121
5167
  }
5168
+ /**
5169
+ * Get all token metadata with filtering and pagination
5170
+ *
5171
+ * Useful for displaying rewards (ERC1155) or stamps (ERC721) in tables.
5172
+ * Non-admin users always get active metadata only.
5173
+ *
5174
+ * @param options - Filter and pagination options
5175
+ * @returns Paginated response with token metadata
5176
+ *
5177
+ * @example Get all active rewards
5178
+ * ```typescript
5179
+ * const rewards = await tokenApi.getTokenMetadata({
5180
+ * tokenType: 'ERC1155',
5181
+ * active: true,
5182
+ * limit: 10
5183
+ * });
5184
+ * ```
5185
+ *
5186
+ * @example Get stamps with search
5187
+ * ```typescript
5188
+ * const stamps = await tokenApi.getTokenMetadata({
5189
+ * tokenType: 'ERC721',
5190
+ * search: 'gold',
5191
+ * sortBy: 'name',
5192
+ * sortOrder: 'ASC'
5193
+ * });
5194
+ * ```
5195
+ */
5196
+ async getTokenMetadata(options) {
5197
+ const params = buildPaginationParams(options);
5198
+ if (options?.active !== undefined) {
5199
+ params.set('active', String(options.active));
5200
+ }
5201
+ if (options?.tokenType) {
5202
+ params.set('tokenType', options.tokenType);
5203
+ }
5204
+ if (options?.tokenId) {
5205
+ params.set('tokenId', options.tokenId);
5206
+ }
5207
+ if (options?.search) {
5208
+ params.set('search', options.search);
5209
+ }
5210
+ // Handle tags as string or array
5211
+ if (options?.tags) {
5212
+ const tagsArray = Array.isArray(options.tags) ? options.tags : [options.tags];
5213
+ if (tagsArray.length > 0) {
5214
+ params.set('tags', tagsArray.join(','));
5215
+ }
5216
+ }
5217
+ const response = await this.apiClient.get(`${this.basePath}/metadata?${params.toString()}`);
5218
+ return normalizeToPaginated(response);
5219
+ }
5122
5220
  // ==========================================
5123
5221
  // ADMIN OPERATIONS
5124
5222
  // ==========================================
@@ -5228,6 +5326,40 @@ class TokenService {
5228
5326
  async getTokenByContractAddress(contractAddress, contractTokenId) {
5229
5327
  return this.tokenApi.getTokenByContractAddress(contractAddress, contractTokenId);
5230
5328
  }
5329
+ /**
5330
+ * Get all token metadata with filtering and pagination
5331
+ *
5332
+ * Useful for displaying rewards (ERC1155) or stamps (ERC721) in tables.
5333
+ * Non-admin users always get active metadata only.
5334
+ *
5335
+ * @param options - Filter and pagination options
5336
+ * @returns Paginated response with token metadata
5337
+ */
5338
+ async getTokenMetadata(options) {
5339
+ return this.tokenApi.getTokenMetadata(options);
5340
+ }
5341
+ /**
5342
+ * Get rewards (ERC1155 token metadata) with filtering and pagination
5343
+ *
5344
+ * Convenience method for fetching reward metadata.
5345
+ *
5346
+ * @param options - Filter and pagination options (tokenType is set automatically)
5347
+ * @returns Paginated response with reward metadata
5348
+ */
5349
+ async getRewards(options) {
5350
+ return this.tokenApi.getTokenMetadata({ ...options, tokenType: NativeTokenTypes.ERC1155 });
5351
+ }
5352
+ /**
5353
+ * Get stamps (ERC721 token metadata) with filtering and pagination
5354
+ *
5355
+ * Convenience method for fetching stamp metadata.
5356
+ *
5357
+ * @param options - Filter and pagination options (tokenType is set automatically)
5358
+ * @returns Paginated response with stamp metadata
5359
+ */
5360
+ async getStamps(options) {
5361
+ return this.tokenApi.getTokenMetadata({ ...options, tokenType: NativeTokenTypes.ERC721 });
5362
+ }
5231
5363
  // ==========================================
5232
5364
  // ADMIN OPERATIONS
5233
5365
  // ==========================================
@@ -5354,7 +5486,7 @@ class BusinessApi {
5354
5486
  /**
5355
5487
  * Get all businesses with role-based filtering and pagination
5356
5488
  *
5357
- * Endpoint: GET /businesses?active={boolean}&sanitize={mode}&page={number}&limit={number}
5489
+ * Endpoint: GET /businesses?active={boolean}&sanitize={mode}&page={number}&limit={number}&businessTypeId={number}
5358
5490
  * Auth: @ApiSecurity('projectKey') (enhanced with role-based filtering)
5359
5491
  *
5360
5492
  * Note:
@@ -5377,8 +5509,11 @@ class BusinessApi {
5377
5509
  * });
5378
5510
  *
5379
5511
  * // Filter by tag(s)
5380
- * const tagged = await businessApi.getBusinesses({ tag: 'restaurant' });
5381
- * const multiTag = await businessApi.getBusinesses({ tag: ['restaurant', 'cafe'] });
5512
+ * const tagged = await businessApi.getBusinesses({ tags: 'restaurant' });
5513
+ * const multiTag = await businessApi.getBusinesses({ tags: ['restaurant', 'cafe'] });
5514
+ *
5515
+ * // Filter by business type
5516
+ * const hotels = await businessApi.getBusinesses({ businessTypeId: 1 });
5382
5517
  * ```
5383
5518
  */
5384
5519
  async getBusinesses(options) {
@@ -5398,6 +5533,9 @@ class BusinessApi {
5398
5533
  if (options?.sanitize) {
5399
5534
  params.set('sanitize', options.sanitize);
5400
5535
  }
5536
+ if (options?.businessTypeId !== undefined) {
5537
+ params.set('businessTypeId', String(options.businessTypeId));
5538
+ }
5401
5539
  const response = await this.apiClient.get(`${this.basePath}?${params.toString()}`);
5402
5540
  return normalizeToPaginated(response);
5403
5541
  }
@@ -5744,20 +5882,16 @@ class BusinessService {
5744
5882
  return this.businessApi.getBusinessByAccount(accountAddress);
5745
5883
  }
5746
5884
  /**
5747
- * Get businesses by type (client-side filtering)
5748
- * @deprecated Consider implementing server-side filtering for better performance
5885
+ * Get businesses by type (server-side filtering)
5886
+ * Uses the businessTypeId parameter for efficient server-side filtering.
5749
5887
  */
5750
5888
  async getBusinessesByType(typeId, options) {
5751
- const response = await this.getActiveBusinesses(options);
5752
- const filtered = response.data.filter((business) => business.businessType && business.businessType.id === parseInt(typeId));
5753
- return {
5754
- data: filtered,
5755
- pagination: {
5756
- ...response.pagination,
5757
- total: filtered.length,
5758
- pages: Math.ceil(filtered.length / (response.pagination.limit || 20))
5759
- }
5760
- };
5889
+ const businessTypeId = parseInt(typeId, 10);
5890
+ return this.businessApi.getBusinesses({
5891
+ ...options,
5892
+ businessTypeId,
5893
+ active: options?.active ?? true // Default to active businesses
5894
+ });
5761
5895
  }
5762
5896
  // ==========================================
5763
5897
  // ADMIN OPERATIONS
@@ -6996,6 +7130,14 @@ class RedemptionService {
6996
7130
  async getRedemptions(options) {
6997
7131
  return this.redemptionApi.getRedemptions(options);
6998
7132
  }
7133
+ /**
7134
+ * Get a specific redemption by ID
7135
+ * @param id - Redemption UUID
7136
+ * @param include - Optional relations to include: 'redeemCount'
7137
+ */
7138
+ async getRedemptionById(id, include) {
7139
+ return this.redemptionApi.getRedemptionById(id, include);
7140
+ }
6999
7141
  /**
7000
7142
  * Convenience: Get active redemptions (public)
7001
7143
  */
@@ -8635,6 +8777,49 @@ class AnalyticsApi {
8635
8777
  async getCampaignClaimAnalytics(request) {
8636
8778
  return this.apiClient.post('/analytics/campaign-claims', request);
8637
8779
  }
8780
+ /**
8781
+ * ADMIN: Get redemption redeem analytics with aggregation (charts, metrics, grouping)
8782
+ *
8783
+ * This endpoint is for aggregated analytics only (groupBy/metrics).
8784
+ * For enriched list data with nested objects, use redemption.getRedeems() instead.
8785
+ *
8786
+ * @example Redeems per redemption
8787
+ * ```typescript
8788
+ * const response = await analyticsApi.getRedemptionRedeemAnalytics({
8789
+ * groupBy: ['redemptionId'],
8790
+ * metrics: ['count'],
8791
+ * sortBy: 'count',
8792
+ * sortOrder: SortOrder.DESC,
8793
+ * limit: 10
8794
+ * });
8795
+ * ```
8796
+ *
8797
+ * @example Redeems by status over time
8798
+ * ```typescript
8799
+ * const response = await analyticsApi.getRedemptionRedeemAnalytics({
8800
+ * groupBy: ['month', 'status'],
8801
+ * metrics: ['count'],
8802
+ * sortBy: 'month',
8803
+ * sortOrder: SortOrder.DESC,
8804
+ * startDate: new Date('2026-01-01'),
8805
+ * endDate: new Date('2026-12-31')
8806
+ * });
8807
+ * ```
8808
+ *
8809
+ * @example Redeems by business
8810
+ * ```typescript
8811
+ * const response = await analyticsApi.getRedemptionRedeemAnalytics({
8812
+ * filters: { status: 'COMPLETED' },
8813
+ * groupBy: ['businessId'],
8814
+ * metrics: ['count'],
8815
+ * sortBy: 'count',
8816
+ * sortOrder: SortOrder.DESC
8817
+ * });
8818
+ * ```
8819
+ */
8820
+ async getRedemptionRedeemAnalytics(request) {
8821
+ return this.apiClient.post('/analytics/redemption-redeems', request);
8822
+ }
8638
8823
  /**
8639
8824
  * ADMIN: Get user analytics with engagement metrics
8640
8825
  *
@@ -8866,6 +9051,15 @@ class AnalyticsService {
8866
9051
  async getCampaignClaimAnalytics(request) {
8867
9052
  return this.analyticsApi.getCampaignClaimAnalytics(request);
8868
9053
  }
9054
+ /**
9055
+ * ADMIN: Get redemption redeem analytics with aggregation
9056
+ *
9057
+ * Returns aggregated redeem data with flexible grouping and metrics.
9058
+ * Perfect for charts and dashboard summaries.
9059
+ */
9060
+ async getRedemptionRedeemAnalytics(request) {
9061
+ return this.analyticsApi.getRedemptionRedeemAnalytics(request);
9062
+ }
8869
9063
  /**
8870
9064
  * ADMIN: Get user analytics with engagement metrics
8871
9065
  *
@@ -9323,6 +9517,8 @@ function isExtendedProvider(provider) {
9323
9517
  * TOKEN_EXPIRED is NOT in this list - it's the normal "please refresh" case.
9324
9518
  *
9325
9519
  * Values validated against CommonErrorCodes from @explorins/pers-shared at compile time.
9520
+ *
9521
+ * SINGLE SOURCE OF TRUTH - use this constant everywhere, never duplicate these values.
9326
9522
  */
9327
9523
  const FATAL_AUTH_CODES = [
9328
9524
  'REFRESH_TOKEN_EXPIRED',
@@ -9330,6 +9526,13 @@ const FATAL_AUTH_CODES = [
9330
9526
  'INVALID_TOKEN',
9331
9527
  'TOKEN_REVOKED'
9332
9528
  ];
9529
+ /**
9530
+ * Check if an error message contains any fatal auth error code.
9531
+ * Useful for detecting fatal errors in wrapped/stringified errors.
9532
+ */
9533
+ function isFatalAuthErrorInMessage(message) {
9534
+ return FATAL_AUTH_CODES.some(code => message.includes(code));
9535
+ }
9333
9536
  /**
9334
9537
  * Platform-agnostic authentication service
9335
9538
  * Handles login, token refresh, and storage operations
@@ -9485,8 +9688,58 @@ class AuthService {
9485
9688
  return FATAL_AUTH_CODES.includes(errorCode);
9486
9689
  }
9487
9690
  /**
9488
- * Handle authentication failure with proper cleanup.
9489
- * Called when backend returns fatal auth error codes or refresh fails.
9691
+ * Comprehensive check if an error represents a DEFINITIVE auth failure.
9692
+ * This is the SINGLE SOURCE OF TRUTH for determining if logout is required.
9693
+ *
9694
+ * Returns true for:
9695
+ * - Known fatal error codes (REFRESH_TOKEN_EXPIRED, TOKEN_REVOKED, etc.)
9696
+ * - Fatal error codes in error messages (wrapped errors)
9697
+ *
9698
+ * Returns false for (retryable/recoverable):
9699
+ * - Network errors
9700
+ * - Timeouts
9701
+ * - 5xx server errors
9702
+ * - TOKEN_EXPIRED (normal refresh trigger)
9703
+ * - Raw 401/403 without fatal code (should try refresh first)
9704
+ *
9705
+ * NOTE: We intentionally DO NOT treat raw 401/403 status as fatal.
9706
+ * The HTTP client handles 401 by attempting token refresh first.
9707
+ * Only if refresh fails WITH a fatal code do we logout.
9708
+ */
9709
+ isDefinitiveAuthFailure(error) {
9710
+ if (!error)
9711
+ return false;
9712
+ const errorAny = error;
9713
+ // Check error code property against canonical list
9714
+ if (errorAny?.code && this.isFatalAuthError(errorAny.code)) {
9715
+ return true;
9716
+ }
9717
+ // Check if error message contains fatal keywords (fallback for wrapped errors)
9718
+ const errorMessage = error instanceof Error ? error.message : String(error);
9719
+ if (isFatalAuthErrorInMessage(errorMessage)) {
9720
+ return true;
9721
+ }
9722
+ // NOTE: We intentionally DO NOT check HTTP status codes here.
9723
+ // A raw 401/403 without a fatal error code should trigger a refresh attempt,
9724
+ // not an immediate logout. The HTTP client (pers-api-client.ts) handles this:
9725
+ // 1. 401 received → try refresh token
9726
+ // 2. Refresh succeeds → retry original request
9727
+ // 3. Refresh fails with fatal CODE → then logout
9728
+ // Everything else is considered retryable
9729
+ return false;
9730
+ }
9731
+ /**
9732
+ * Handle authentication failure with provider token recovery.
9733
+ *
9734
+ * Before logging out, attempts to recover the session using the stored
9735
+ * provider token (Firebase JWT, etc.) if it's still valid.
9736
+ *
9737
+ * Recovery Flow:
9738
+ * 1. Check if provider token exists and is not expired
9739
+ * 2. Get the auth type (USER, BUSINESS, TENANT) to know which login to use
9740
+ * 3. Attempt re-authentication with provider token
9741
+ * 4. If successful: Session recovered, stay logged in
9742
+ * 5. If failed: Clear tokens and emit AUTH_FAILED
9490
9743
  */
9491
9744
  async handleAuthFailure() {
9492
9745
  // Already failed - nothing to do
@@ -9502,7 +9755,7 @@ class AuthService {
9502
9755
  return await this.activeAuthFailurePromise;
9503
9756
  }
9504
9757
  // Create the failure promise to deduplicate concurrent calls
9505
- this.activeAuthFailurePromise = this.performAuthFailure();
9758
+ this.activeAuthFailurePromise = this.performAuthFailureWithRecovery();
9506
9759
  try {
9507
9760
  await this.activeAuthFailurePromise;
9508
9761
  }
@@ -9510,6 +9763,77 @@ class AuthService {
9510
9763
  this.activeAuthFailurePromise = null;
9511
9764
  }
9512
9765
  }
9766
+ /**
9767
+ * Attempts to recover session using provider token before failing.
9768
+ */
9769
+ async performAuthFailureWithRecovery() {
9770
+ // Clear active refresh operations
9771
+ this.activeRefreshPromise = null;
9772
+ // Try to recover using provider token
9773
+ const recovered = await this.attemptProviderTokenRecovery();
9774
+ if (recovered) {
9775
+ return; // Session recovered, don't fail
9776
+ }
9777
+ // Recovery failed - perform actual logout
9778
+ await this.performAuthFailure();
9779
+ }
9780
+ /**
9781
+ * Attempt to recover session using stored provider token.
9782
+ * Extracts context (tenantId/businessId) from the current refresh token to ensure
9783
+ * the recovered session maintains the same context.
9784
+ *
9785
+ * @returns true if session was successfully recovered
9786
+ */
9787
+ async attemptProviderTokenRecovery() {
9788
+ const extended = this.extendedProvider;
9789
+ if (!extended?.getProviderToken || !extended?.getAuthType) {
9790
+ return false; // Provider doesn't support recovery
9791
+ }
9792
+ try {
9793
+ const providerToken = await extended.getProviderToken();
9794
+ if (!providerToken) {
9795
+ return false;
9796
+ }
9797
+ // Check if provider token is still valid (with small margin)
9798
+ if (isTokenExpired(providerToken, 30)) {
9799
+ return false;
9800
+ }
9801
+ const authType = await extended.getAuthType();
9802
+ if (!authType) {
9803
+ return false;
9804
+ }
9805
+ // Extract context (tenantId/businessId) from the current refresh token
9806
+ // This ensures multi-tenant/multi-business users recover to the same context
9807
+ const refreshToken = await this.authProvider?.getRefreshToken?.();
9808
+ const contextClaims = refreshToken ? decodeJwtPayload(refreshToken) : null;
9809
+ // Re-authenticate using provider token based on auth type
9810
+ // Pass the context from the previous session to avoid MULTIPLE_CONTEXT_SELECTION_REQUIRED
9811
+ // Note: In AuthJWTPayload, when accountType=BUSINESS, sub contains the businessId
9812
+ switch (authType) {
9813
+ case exports.AccountOwnerType.USER:
9814
+ await this.loginUser(providerToken);
9815
+ break;
9816
+ case exports.AccountOwnerType.BUSINESS:
9817
+ // For BUSINESS auth, sub contains the businessId when accountType is BUSINESS
9818
+ const businessId = contextClaims?.accountType === exports.AccountOwnerType.BUSINESS
9819
+ ? contextClaims.sub
9820
+ : undefined;
9821
+ await this.loginBusiness(providerToken, businessId ? { businessId } : undefined);
9822
+ break;
9823
+ case exports.AccountOwnerType.TENANT:
9824
+ await this.loginTenantAdmin(providerToken, contextClaims?.tenantId ? { tenantId: contextClaims.tenantId } : undefined);
9825
+ break;
9826
+ default:
9827
+ console.warn(`[AuthService] Unknown auth type for recovery: ${authType}`);
9828
+ return false;
9829
+ }
9830
+ return true; // Recovery successful
9831
+ }
9832
+ catch (error) {
9833
+ console.warn('[AuthService] Provider token recovery failed:', error);
9834
+ return false;
9835
+ }
9836
+ }
9513
9837
  /**
9514
9838
  * Performs the actual auth failure cleanup
9515
9839
  */
@@ -9589,16 +9913,32 @@ class AuthService {
9589
9913
 
9590
9914
  /**
9591
9915
  * Token Refresh Manager
9592
- * Simple container for token refresh logic with better organization
9916
+ * Industry-standard token lifecycle management with:
9917
+ * - Dynamic refresh margin based on token lifetime
9918
+ * - Retry logic for transient failures
9919
+ * - Only logout on definitive auth failures (not network errors)
9593
9920
  */
9594
9921
  class TokenRefreshManager {
9595
9922
  constructor(authService, authProvider) {
9596
9923
  this.authService = authService;
9597
9924
  this.authProvider = authProvider;
9598
- this.tokenRefreshMarginSeconds = 120; // 2 minutes
9925
+ // Industry standard: refresh when 25% of token lifetime remains
9926
+ this.REFRESH_THRESHOLD_PERCENT = 0.25;
9927
+ // Minimum margin regardless of token lifetime
9928
+ this.MIN_REFRESH_MARGIN_SECONDS = 30;
9929
+ // Maximum margin (cap for very long-lived tokens)
9930
+ this.MAX_REFRESH_MARGIN_SECONDS = 300; // 5 minutes
9931
+ // Validation cache to avoid redundant checks
9599
9932
  this.lastValidationTime = 0;
9600
9933
  this.validationCacheDurationMs = 30000; // 30 seconds
9934
+ // Retry configuration for transient failures
9935
+ this.MAX_RETRY_ATTEMPTS = 3;
9936
+ this.RETRY_DELAY_MS = 1000;
9601
9937
  }
9938
+ /**
9939
+ * Ensures the access token is valid, refreshing if needed.
9940
+ * Uses dynamic margin based on token lifetime (industry standard).
9941
+ */
9602
9942
  async ensureValidToken() {
9603
9943
  const now = Date.now();
9604
9944
  // Skip validation if we checked recently (within cache duration)
@@ -9610,35 +9950,95 @@ class TokenRefreshManager {
9610
9950
  if (!token) {
9611
9951
  return;
9612
9952
  }
9613
- if (isTokenExpired(token, this.tokenRefreshMarginSeconds)) {
9614
- const refreshSuccess = await this.attemptInternalRefresh();
9615
- if (!refreshSuccess) {
9953
+ // Calculate dynamic refresh margin based on token lifetime
9954
+ const marginSeconds = this.calculateRefreshMargin(token);
9955
+ if (isTokenExpired(token, marginSeconds)) {
9956
+ const result = await this.attemptRefreshWithRetry();
9957
+ if (!result.success) {
9958
+ if (result.retryable) {
9959
+ // Transient error - don't logout, let next request retry
9960
+ console.warn('[TokenRefreshManager] Refresh failed with retryable error, will retry on next request');
9961
+ return;
9962
+ }
9963
+ // Definitive auth failure - logout required
9616
9964
  await this.authService.handleAuthFailure();
9617
9965
  }
9618
9966
  else {
9619
- // Update validation time on successful refresh
9967
+ // Refresh successful
9620
9968
  this.lastValidationTime = now;
9621
9969
  }
9622
9970
  }
9623
9971
  else {
9624
- // Token is valid, update validation time
9972
+ // Token is valid
9625
9973
  this.lastValidationTime = now;
9626
9974
  }
9627
9975
  }
9628
9976
  catch (error) {
9629
- await this.authService.handleAuthFailure();
9977
+ // Unexpected error - log but don't logout (conservative approach)
9978
+ console.error('[TokenRefreshManager] Unexpected error during token validation:', error);
9630
9979
  }
9631
9980
  }
9632
- async attemptInternalRefresh() {
9633
- try {
9634
- await this.authService.refreshAccessToken();
9635
- return true;
9981
+ /**
9982
+ * Calculate refresh margin dynamically based on token lifetime.
9983
+ * Industry standard: refresh when 25% of lifetime remains.
9984
+ */
9985
+ calculateRefreshMargin(token) {
9986
+ const ttlSeconds = getTokenTimeToLive(token);
9987
+ if (ttlSeconds === null || ttlSeconds <= 0) {
9988
+ // Can't determine TTL, use minimum margin
9989
+ return this.MIN_REFRESH_MARGIN_SECONDS;
9636
9990
  }
9637
- catch (error) {
9638
- // Don't call handleAuthFailure here - let the caller decide
9639
- // This prevents duplicate auth failure handling in race conditions
9640
- return false;
9991
+ // Calculate 25% of remaining lifetime
9992
+ const dynamicMargin = Math.floor(ttlSeconds * this.REFRESH_THRESHOLD_PERCENT);
9993
+ // Clamp between min and max
9994
+ return Math.max(this.MIN_REFRESH_MARGIN_SECONDS, Math.min(dynamicMargin, this.MAX_REFRESH_MARGIN_SECONDS));
9995
+ }
9996
+ /**
9997
+ * Attempt token refresh with retry logic for transient failures.
9998
+ * Only gives up on definitive auth errors.
9999
+ */
10000
+ async attemptRefreshWithRetry() {
10001
+ let lastError;
10002
+ for (let attempt = 1; attempt <= this.MAX_RETRY_ATTEMPTS; attempt++) {
10003
+ try {
10004
+ await this.authService.refreshAccessToken();
10005
+ return { success: true, retryable: false };
10006
+ }
10007
+ catch (error) {
10008
+ lastError = error instanceof Error ? error : new Error(String(error));
10009
+ // Check if this is a definitive auth failure (non-retryable)
10010
+ if (this.isDefinitiveAuthFailure(error)) {
10011
+ console.warn(`[TokenRefreshManager] Definitive auth failure: ${lastError.message}`);
10012
+ return { success: false, retryable: false, error: lastError };
10013
+ }
10014
+ // Retryable error - wait and try again
10015
+ if (attempt < this.MAX_RETRY_ATTEMPTS) {
10016
+ await this.delay(this.RETRY_DELAY_MS * attempt); // Exponential backoff
10017
+ }
10018
+ }
9641
10019
  }
10020
+ // All retries exhausted - return as retryable so we don't logout
10021
+ console.warn(`[TokenRefreshManager] All ${this.MAX_RETRY_ATTEMPTS} refresh attempts failed`);
10022
+ return { success: false, retryable: true, error: lastError };
10023
+ }
10024
+ /**
10025
+ * Determine if an error is a definitive auth failure that requires logout.
10026
+ * Network errors, timeouts, 5xx errors are NOT definitive - they're retryable.
10027
+ *
10028
+ * Delegates to AuthService.isDefinitiveAuthFailure() - the SINGLE SOURCE OF TRUTH.
10029
+ */
10030
+ isDefinitiveAuthFailure(error) {
10031
+ return this.authService.isDefinitiveAuthFailure(error);
10032
+ }
10033
+ delay(ms) {
10034
+ return new Promise(resolve => setTimeout(resolve, ms));
10035
+ }
10036
+ /**
10037
+ * @deprecated Use ensureValidToken() instead
10038
+ */
10039
+ async attemptInternalRefresh() {
10040
+ const result = await this.attemptRefreshWithRetry();
10041
+ return result.success;
9642
10042
  }
9643
10043
  }
9644
10044
 
@@ -9786,6 +10186,22 @@ class AuthTokenManager {
9786
10186
  this.cache = {};
9787
10187
  await this.storage.clear();
9788
10188
  }
10189
+ /**
10190
+ * Clears only auth tokens (access, refresh, provider, authType), preserving DPoP keys.
10191
+ * Use this when DPoP keys are regenerated - we want to invalidate auth tokens
10192
+ * that were bound to old keys, but keep the newly generated DPoP keys.
10193
+ */
10194
+ async clearAuthTokens() {
10195
+ // Clear cache
10196
+ this.cache = {};
10197
+ // Remove all auth tokens and auth type, NOT DPoP keys
10198
+ await Promise.all([
10199
+ this.storage.remove(AUTH_STORAGE_KEYS.ACCESS_TOKEN),
10200
+ this.storage.remove(AUTH_STORAGE_KEYS.REFRESH_TOKEN),
10201
+ this.storage.remove(AUTH_STORAGE_KEYS.PROVIDER_TOKEN),
10202
+ this.storage.remove(AUTH_STORAGE_KEYS.AUTH_TYPE),
10203
+ ]);
10204
+ }
9789
10205
  async hasAccessToken() {
9790
10206
  // Use cached value if available to avoid storage read
9791
10207
  if (this.cache.accessToken !== undefined) {
@@ -9816,13 +10232,12 @@ class IndexedDBTokenStorage {
9816
10232
  constructor() {
9817
10233
  this.supportsObjects = true;
9818
10234
  this.dbPromise = null;
9819
- if (typeof indexedDB === 'undefined') {
9820
- console.warn('IndexedDB is not available in this environment');
9821
- }
10235
+ // IndexedDB availability is checked lazily on first operation
9822
10236
  }
9823
10237
  getDB() {
9824
- if (this.dbPromise)
10238
+ if (this.dbPromise) {
9825
10239
  return this.dbPromise;
10240
+ }
9826
10241
  this.dbPromise = new Promise((resolve, reject) => {
9827
10242
  if (typeof indexedDB === 'undefined') {
9828
10243
  return reject(new Error('IndexedDB not supported'));
@@ -9850,8 +10265,12 @@ class IndexedDBTokenStorage {
9850
10265
  const transaction = db.transaction(IndexedDBTokenStorage.STORE_NAME, 'readonly');
9851
10266
  const store = transaction.objectStore(IndexedDBTokenStorage.STORE_NAME);
9852
10267
  const request = store.get(key);
9853
- request.onsuccess = () => resolve(request.result || null);
9854
- request.onerror = () => reject(request.error);
10268
+ request.onsuccess = () => {
10269
+ resolve(request.result || null);
10270
+ };
10271
+ request.onerror = () => {
10272
+ reject(request.error);
10273
+ };
9855
10274
  });
9856
10275
  }
9857
10276
  catch (e) {
@@ -10047,6 +10466,8 @@ class DPoPManager {
10047
10466
  constructor(storage, cryptoProvider, callbacks) {
10048
10467
  this.storage = storage;
10049
10468
  this.memoryKeyPair = null;
10469
+ /** Pending key pair promise to prevent concurrent generation race conditions */
10470
+ this.pendingKeyPair = null;
10050
10471
  this.cryptoProvider = cryptoProvider || new WebDPoPCryptoProvider();
10051
10472
  this.callbacks = callbacks || {};
10052
10473
  }
@@ -10055,8 +10476,9 @@ class DPoPManager {
10055
10476
  * Useful for detecting if a key regeneration would be needed.
10056
10477
  */
10057
10478
  async hasStoredKeys() {
10058
- if (this.memoryKeyPair)
10479
+ if (this.memoryKeyPair) {
10059
10480
  return true;
10481
+ }
10060
10482
  const storedPublic = await this.storage.get(DPOP_STORAGE_KEYS.PUBLIC);
10061
10483
  const storedPrivate = await this.storage.get(DPOP_STORAGE_KEYS.PRIVATE);
10062
10484
  return !!(storedPublic && storedPrivate);
@@ -10070,8 +10492,35 @@ class DPoPManager {
10070
10492
  * match refresh token binding" errors.
10071
10493
  */
10072
10494
  async ensureKeyPair() {
10073
- if (this.memoryKeyPair)
10495
+ // Fast path: already have keys in memory
10496
+ if (this.memoryKeyPair) {
10497
+ return this.memoryKeyPair;
10498
+ }
10499
+ // Prevent concurrent key generation race condition:
10500
+ // If another call is already generating keys, wait for it instead of starting a new one
10501
+ if (this.pendingKeyPair) {
10502
+ return this.pendingKeyPair;
10503
+ }
10504
+ // Create a promise that subsequent callers will wait on
10505
+ this.pendingKeyPair = this.loadOrGenerateKeyPair();
10506
+ try {
10507
+ const keyPair = await this.pendingKeyPair;
10508
+ return keyPair;
10509
+ }
10510
+ finally {
10511
+ // Clear pending promise so future calls can proceed
10512
+ this.pendingKeyPair = null;
10513
+ }
10514
+ }
10515
+ /**
10516
+ * Internal method that actually loads or generates keys.
10517
+ * Called only once per concurrent batch of ensureKeyPair() calls.
10518
+ */
10519
+ async loadOrGenerateKeyPair() {
10520
+ // Double-check in case keys were set while waiting
10521
+ if (this.memoryKeyPair) {
10074
10522
  return this.memoryKeyPair;
10523
+ }
10075
10524
  const storedPublic = await this.storage.get(DPOP_STORAGE_KEYS.PUBLIC);
10076
10525
  const storedPrivate = await this.storage.get(DPOP_STORAGE_KEYS.PRIVATE);
10077
10526
  if (storedPublic && storedPrivate) {
@@ -10083,18 +10532,23 @@ class DPoPManager {
10083
10532
  return this.memoryKeyPair;
10084
10533
  }
10085
10534
  catch (e) {
10086
- console.warn('[DPoPManager] Corrupted DPoP keys in storage, regenerating.');
10535
+ console.warn('[DPoPManager] Corrupted DPoP keys in storage, regenerating');
10087
10536
  }
10088
10537
  }
10089
10538
  // Generate new key pair
10090
10539
  // IMPORTANT: This invalidates any existing refresh tokens bound to the old keys
10091
- console.info('[DPoPManager] Generating new DPoP key pair');
10092
- // Adaptation: If storage supports raw objects (like IndexedDB or Native Keychain),
10540
+ // Adaptation: If storage supports raw objects (like Native Keychain on iOS/Android),
10093
10541
  // we can generate Non-Extractable keys for maximum security.
10094
- // If storage is text-only (LocalStorage), we must use Extractable keys to serialize them.
10542
+ // If storage is text-only (LocalStorage) OR IndexedDB (CryptoKey doesn't survive reload),
10543
+ // we must use Extractable keys to serialize them as JWK.
10544
+ //
10545
+ // NOTE: IndexedDB CAN store CryptoKey objects, but they don't survive page reloads -
10546
+ // they come back as empty/corrupted objects. Only native secure storage can persist
10547
+ // non-extractable keys. Frontend should set supportsObjects: false for web storage.
10095
10548
  const useHighSecurity = !!this.storage.supportsObjects;
10549
+ const extractable = !useHighSecurity;
10096
10550
  const keyPair = await this.cryptoProvider.generateKeyPair({
10097
- extractable: !useHighSecurity
10551
+ extractable
10098
10552
  });
10099
10553
  // Save to storage
10100
10554
  await this.storage.set(DPOP_STORAGE_KEYS.PUBLIC, keyPair.publicKey);
@@ -10128,6 +10582,20 @@ class DPoPManager {
10128
10582
  }
10129
10583
  return this.cryptoProvider.signProof(payload, keyPair);
10130
10584
  }
10585
+ /**
10586
+ * Generate a short fingerprint of the public key for debugging
10587
+ * This helps identify if the same key is being used across operations
10588
+ */
10589
+ /* private getPublicKeyFingerprint(publicKey: JsonWebKey): string {
10590
+ try {
10591
+ // Use the 'x' coordinate of the EC key as a fingerprint
10592
+ const x = publicKey.x || '';
10593
+ const y = publicKey.y || '';
10594
+ return `${x.substring(0, 8)}...${y.substring(0, 4)}`;
10595
+ } catch {
10596
+ return 'unknown';
10597
+ }
10598
+ } */
10131
10599
  /**
10132
10600
  * Clears DPoP keys (e.g. on logout)
10133
10601
  */
@@ -10176,13 +10644,15 @@ class DefaultAuthProvider {
10176
10644
  this.tokenManager = new AuthTokenManager(storage);
10177
10645
  if (this.dpopEnabled) {
10178
10646
  this.dpopManager = new DPoPManager(storage, config.dpop?.cryptoProvider, {
10179
- // When DPoP keys are regenerated (corrupted/missing), the refresh token
10180
- // bound to the old keys becomes invalid. Clear it to prevent
10181
- // "DPoP proof does not match refresh token binding" errors.
10647
+ // When DPoP keys are regenerated (corrupted/missing), auth tokens
10648
+ // bound to the old keys become invalid. Clear only auth tokens, NOT DPoP keys.
10649
+ // Access token has cnf claim binding it to the old public key.
10650
+ // Refresh token has DPoP binding to the old key pair.
10651
+ // NOTE: Do NOT call clearAllTokens() here - it would wipe the freshly saved DPoP keys!
10182
10652
  onKeysRegenerated: () => {
10183
- console.info('[DefaultAuthProvider] DPoP keys regenerated, clearing invalid refresh token');
10184
- this.tokenManager.clearRefreshToken().catch(err => {
10185
- console.warn('[DefaultAuthProvider] Failed to clear refresh token:', err);
10653
+ // Clear only auth tokens, preserve DPoP keys (they were just regenerated and saved)
10654
+ this.tokenManager.clearAuthTokens().catch(err => {
10655
+ console.warn('[DefaultAuthProvider] Failed to clear tokens after key regeneration:', err);
10186
10656
  });
10187
10657
  }
10188
10658
  });
@@ -10324,6 +10794,41 @@ class DefaultAuthProvider {
10324
10794
  return null;
10325
10795
  }
10326
10796
  }
10797
+ /**
10798
+ * Store the provider token (Firebase JWT, Auth0 token, etc.)
10799
+ * Used for session recovery when refresh tokens expire.
10800
+ *
10801
+ * NOTE: This is called AUTOMATICALLY by loginWithToken(), so you typically
10802
+ * don't need to call this manually. The only reason to call this is if:
10803
+ *
10804
+ * 1. Your auth provider (Firebase, Auth0) refreshes tokens in the background
10805
+ * 2. You want to proactively update the stored token for better recovery chances
10806
+ *
10807
+ * The stored provider token is used as a fallback when:
10808
+ * - PERS refresh token expires (e.g., after 30 days of inactivity)
10809
+ * - SDK attempts recovery using the stored provider token
10810
+ * - If provider token is still valid → session recovered automatically
10811
+ * - If provider token is also expired → user must re-authenticate
10812
+ *
10813
+ * @example
10814
+ * // Optional: Update stored token when Firebase refreshes it
10815
+ * firebase.auth().onIdTokenChanged(async (user) => {
10816
+ * if (user) {
10817
+ * const freshToken = await user.getIdToken();
10818
+ * await sdk.auth.setProviderToken(freshToken);
10819
+ * }
10820
+ * });
10821
+ */
10822
+ async setProviderToken(token) {
10823
+ await this.tokenManager.setProviderToken(token);
10824
+ }
10825
+ /**
10826
+ * Get the stored provider token (Firebase JWT, Auth0 token, etc.)
10827
+ * Used internally for session recovery when refresh tokens expire.
10828
+ */
10829
+ async getProviderToken() {
10830
+ return this.tokenManager.getProviderToken();
10831
+ }
10327
10832
  }
10328
10833
 
10329
10834
  /**
@@ -10339,7 +10844,7 @@ class DefaultAuthProvider {
10339
10844
  /** SDK package name */
10340
10845
  const SDK_NAME = "@explorins/pers-sdk";
10341
10846
  /** SDK version - injected from package.json at build time */
10342
- const SDK_VERSION = "2.1.32";
10847
+ const SDK_VERSION = "2.1.37";
10343
10848
  /** Full SDK identifier for headers */
10344
10849
  const SDK_USER_AGENT = `${SDK_NAME}/${SDK_VERSION}`;
10345
10850
 
@@ -10488,7 +10993,9 @@ class PersApiClient {
10488
10993
  // Handle 401 errors centrally through AuthService
10489
10994
  if (status === 401) {
10490
10995
  const backendError = ErrorUtils.extractBackendErrorDetails(error);
10491
- // Check for fatal auth errors that require immediate logout
10996
+ // Check for FATAL auth error CODES (not just 401 status)
10997
+ // Fatal codes: REFRESH_TOKEN_EXPIRED, TOKEN_REVOKED, etc. → immediate logout
10998
+ // Non-fatal codes: TOKEN_EXPIRED → try refresh first
10492
10999
  if (this.authService.isFatalAuthError(backendError.code)) {
10493
11000
  // Definitive auth failure - no retry, session is invalid
10494
11001
  await this.authService.handleAuthFailure();
@@ -10548,9 +11055,9 @@ class PersApiClient {
10548
11055
  }); */
10549
11056
  this._events?.emitError({
10550
11057
  domain: errorDetails.domain || 'external',
10551
- type: errorDetails.code || 'API_ERROR',
10552
- userMessage: errorDetails.message || errorMessage, // Event system uses userMessage for display
10553
- code: errorDetails.code,
11058
+ type: 'api_error', // Event type discriminator (fixed value)
11059
+ userMessage: errorDetails.message || errorMessage,
11060
+ code: errorDetails.code, // Actual error code from backend
10554
11061
  details: {
10555
11062
  endpoint,
10556
11063
  method,
@@ -12186,6 +12693,107 @@ class TokenManager {
12186
12693
  async getTokenByContract(contractAddress, contractTokenId = null) {
12187
12694
  return this.tokenService.getTokenByContractAddress(contractAddress, contractTokenId);
12188
12695
  }
12696
+ /**
12697
+ * Get all token metadata with filtering and pagination
12698
+ *
12699
+ * Retrieves token metadata (rewards/stamps) with server-side filtering and pagination.
12700
+ * Useful for displaying rewards (ERC1155) or stamps (ERC721) in admin tables.
12701
+ * Non-admin users always get active metadata only.
12702
+ *
12703
+ * @param options - Filter and pagination options
12704
+ * @returns Promise resolving to paginated token metadata
12705
+ *
12706
+ * @example Get paginated rewards
12707
+ * ```typescript
12708
+ * const rewards = await sdk.tokens.getTokenMetadata({
12709
+ * tokenType: 'ERC1155',
12710
+ * active: true,
12711
+ * page: 1,
12712
+ * limit: 10,
12713
+ * sortBy: 'name',
12714
+ * sortOrder: 'ASC'
12715
+ * });
12716
+ *
12717
+ * console.log(`Page 1 of ${rewards.pagination.pages}`);
12718
+ * rewards.data.forEach(r => console.log(r.name));
12719
+ * ```
12720
+ *
12721
+ * @example Get stamps with search
12722
+ * ```typescript
12723
+ * const stamps = await sdk.tokens.getTokenMetadata({
12724
+ * tokenType: 'ERC721',
12725
+ * search: 'gold',
12726
+ * limit: 25
12727
+ * });
12728
+ * ```
12729
+ */
12730
+ async getTokenMetadata(options) {
12731
+ return this.tokenService.getTokenMetadata(options);
12732
+ }
12733
+ /**
12734
+ * Get rewards (ERC1155 token metadata) with filtering and pagination
12735
+ *
12736
+ * Convenience method for fetching reward metadata. Rewards are ERC1155 tokens
12737
+ * that represent collectible items, vouchers, or other redeemable rewards.
12738
+ *
12739
+ * @param options - Filter and pagination options (tokenType is set automatically)
12740
+ * @returns Promise resolving to paginated reward metadata
12741
+ *
12742
+ * @example Get active rewards
12743
+ * ```typescript
12744
+ * const rewards = await sdk.tokens.getRewards({
12745
+ * active: true,
12746
+ * limit: 10
12747
+ * });
12748
+ *
12749
+ * console.log(`Found ${rewards.pagination.total} rewards`);
12750
+ * rewards.data.forEach(r => console.log(`${r.name}: ${r.description}`));
12751
+ * ```
12752
+ *
12753
+ * @example Search rewards
12754
+ * ```typescript
12755
+ * const discountRewards = await sdk.tokens.getRewards({
12756
+ * search: 'discount',
12757
+ * sortBy: 'name',
12758
+ * sortOrder: 'ASC'
12759
+ * });
12760
+ * ```
12761
+ */
12762
+ async getRewards(options) {
12763
+ return this.tokenService.getRewards(options);
12764
+ }
12765
+ /**
12766
+ * Get stamps (ERC721 token metadata) with filtering and pagination
12767
+ *
12768
+ * Convenience method for fetching stamp metadata. Stamps are ERC721 tokens
12769
+ * that represent achievements, badges, or collectible status markers.
12770
+ *
12771
+ * @param options - Filter and pagination options (tokenType is set automatically)
12772
+ * @returns Promise resolving to paginated stamp metadata
12773
+ *
12774
+ * @example Get active stamps
12775
+ * ```typescript
12776
+ * const stamps = await sdk.tokens.getStamps({
12777
+ * active: true,
12778
+ * limit: 10
12779
+ * });
12780
+ *
12781
+ * console.log(`Found ${stamps.pagination.total} stamps`);
12782
+ * stamps.data.forEach(s => console.log(`${s.name}: ${s.description}`));
12783
+ * ```
12784
+ *
12785
+ * @example Filter stamps by tags
12786
+ * ```typescript
12787
+ * const goldStamps = await sdk.tokens.getStamps({
12788
+ * tags: ['gold', 'premium'],
12789
+ * sortBy: 'createdAt',
12790
+ * sortOrder: 'DESC'
12791
+ * });
12792
+ * ```
12793
+ */
12794
+ async getStamps(options) {
12795
+ return this.tokenService.getStamps(options);
12796
+ }
12189
12797
  /**
12190
12798
  * Admin: Create new token
12191
12799
  *
@@ -14090,6 +14698,52 @@ class RedemptionManager {
14090
14698
  async getRedemptions(options) {
14091
14699
  return this.redemptionService.getRedemptions(options);
14092
14700
  }
14701
+ /**
14702
+ * Get a specific redemption by ID
14703
+ *
14704
+ * Retrieves detailed information about a specific redemption offer.
14705
+ * Useful when you already have the redemption ID and need full details.
14706
+ *
14707
+ * @param id - Redemption UUID
14708
+ * @param include - Optional relations to include: 'redeemCount'
14709
+ * @returns Promise resolving to the redemption
14710
+ *
14711
+ * @example Basic usage
14712
+ * ```typescript
14713
+ * const redemption = await sdk.redemptions.getRedemptionById('7baf4d39-0bd6-45ca-9c66-8c0d655767c3');
14714
+ * console.log(redemption.name);
14715
+ * console.log(redemption.description);
14716
+ * ```
14717
+ *
14718
+ * @example With redeem count
14719
+ * ```typescript
14720
+ * const redemption = await sdk.redemptions.getRedemptionById(
14721
+ * 'redemption-123',
14722
+ * ['redeemCount']
14723
+ * );
14724
+ * console.log(`${redemption.name} has been redeemed ${redemption.redeemCount} times`);
14725
+ * ```
14726
+ *
14727
+ * @example Get redemption details for display
14728
+ * ```typescript
14729
+ * const redemption = await sdk.redemptions.getRedemptionById(id);
14730
+ *
14731
+ * console.log('Redemption Details:');
14732
+ * console.log(`Name: ${redemption.name}`);
14733
+ * console.log(`Description: ${redemption.description}`);
14734
+ * console.log(`Active: ${redemption.isActive}`);
14735
+ *
14736
+ * if (redemption.tokenUnits?.length) {
14737
+ * console.log('Cost:');
14738
+ * redemption.tokenUnits.forEach(unit => {
14739
+ * console.log(` ${unit.amount} ${unit.token.symbol}`);
14740
+ * });
14741
+ * }
14742
+ * ```
14743
+ */
14744
+ async getRedemptionById(id, include) {
14745
+ return this.redemptionService.getRedemptionById(id, include);
14746
+ }
14093
14747
  /**
14094
14748
  * Get available redemption types
14095
14749
  *
@@ -16615,6 +17269,55 @@ class AnalyticsManager {
16615
17269
  async getCampaignClaimAnalytics(request) {
16616
17270
  return this.analyticsService.getCampaignClaimAnalytics(request);
16617
17271
  }
17272
+ /**
17273
+ * Get redemption redeem analytics with aggregation
17274
+ *
17275
+ * Retrieves aggregated redemption redeem data with flexible grouping and metrics.
17276
+ * Perfect for charts, dashboards, and redeem pattern analysis across redemptions,
17277
+ * businesses, and time periods.
17278
+ *
17279
+ * @param request - Analytics request with filters, groupBy, and metrics
17280
+ * @returns Promise resolving to redemption redeem analytics data
17281
+ *
17282
+ * @example Redeems per redemption
17283
+ * ```typescript
17284
+ * const analytics = await sdk.analytics.getRedemptionRedeemAnalytics({
17285
+ * groupBy: ['redemptionId'],
17286
+ * metrics: ['count'],
17287
+ * sortBy: 'count',
17288
+ * sortOrder: 'DESC',
17289
+ * limit: 10
17290
+ * });
17291
+ *
17292
+ * console.log('Top redemptions by redeems:', analytics.results);
17293
+ * ```
17294
+ *
17295
+ * @example Redeems by status over time
17296
+ * ```typescript
17297
+ * const analytics = await sdk.analytics.getRedemptionRedeemAnalytics({
17298
+ * groupBy: ['month', 'status'],
17299
+ * metrics: ['count'],
17300
+ * sortBy: 'month',
17301
+ * sortOrder: 'DESC',
17302
+ * startDate: new Date('2026-01-01'),
17303
+ * endDate: new Date('2026-12-31')
17304
+ * });
17305
+ * ```
17306
+ *
17307
+ * @example Redeems by business
17308
+ * ```typescript
17309
+ * const analytics = await sdk.analytics.getRedemptionRedeemAnalytics({
17310
+ * filters: { status: 'COMPLETED' },
17311
+ * groupBy: ['businessId'],
17312
+ * metrics: ['count'],
17313
+ * sortBy: 'count',
17314
+ * sortOrder: 'DESC'
17315
+ * });
17316
+ * ```
17317
+ */
17318
+ async getRedemptionRedeemAnalytics(request) {
17319
+ return this.analyticsService.getRedemptionRedeemAnalytics(request);
17320
+ }
16618
17321
  /**
16619
17322
  * Get user analytics with engagement metrics
16620
17323
  *
@@ -20170,7 +20873,7 @@ const PersSDKProvider = ({ children, config }) => {
20170
20873
  setIsAuthenticated(true);
20171
20874
  }
20172
20875
  }
20173
- if (event.type === 'logout_success' || event.type === 'session_restoration_failed' || event.code === 'AUTH_FAILED') {
20876
+ if (event.type === 'logout_success' || event.type === 'session_restoration_failed') {
20174
20877
  setUser(null);
20175
20878
  setIsAuthenticated(false);
20176
20879
  }
@@ -20729,6 +21432,99 @@ const useTokens = () => {
20729
21432
  throw error;
20730
21433
  }
20731
21434
  }, [sdk, isInitialized]);
21435
+ /**
21436
+ * Get all token metadata with filtering and pagination
21437
+ *
21438
+ * Retrieves token metadata (rewards/stamps) with server-side filtering.
21439
+ * Use tokenType: 'ERC1155' for rewards, 'ERC721' for stamps.
21440
+ *
21441
+ * @param options - Filter and pagination options
21442
+ * @returns Promise resolving to paginated token metadata
21443
+ * @throws Error if SDK is not initialized
21444
+ *
21445
+ * @example
21446
+ * ```typescript
21447
+ * const { getTokenMetadata } = useTokens();
21448
+ * const rewards = await getTokenMetadata({
21449
+ * tokenType: 'ERC1155',
21450
+ * active: true,
21451
+ * limit: 10
21452
+ * });
21453
+ * ```
21454
+ */
21455
+ const getTokenMetadata = react.useCallback(async (options) => {
21456
+ if (!isInitialized || !sdk) {
21457
+ throw new Error('SDK not initialized. Call initialize() first.');
21458
+ }
21459
+ try {
21460
+ const result = await sdk.tokens.getTokenMetadata(options);
21461
+ return result;
21462
+ }
21463
+ catch (error) {
21464
+ console.error('Failed to fetch token metadata:', error);
21465
+ throw error;
21466
+ }
21467
+ }, [sdk, isInitialized]);
21468
+ /**
21469
+ * Get rewards (ERC1155 token metadata) with filtering and pagination
21470
+ *
21471
+ * Convenience method for fetching reward metadata. Rewards are ERC1155 tokens
21472
+ * that represent collectible items, vouchers, or other redeemable rewards.
21473
+ *
21474
+ * @param options - Filter and pagination options
21475
+ * @returns Promise resolving to paginated reward metadata
21476
+ * @throws Error if SDK is not initialized
21477
+ *
21478
+ * @example
21479
+ * ```typescript
21480
+ * const { getRewards } = useTokens();
21481
+ * const rewards = await getRewards({ active: true, limit: 10 });
21482
+ * console.log(`Found ${rewards.pagination.total} rewards`);
21483
+ * ```
21484
+ */
21485
+ const getRewards = react.useCallback(async (options) => {
21486
+ if (!isInitialized || !sdk) {
21487
+ throw new Error('SDK not initialized. Call initialize() first.');
21488
+ }
21489
+ try {
21490
+ const result = await sdk.tokens.getRewards(options);
21491
+ return result;
21492
+ }
21493
+ catch (error) {
21494
+ console.error('Failed to fetch rewards:', error);
21495
+ throw error;
21496
+ }
21497
+ }, [sdk, isInitialized]);
21498
+ /**
21499
+ * Get stamps (ERC721 token metadata) with filtering and pagination
21500
+ *
21501
+ * Convenience method for fetching stamp metadata. Stamps are ERC721 tokens
21502
+ * that represent achievements, badges, or collectible status markers.
21503
+ *
21504
+ * @param options - Filter and pagination options
21505
+ * @returns Promise resolving to paginated stamp metadata
21506
+ * @throws Error if SDK is not initialized
21507
+ *
21508
+ * @example
21509
+ * ```typescript
21510
+ * const { getStamps } = useTokens();
21511
+ * const stamps = await getStamps({ active: true, limit: 10 });
21512
+ * console.log(`Found ${stamps.pagination.total} stamps`);
21513
+ * ```
21514
+ */
21515
+ const getStamps = react.useCallback(async (options) => {
21516
+ if (!isInitialized || !sdk) {
21517
+ throw new Error('SDK not initialized. Call initialize() first.');
21518
+ }
21519
+ try {
21520
+ const result = await sdk.tokens.getStamps(options);
21521
+ return result;
21522
+ }
21523
+ catch (error) {
21524
+ console.error('Failed to fetch stamps:', error);
21525
+ throw error;
21526
+ }
21527
+ }, [sdk, isInitialized]);
20732
21528
  return {
20733
21529
  getTokens,
20734
21530
  getActiveCreditToken,
@@ -20736,6 +21532,9 @@ const useTokens = () => {
20736
21532
  getTokenTypes,
20737
21533
  getStatusTokens,
20738
21534
  getTokenByContract,
21535
+ getTokenMetadata,
21536
+ getRewards,
21537
+ getStamps,
20739
21538
  isAvailable: isInitialized && !!sdk?.tokens,
20740
21539
  };
20741
21540
  };
@@ -43972,6 +44771,25 @@ const useRedemptions = () => {
43972
44771
  /**
43973
44772
  * Redeem a redemption offer with optional dynamic context
43974
44773
  *
44774
+ * ## When to use `redeem()` vs `createTransaction()`
44775
+ *
44776
+ * | Use Case | Method | Auto-Signs |
44777
+ * |----------|--------|------------|
44778
+ * | **Voucher redemption** | `redeem()` | ✅ Yes |
44779
+ * | **Reward redemption** | `redeem()` | ✅ Yes |
44780
+ * | **Direct token transfer** | `createTransaction()` | ⚠️ Depends |
44781
+ * | **POS burn with QR** | `buildPOSBurnRequest()` | ❌ Manual |
44782
+ *
44783
+ * **Use `redeem()` for voucher/reward redemptions.** It automatically:
44784
+ * 1. Creates redemption record
44785
+ * 2. Triggers passkey/biometric prompt
44786
+ * 3. Signs the transaction
44787
+ * 4. Submits to blockchain
44788
+ * 5. Returns result with `transactionHash`
44789
+ *
44790
+ * **No admin approval required** - The "PENDING" state is brief (seconds).
44791
+ * The user's passkey signature IS the approval.
44792
+ *
43975
44793
  * Supports dynamic context for personalized NFT generation when the redemption
43976
44794
  * issues ERC721 tokens. Context can include validity dates, AI prompt placeholders,
43977
44795
  * and custom metadata.
@@ -43980,11 +44798,21 @@ const useRedemptions = () => {
43980
44798
  * @param options - Optional settings
43981
44799
  * @param options.context - Dynamic context for NFT personalization (validity dates, AI prompts)
43982
44800
  * @param options.onStatusUpdate - Callback for transaction signing status updates
43983
- * @returns Promise resolving to redemption result
44801
+ * @returns Promise resolving to redemption result with `transactionHash` on success
43984
44802
  *
43985
- * @example Basic redemption
44803
+ * @example Basic redemption (most common)
43986
44804
  * ```typescript
43987
- * const result = await redeem('redemption-123');
44805
+ * const { redeem } = useRedemptions();
44806
+ *
44807
+ * const result = await redeem('redemption-123', {
44808
+ * onStatusUpdate: (status, message) => {
44809
+ * console.log(`Status: ${status} - ${message}`);
44810
+ * }
44811
+ * });
44812
+ *
44813
+ * if (result.transactionHash) {
44814
+ * console.log('Redemption complete!', result.transactionHash);
44815
+ * }
43988
44816
  * ```
43989
44817
  *
43990
44818
  * @example Redemption with dynamic context (personalized NFT)
@@ -44015,6 +44843,8 @@ const useRedemptions = () => {
44015
44843
  * }
44016
44844
  * });
44017
44845
  * ```
44846
+ *
44847
+ * @see {@link useTransactions.createTransaction | createTransaction()} for direct transfers without redemption flow
44018
44848
  */
44019
44849
  const redeem = react.useCallback(async (redemptionId, options) => {
44020
44850
  if (!isInitialized || !sdk) {
@@ -44164,8 +44994,43 @@ const useRedemptions = () => {
44164
44994
  throw error;
44165
44995
  }
44166
44996
  }, [sdk, isInitialized]);
44997
+ /**
44998
+ * Get a specific redemption by ID
44999
+ *
45000
+ * Retrieves detailed information about a specific redemption offer.
45001
+ *
45002
+ * @param id - Redemption UUID
45003
+ * @param include - Optional relations to include: 'redeemCount'
45004
+ * @returns Promise resolving to the redemption
45005
+ *
45006
+ * @example Basic usage
45007
+ * ```typescript
45008
+ * const redemption = await getRedemptionById('7baf4d39-0bd6-45ca-9c66-8c0d655767c3');
45009
+ * console.log(redemption.name);
45010
+ * ```
45011
+ *
45012
+ * @example With redeem count
45013
+ * ```typescript
45014
+ * const redemption = await getRedemptionById('redemption-123', ['redeemCount']);
45015
+ * console.log(`${redemption.name} has been redeemed ${redemption.redeemCount} times`);
45016
+ * ```
45017
+ */
45018
+ const getRedemptionById = react.useCallback(async (id, include) => {
45019
+ if (!isInitialized || !sdk) {
45020
+ throw new Error('SDK not initialized. Call initialize() first.');
45021
+ }
45022
+ try {
45023
+ const result = await sdk.redemptions.getRedemptionById(id, include);
45024
+ return result;
45025
+ }
45026
+ catch (error) {
45027
+ console.error('Failed to fetch redemption by ID:', error);
45028
+ throw error;
45029
+ }
45030
+ }, [sdk, isInitialized]);
44167
45031
  return {
44168
45032
  getRedemptions,
45033
+ getRedemptionById,
44169
45034
  getUserRedemptions,
44170
45035
  redeem,
44171
45036
  getRedemptionTypes,
@@ -45797,6 +46662,7 @@ exports.DonationService = DonationService;
45797
46662
  exports.EntityTypes = EntityTypes;
45798
46663
  exports.ErrorDomains = ErrorDomains;
45799
46664
  exports.ErrorUtils = ErrorUtils;
46665
+ exports.FATAL_AUTH_CODES = FATAL_AUTH_CODES;
45800
46666
  exports.FileApi = FileApi;
45801
46667
  exports.FileManager = FileManager;
45802
46668
  exports.FileService = FileService;
@@ -45902,6 +46768,7 @@ exports.initializeReactNativePolyfills = initializeReactNativePolyfills;
45902
46768
  exports.isConnectedMessage = isConnectedMessage;
45903
46769
  exports.isErrorMessage = isErrorMessage;
45904
46770
  exports.isEventMessage = isEventMessage;
46771
+ exports.isFatalAuthErrorInMessage = isFatalAuthErrorInMessage;
45905
46772
  exports.isPaginatedResponse = isPaginatedResponse;
45906
46773
  exports.isPingMessage = isPingMessage;
45907
46774
  exports.isServerMessage = isServerMessage;