@explorins/pers-sdk-react-native 2.2.0-beta.1 → 2.2.0
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/__tests__/PersSDKProvider.test.d.ts +2 -0
- package/dist/__tests__/PersSDKProvider.test.d.ts.map +1 -0
- package/dist/__tests__/PersSDKProvider.test.js +290 -0
- package/dist/__tests__/__mocks__/pers-sdk-core.d.ts +42 -0
- package/dist/__tests__/__mocks__/pers-sdk-core.d.ts.map +1 -0
- package/dist/__tests__/__mocks__/pers-sdk-core.js +48 -0
- package/dist/__tests__/__mocks__/pers-shared.d.ts +15 -0
- package/dist/__tests__/__mocks__/pers-shared.d.ts.map +1 -0
- package/dist/__tests__/__mocks__/pers-shared.js +2 -0
- package/dist/__tests__/setup.d.ts +2 -0
- package/dist/__tests__/setup.d.ts.map +1 -0
- package/dist/__tests__/setup.js +35 -0
- package/dist/hooks/useRedemptions.d.ts.map +1 -1
- package/dist/index.js +481 -51
- package/dist/index.js.map +1 -1
- package/dist/providers/PersSDKProvider.js +1 -1
- package/package.json +199 -189
- package/src/__tests__/PersSDKProvider.test.tsx +420 -0
- package/src/__tests__/__mocks__/pers-sdk-core.ts +83 -0
- package/src/__tests__/__mocks__/pers-shared.ts +17 -0
- package/src/__tests__/setup.ts +40 -0
- package/src/hooks/useRedemptions.ts +1 -1
- package/src/providers/PersSDKProvider.tsx +1 -1
package/dist/index.js
CHANGED
|
@@ -3812,26 +3812,49 @@ const CommonErrorCodes = {
|
|
|
3812
3812
|
* Native implementation without external dependencies for optimal performance
|
|
3813
3813
|
*/
|
|
3814
3814
|
/**
|
|
3815
|
-
*
|
|
3815
|
+
* Decode JWT payload without verification
|
|
3816
3816
|
* @param token JWT token (with or without Bearer prefix)
|
|
3817
|
-
* @
|
|
3817
|
+
* @returns Decoded payload or null if invalid
|
|
3818
3818
|
*/
|
|
3819
|
-
const
|
|
3819
|
+
const decodeJwtPayload = (token) => {
|
|
3820
3820
|
const cleanToken = token.replace(/^Bearer\s+/i, '');
|
|
3821
3821
|
if (!cleanToken || cleanToken.split('.').length !== 3) {
|
|
3822
|
-
return
|
|
3822
|
+
return null;
|
|
3823
3823
|
}
|
|
3824
3824
|
try {
|
|
3825
|
-
|
|
3826
|
-
if (!payload.exp)
|
|
3827
|
-
return false;
|
|
3828
|
-
const currentTime = Math.floor(Date.now() / 1000);
|
|
3829
|
-
return payload.exp < (currentTime + margin);
|
|
3825
|
+
return JSON.parse(atob(cleanToken.split('.')[1]));
|
|
3830
3826
|
}
|
|
3831
3827
|
catch {
|
|
3832
|
-
return
|
|
3828
|
+
return null;
|
|
3833
3829
|
}
|
|
3834
3830
|
};
|
|
3831
|
+
/**
|
|
3832
|
+
* Check if JWT token is expired or expiring within margin
|
|
3833
|
+
* @param token JWT token (with or without Bearer prefix)
|
|
3834
|
+
* @param margin Safety margin in seconds before expiry (default: 60)
|
|
3835
|
+
*/
|
|
3836
|
+
const isTokenExpired = (token, margin = 60) => {
|
|
3837
|
+
const payload = decodeJwtPayload(token);
|
|
3838
|
+
if (!payload)
|
|
3839
|
+
return true;
|
|
3840
|
+
if (!payload.exp)
|
|
3841
|
+
return false;
|
|
3842
|
+
const currentTime = Math.floor(Date.now() / 1000);
|
|
3843
|
+
return payload.exp < (currentTime + margin);
|
|
3844
|
+
};
|
|
3845
|
+
/**
|
|
3846
|
+
* Get remaining time-to-live for a JWT token in seconds
|
|
3847
|
+
* @param token JWT token (with or without Bearer prefix)
|
|
3848
|
+
* @returns Remaining TTL in seconds, or null if invalid/expired
|
|
3849
|
+
*/
|
|
3850
|
+
const getTokenTimeToLive = (token) => {
|
|
3851
|
+
const payload = decodeJwtPayload(token);
|
|
3852
|
+
if (!payload?.exp)
|
|
3853
|
+
return null;
|
|
3854
|
+
const currentTime = Math.floor(Date.now() / 1000);
|
|
3855
|
+
const ttl = payload.exp - currentTime;
|
|
3856
|
+
return ttl > 0 ? ttl : null;
|
|
3857
|
+
};
|
|
3835
3858
|
|
|
3836
3859
|
/**
|
|
3837
3860
|
* PERS SDK Error Handling - Optimized for Performance
|
|
@@ -8635,6 +8658,49 @@ class AnalyticsApi {
|
|
|
8635
8658
|
async getCampaignClaimAnalytics(request) {
|
|
8636
8659
|
return this.apiClient.post('/analytics/campaign-claims', request);
|
|
8637
8660
|
}
|
|
8661
|
+
/**
|
|
8662
|
+
* ADMIN: Get redemption redeem analytics with aggregation (charts, metrics, grouping)
|
|
8663
|
+
*
|
|
8664
|
+
* This endpoint is for aggregated analytics only (groupBy/metrics).
|
|
8665
|
+
* For enriched list data with nested objects, use redemption.getRedeems() instead.
|
|
8666
|
+
*
|
|
8667
|
+
* @example Redeems per redemption
|
|
8668
|
+
* ```typescript
|
|
8669
|
+
* const response = await analyticsApi.getRedemptionRedeemAnalytics({
|
|
8670
|
+
* groupBy: ['redemptionId'],
|
|
8671
|
+
* metrics: ['count'],
|
|
8672
|
+
* sortBy: 'count',
|
|
8673
|
+
* sortOrder: SortOrder.DESC,
|
|
8674
|
+
* limit: 10
|
|
8675
|
+
* });
|
|
8676
|
+
* ```
|
|
8677
|
+
*
|
|
8678
|
+
* @example Redeems by status over time
|
|
8679
|
+
* ```typescript
|
|
8680
|
+
* const response = await analyticsApi.getRedemptionRedeemAnalytics({
|
|
8681
|
+
* groupBy: ['month', 'status'],
|
|
8682
|
+
* metrics: ['count'],
|
|
8683
|
+
* sortBy: 'month',
|
|
8684
|
+
* sortOrder: SortOrder.DESC,
|
|
8685
|
+
* startDate: new Date('2026-01-01'),
|
|
8686
|
+
* endDate: new Date('2026-12-31')
|
|
8687
|
+
* });
|
|
8688
|
+
* ```
|
|
8689
|
+
*
|
|
8690
|
+
* @example Redeems by business
|
|
8691
|
+
* ```typescript
|
|
8692
|
+
* const response = await analyticsApi.getRedemptionRedeemAnalytics({
|
|
8693
|
+
* filters: { status: 'COMPLETED' },
|
|
8694
|
+
* groupBy: ['businessId'],
|
|
8695
|
+
* metrics: ['count'],
|
|
8696
|
+
* sortBy: 'count',
|
|
8697
|
+
* sortOrder: SortOrder.DESC
|
|
8698
|
+
* });
|
|
8699
|
+
* ```
|
|
8700
|
+
*/
|
|
8701
|
+
async getRedemptionRedeemAnalytics(request) {
|
|
8702
|
+
return this.apiClient.post('/analytics/redemption-redeems', request);
|
|
8703
|
+
}
|
|
8638
8704
|
/**
|
|
8639
8705
|
* ADMIN: Get user analytics with engagement metrics
|
|
8640
8706
|
*
|
|
@@ -8866,6 +8932,15 @@ class AnalyticsService {
|
|
|
8866
8932
|
async getCampaignClaimAnalytics(request) {
|
|
8867
8933
|
return this.analyticsApi.getCampaignClaimAnalytics(request);
|
|
8868
8934
|
}
|
|
8935
|
+
/**
|
|
8936
|
+
* ADMIN: Get redemption redeem analytics with aggregation
|
|
8937
|
+
*
|
|
8938
|
+
* Returns aggregated redeem data with flexible grouping and metrics.
|
|
8939
|
+
* Perfect for charts and dashboard summaries.
|
|
8940
|
+
*/
|
|
8941
|
+
async getRedemptionRedeemAnalytics(request) {
|
|
8942
|
+
return this.analyticsApi.getRedemptionRedeemAnalytics(request);
|
|
8943
|
+
}
|
|
8869
8944
|
/**
|
|
8870
8945
|
* ADMIN: Get user analytics with engagement metrics
|
|
8871
8946
|
*
|
|
@@ -9323,6 +9398,8 @@ function isExtendedProvider(provider) {
|
|
|
9323
9398
|
* TOKEN_EXPIRED is NOT in this list - it's the normal "please refresh" case.
|
|
9324
9399
|
*
|
|
9325
9400
|
* Values validated against CommonErrorCodes from @explorins/pers-shared at compile time.
|
|
9401
|
+
*
|
|
9402
|
+
* SINGLE SOURCE OF TRUTH - use this constant everywhere, never duplicate these values.
|
|
9326
9403
|
*/
|
|
9327
9404
|
const FATAL_AUTH_CODES = [
|
|
9328
9405
|
'REFRESH_TOKEN_EXPIRED',
|
|
@@ -9330,6 +9407,13 @@ const FATAL_AUTH_CODES = [
|
|
|
9330
9407
|
'INVALID_TOKEN',
|
|
9331
9408
|
'TOKEN_REVOKED'
|
|
9332
9409
|
];
|
|
9410
|
+
/**
|
|
9411
|
+
* Check if an error message contains any fatal auth error code.
|
|
9412
|
+
* Useful for detecting fatal errors in wrapped/stringified errors.
|
|
9413
|
+
*/
|
|
9414
|
+
function isFatalAuthErrorInMessage(message) {
|
|
9415
|
+
return FATAL_AUTH_CODES.some(code => message.includes(code));
|
|
9416
|
+
}
|
|
9333
9417
|
/**
|
|
9334
9418
|
* Platform-agnostic authentication service
|
|
9335
9419
|
* Handles login, token refresh, and storage operations
|
|
@@ -9485,8 +9569,53 @@ class AuthService {
|
|
|
9485
9569
|
return FATAL_AUTH_CODES.includes(errorCode);
|
|
9486
9570
|
}
|
|
9487
9571
|
/**
|
|
9488
|
-
*
|
|
9489
|
-
*
|
|
9572
|
+
* Comprehensive check if an error represents a DEFINITIVE auth failure.
|
|
9573
|
+
* This is the SINGLE SOURCE OF TRUTH for determining if logout is required.
|
|
9574
|
+
*
|
|
9575
|
+
* Returns true for:
|
|
9576
|
+
* - Known fatal error codes (REFRESH_TOKEN_EXPIRED, TOKEN_REVOKED, etc.)
|
|
9577
|
+
* - Fatal error codes in error messages (wrapped errors)
|
|
9578
|
+
* - 401/403 HTTP status codes
|
|
9579
|
+
*
|
|
9580
|
+
* Returns false for (retryable errors):
|
|
9581
|
+
* - Network errors
|
|
9582
|
+
* - Timeouts
|
|
9583
|
+
* - 5xx server errors
|
|
9584
|
+
* - TOKEN_EXPIRED (normal refresh trigger)
|
|
9585
|
+
*/
|
|
9586
|
+
isDefinitiveAuthFailure(error) {
|
|
9587
|
+
if (!error)
|
|
9588
|
+
return false;
|
|
9589
|
+
const errorAny = error;
|
|
9590
|
+
// Check error code property against canonical list
|
|
9591
|
+
if (errorAny?.code && this.isFatalAuthError(errorAny.code)) {
|
|
9592
|
+
return true;
|
|
9593
|
+
}
|
|
9594
|
+
// Check if error message contains fatal keywords (fallback for wrapped errors)
|
|
9595
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
9596
|
+
if (isFatalAuthErrorInMessage(errorMessage)) {
|
|
9597
|
+
return true;
|
|
9598
|
+
}
|
|
9599
|
+
// Check HTTP status if available
|
|
9600
|
+
const status = errorAny?.status || errorAny?.response?.status;
|
|
9601
|
+
if (status === 401 || status === 403) {
|
|
9602
|
+
return true;
|
|
9603
|
+
}
|
|
9604
|
+
// Everything else is considered retryable
|
|
9605
|
+
return false;
|
|
9606
|
+
}
|
|
9607
|
+
/**
|
|
9608
|
+
* Handle authentication failure with provider token recovery.
|
|
9609
|
+
*
|
|
9610
|
+
* Before logging out, attempts to recover the session using the stored
|
|
9611
|
+
* provider token (Firebase JWT, etc.) if it's still valid.
|
|
9612
|
+
*
|
|
9613
|
+
* Recovery Flow:
|
|
9614
|
+
* 1. Check if provider token exists and is not expired
|
|
9615
|
+
* 2. Get the auth type (USER, BUSINESS, TENANT) to know which login to use
|
|
9616
|
+
* 3. Attempt re-authentication with provider token
|
|
9617
|
+
* 4. If successful: Session recovered, stay logged in
|
|
9618
|
+
* 5. If failed: Clear tokens and emit AUTH_FAILED
|
|
9490
9619
|
*/
|
|
9491
9620
|
async handleAuthFailure() {
|
|
9492
9621
|
// Already failed - nothing to do
|
|
@@ -9502,7 +9631,7 @@ class AuthService {
|
|
|
9502
9631
|
return await this.activeAuthFailurePromise;
|
|
9503
9632
|
}
|
|
9504
9633
|
// Create the failure promise to deduplicate concurrent calls
|
|
9505
|
-
this.activeAuthFailurePromise = this.
|
|
9634
|
+
this.activeAuthFailurePromise = this.performAuthFailureWithRecovery();
|
|
9506
9635
|
try {
|
|
9507
9636
|
await this.activeAuthFailurePromise;
|
|
9508
9637
|
}
|
|
@@ -9510,6 +9639,72 @@ class AuthService {
|
|
|
9510
9639
|
this.activeAuthFailurePromise = null;
|
|
9511
9640
|
}
|
|
9512
9641
|
}
|
|
9642
|
+
/**
|
|
9643
|
+
* Attempts to recover session using provider token before failing.
|
|
9644
|
+
*/
|
|
9645
|
+
async performAuthFailureWithRecovery() {
|
|
9646
|
+
// Clear active refresh operations
|
|
9647
|
+
this.activeRefreshPromise = null;
|
|
9648
|
+
// Try to recover using provider token
|
|
9649
|
+
const recovered = await this.attemptProviderTokenRecovery();
|
|
9650
|
+
if (recovered) {
|
|
9651
|
+
return; // Session recovered, don't fail
|
|
9652
|
+
}
|
|
9653
|
+
// Recovery failed - perform actual logout
|
|
9654
|
+
await this.performAuthFailure();
|
|
9655
|
+
}
|
|
9656
|
+
/**
|
|
9657
|
+
* Attempt to recover session using stored provider token.
|
|
9658
|
+
* Extracts context (tenantId/businessId) from the current refresh token to ensure
|
|
9659
|
+
* the recovered session maintains the same context.
|
|
9660
|
+
*
|
|
9661
|
+
* @returns true if session was successfully recovered
|
|
9662
|
+
*/
|
|
9663
|
+
async attemptProviderTokenRecovery() {
|
|
9664
|
+
const extended = this.extendedProvider;
|
|
9665
|
+
if (!extended?.getProviderToken || !extended?.getAuthType) {
|
|
9666
|
+
return false; // Provider doesn't support recovery
|
|
9667
|
+
}
|
|
9668
|
+
try {
|
|
9669
|
+
const providerToken = await extended.getProviderToken();
|
|
9670
|
+
if (!providerToken) {
|
|
9671
|
+
return false;
|
|
9672
|
+
}
|
|
9673
|
+
// Check if provider token is still valid (with small margin)
|
|
9674
|
+
if (isTokenExpired(providerToken, 30)) {
|
|
9675
|
+
return false;
|
|
9676
|
+
}
|
|
9677
|
+
const authType = await extended.getAuthType();
|
|
9678
|
+
if (!authType) {
|
|
9679
|
+
return false;
|
|
9680
|
+
}
|
|
9681
|
+
// Extract context (tenantId/businessId) from the current refresh token
|
|
9682
|
+
// This ensures multi-tenant/multi-business users recover to the same context
|
|
9683
|
+
const refreshToken = await this.authProvider?.getRefreshToken?.();
|
|
9684
|
+
const contextClaims = refreshToken ? decodeJwtPayload(refreshToken) : null;
|
|
9685
|
+
// Re-authenticate using provider token based on auth type
|
|
9686
|
+
// Pass the context from the previous session to avoid MULTIPLE_CONTEXT_SELECTION_REQUIRED
|
|
9687
|
+
switch (authType) {
|
|
9688
|
+
case exports.AccountOwnerType.USER:
|
|
9689
|
+
await this.loginUser(providerToken);
|
|
9690
|
+
break;
|
|
9691
|
+
case exports.AccountOwnerType.BUSINESS:
|
|
9692
|
+
await this.loginBusiness(providerToken, contextClaims?.businessId ? { businessId: contextClaims.businessId } : undefined);
|
|
9693
|
+
break;
|
|
9694
|
+
case exports.AccountOwnerType.TENANT:
|
|
9695
|
+
await this.loginTenantAdmin(providerToken, contextClaims?.tenantId ? { tenantId: contextClaims.tenantId } : undefined);
|
|
9696
|
+
break;
|
|
9697
|
+
default:
|
|
9698
|
+
console.warn(`[AuthService] Unknown auth type for recovery: ${authType}`);
|
|
9699
|
+
return false;
|
|
9700
|
+
}
|
|
9701
|
+
return true; // Recovery successful
|
|
9702
|
+
}
|
|
9703
|
+
catch (error) {
|
|
9704
|
+
console.warn('[AuthService] Provider token recovery failed:', error);
|
|
9705
|
+
return false;
|
|
9706
|
+
}
|
|
9707
|
+
}
|
|
9513
9708
|
/**
|
|
9514
9709
|
* Performs the actual auth failure cleanup
|
|
9515
9710
|
*/
|
|
@@ -9589,16 +9784,32 @@ class AuthService {
|
|
|
9589
9784
|
|
|
9590
9785
|
/**
|
|
9591
9786
|
* Token Refresh Manager
|
|
9592
|
-
*
|
|
9787
|
+
* Industry-standard token lifecycle management with:
|
|
9788
|
+
* - Dynamic refresh margin based on token lifetime
|
|
9789
|
+
* - Retry logic for transient failures
|
|
9790
|
+
* - Only logout on definitive auth failures (not network errors)
|
|
9593
9791
|
*/
|
|
9594
9792
|
class TokenRefreshManager {
|
|
9595
9793
|
constructor(authService, authProvider) {
|
|
9596
9794
|
this.authService = authService;
|
|
9597
9795
|
this.authProvider = authProvider;
|
|
9598
|
-
|
|
9796
|
+
// Industry standard: refresh when 25% of token lifetime remains
|
|
9797
|
+
this.REFRESH_THRESHOLD_PERCENT = 0.25;
|
|
9798
|
+
// Minimum margin regardless of token lifetime
|
|
9799
|
+
this.MIN_REFRESH_MARGIN_SECONDS = 30;
|
|
9800
|
+
// Maximum margin (cap for very long-lived tokens)
|
|
9801
|
+
this.MAX_REFRESH_MARGIN_SECONDS = 300; // 5 minutes
|
|
9802
|
+
// Validation cache to avoid redundant checks
|
|
9599
9803
|
this.lastValidationTime = 0;
|
|
9600
9804
|
this.validationCacheDurationMs = 30000; // 30 seconds
|
|
9805
|
+
// Retry configuration for transient failures
|
|
9806
|
+
this.MAX_RETRY_ATTEMPTS = 3;
|
|
9807
|
+
this.RETRY_DELAY_MS = 1000;
|
|
9601
9808
|
}
|
|
9809
|
+
/**
|
|
9810
|
+
* Ensures the access token is valid, refreshing if needed.
|
|
9811
|
+
* Uses dynamic margin based on token lifetime (industry standard).
|
|
9812
|
+
*/
|
|
9602
9813
|
async ensureValidToken() {
|
|
9603
9814
|
const now = Date.now();
|
|
9604
9815
|
// Skip validation if we checked recently (within cache duration)
|
|
@@ -9610,35 +9821,95 @@ class TokenRefreshManager {
|
|
|
9610
9821
|
if (!token) {
|
|
9611
9822
|
return;
|
|
9612
9823
|
}
|
|
9613
|
-
|
|
9614
|
-
|
|
9615
|
-
|
|
9824
|
+
// Calculate dynamic refresh margin based on token lifetime
|
|
9825
|
+
const marginSeconds = this.calculateRefreshMargin(token);
|
|
9826
|
+
if (isTokenExpired(token, marginSeconds)) {
|
|
9827
|
+
const result = await this.attemptRefreshWithRetry();
|
|
9828
|
+
if (!result.success) {
|
|
9829
|
+
if (result.retryable) {
|
|
9830
|
+
// Transient error - don't logout, let next request retry
|
|
9831
|
+
console.warn('[TokenRefreshManager] Refresh failed with retryable error, will retry on next request');
|
|
9832
|
+
return;
|
|
9833
|
+
}
|
|
9834
|
+
// Definitive auth failure - logout required
|
|
9616
9835
|
await this.authService.handleAuthFailure();
|
|
9617
9836
|
}
|
|
9618
9837
|
else {
|
|
9619
|
-
//
|
|
9838
|
+
// Refresh successful
|
|
9620
9839
|
this.lastValidationTime = now;
|
|
9621
9840
|
}
|
|
9622
9841
|
}
|
|
9623
9842
|
else {
|
|
9624
|
-
// Token is valid
|
|
9843
|
+
// Token is valid
|
|
9625
9844
|
this.lastValidationTime = now;
|
|
9626
9845
|
}
|
|
9627
9846
|
}
|
|
9628
9847
|
catch (error) {
|
|
9629
|
-
|
|
9848
|
+
// Unexpected error - log but don't logout (conservative approach)
|
|
9849
|
+
console.error('[TokenRefreshManager] Unexpected error during token validation:', error);
|
|
9630
9850
|
}
|
|
9631
9851
|
}
|
|
9632
|
-
|
|
9633
|
-
|
|
9634
|
-
|
|
9635
|
-
|
|
9852
|
+
/**
|
|
9853
|
+
* Calculate refresh margin dynamically based on token lifetime.
|
|
9854
|
+
* Industry standard: refresh when 25% of lifetime remains.
|
|
9855
|
+
*/
|
|
9856
|
+
calculateRefreshMargin(token) {
|
|
9857
|
+
const ttlSeconds = getTokenTimeToLive(token);
|
|
9858
|
+
if (ttlSeconds === null || ttlSeconds <= 0) {
|
|
9859
|
+
// Can't determine TTL, use minimum margin
|
|
9860
|
+
return this.MIN_REFRESH_MARGIN_SECONDS;
|
|
9636
9861
|
}
|
|
9637
|
-
|
|
9638
|
-
|
|
9639
|
-
|
|
9640
|
-
|
|
9862
|
+
// Calculate 25% of remaining lifetime
|
|
9863
|
+
const dynamicMargin = Math.floor(ttlSeconds * this.REFRESH_THRESHOLD_PERCENT);
|
|
9864
|
+
// Clamp between min and max
|
|
9865
|
+
return Math.max(this.MIN_REFRESH_MARGIN_SECONDS, Math.min(dynamicMargin, this.MAX_REFRESH_MARGIN_SECONDS));
|
|
9866
|
+
}
|
|
9867
|
+
/**
|
|
9868
|
+
* Attempt token refresh with retry logic for transient failures.
|
|
9869
|
+
* Only gives up on definitive auth errors.
|
|
9870
|
+
*/
|
|
9871
|
+
async attemptRefreshWithRetry() {
|
|
9872
|
+
let lastError;
|
|
9873
|
+
for (let attempt = 1; attempt <= this.MAX_RETRY_ATTEMPTS; attempt++) {
|
|
9874
|
+
try {
|
|
9875
|
+
await this.authService.refreshAccessToken();
|
|
9876
|
+
return { success: true, retryable: false };
|
|
9877
|
+
}
|
|
9878
|
+
catch (error) {
|
|
9879
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
9880
|
+
// Check if this is a definitive auth failure (non-retryable)
|
|
9881
|
+
if (this.isDefinitiveAuthFailure(error)) {
|
|
9882
|
+
console.warn(`[TokenRefreshManager] Definitive auth failure: ${lastError.message}`);
|
|
9883
|
+
return { success: false, retryable: false, error: lastError };
|
|
9884
|
+
}
|
|
9885
|
+
// Retryable error - wait and try again
|
|
9886
|
+
if (attempt < this.MAX_RETRY_ATTEMPTS) {
|
|
9887
|
+
await this.delay(this.RETRY_DELAY_MS * attempt); // Exponential backoff
|
|
9888
|
+
}
|
|
9889
|
+
}
|
|
9641
9890
|
}
|
|
9891
|
+
// All retries exhausted - return as retryable so we don't logout
|
|
9892
|
+
console.warn(`[TokenRefreshManager] All ${this.MAX_RETRY_ATTEMPTS} refresh attempts failed`);
|
|
9893
|
+
return { success: false, retryable: true, error: lastError };
|
|
9894
|
+
}
|
|
9895
|
+
/**
|
|
9896
|
+
* Determine if an error is a definitive auth failure that requires logout.
|
|
9897
|
+
* Network errors, timeouts, 5xx errors are NOT definitive - they're retryable.
|
|
9898
|
+
*
|
|
9899
|
+
* Delegates to AuthService.isDefinitiveAuthFailure() - the SINGLE SOURCE OF TRUTH.
|
|
9900
|
+
*/
|
|
9901
|
+
isDefinitiveAuthFailure(error) {
|
|
9902
|
+
return this.authService.isDefinitiveAuthFailure(error);
|
|
9903
|
+
}
|
|
9904
|
+
delay(ms) {
|
|
9905
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
9906
|
+
}
|
|
9907
|
+
/**
|
|
9908
|
+
* @deprecated Use ensureValidToken() instead
|
|
9909
|
+
*/
|
|
9910
|
+
async attemptInternalRefresh() {
|
|
9911
|
+
const result = await this.attemptRefreshWithRetry();
|
|
9912
|
+
return result.success;
|
|
9642
9913
|
}
|
|
9643
9914
|
}
|
|
9644
9915
|
|
|
@@ -9786,6 +10057,22 @@ class AuthTokenManager {
|
|
|
9786
10057
|
this.cache = {};
|
|
9787
10058
|
await this.storage.clear();
|
|
9788
10059
|
}
|
|
10060
|
+
/**
|
|
10061
|
+
* Clears only auth tokens (access, refresh, provider, authType), preserving DPoP keys.
|
|
10062
|
+
* Use this when DPoP keys are regenerated - we want to invalidate auth tokens
|
|
10063
|
+
* that were bound to old keys, but keep the newly generated DPoP keys.
|
|
10064
|
+
*/
|
|
10065
|
+
async clearAuthTokens() {
|
|
10066
|
+
// Clear cache
|
|
10067
|
+
this.cache = {};
|
|
10068
|
+
// Remove all auth tokens and auth type, NOT DPoP keys
|
|
10069
|
+
await Promise.all([
|
|
10070
|
+
this.storage.remove(AUTH_STORAGE_KEYS.ACCESS_TOKEN),
|
|
10071
|
+
this.storage.remove(AUTH_STORAGE_KEYS.REFRESH_TOKEN),
|
|
10072
|
+
this.storage.remove(AUTH_STORAGE_KEYS.PROVIDER_TOKEN),
|
|
10073
|
+
this.storage.remove(AUTH_STORAGE_KEYS.AUTH_TYPE),
|
|
10074
|
+
]);
|
|
10075
|
+
}
|
|
9789
10076
|
async hasAccessToken() {
|
|
9790
10077
|
// Use cached value if available to avoid storage read
|
|
9791
10078
|
if (this.cache.accessToken !== undefined) {
|
|
@@ -9816,13 +10103,12 @@ class IndexedDBTokenStorage {
|
|
|
9816
10103
|
constructor() {
|
|
9817
10104
|
this.supportsObjects = true;
|
|
9818
10105
|
this.dbPromise = null;
|
|
9819
|
-
|
|
9820
|
-
console.warn('IndexedDB is not available in this environment');
|
|
9821
|
-
}
|
|
10106
|
+
// IndexedDB availability is checked lazily on first operation
|
|
9822
10107
|
}
|
|
9823
10108
|
getDB() {
|
|
9824
|
-
if (this.dbPromise)
|
|
10109
|
+
if (this.dbPromise) {
|
|
9825
10110
|
return this.dbPromise;
|
|
10111
|
+
}
|
|
9826
10112
|
this.dbPromise = new Promise((resolve, reject) => {
|
|
9827
10113
|
if (typeof indexedDB === 'undefined') {
|
|
9828
10114
|
return reject(new Error('IndexedDB not supported'));
|
|
@@ -9850,8 +10136,12 @@ class IndexedDBTokenStorage {
|
|
|
9850
10136
|
const transaction = db.transaction(IndexedDBTokenStorage.STORE_NAME, 'readonly');
|
|
9851
10137
|
const store = transaction.objectStore(IndexedDBTokenStorage.STORE_NAME);
|
|
9852
10138
|
const request = store.get(key);
|
|
9853
|
-
request.onsuccess = () =>
|
|
9854
|
-
|
|
10139
|
+
request.onsuccess = () => {
|
|
10140
|
+
resolve(request.result || null);
|
|
10141
|
+
};
|
|
10142
|
+
request.onerror = () => {
|
|
10143
|
+
reject(request.error);
|
|
10144
|
+
};
|
|
9855
10145
|
});
|
|
9856
10146
|
}
|
|
9857
10147
|
catch (e) {
|
|
@@ -10047,6 +10337,8 @@ class DPoPManager {
|
|
|
10047
10337
|
constructor(storage, cryptoProvider, callbacks) {
|
|
10048
10338
|
this.storage = storage;
|
|
10049
10339
|
this.memoryKeyPair = null;
|
|
10340
|
+
/** Pending key pair promise to prevent concurrent generation race conditions */
|
|
10341
|
+
this.pendingKeyPair = null;
|
|
10050
10342
|
this.cryptoProvider = cryptoProvider || new WebDPoPCryptoProvider();
|
|
10051
10343
|
this.callbacks = callbacks || {};
|
|
10052
10344
|
}
|
|
@@ -10055,8 +10347,9 @@ class DPoPManager {
|
|
|
10055
10347
|
* Useful for detecting if a key regeneration would be needed.
|
|
10056
10348
|
*/
|
|
10057
10349
|
async hasStoredKeys() {
|
|
10058
|
-
if (this.memoryKeyPair)
|
|
10350
|
+
if (this.memoryKeyPair) {
|
|
10059
10351
|
return true;
|
|
10352
|
+
}
|
|
10060
10353
|
const storedPublic = await this.storage.get(DPOP_STORAGE_KEYS.PUBLIC);
|
|
10061
10354
|
const storedPrivate = await this.storage.get(DPOP_STORAGE_KEYS.PRIVATE);
|
|
10062
10355
|
return !!(storedPublic && storedPrivate);
|
|
@@ -10070,8 +10363,35 @@ class DPoPManager {
|
|
|
10070
10363
|
* match refresh token binding" errors.
|
|
10071
10364
|
*/
|
|
10072
10365
|
async ensureKeyPair() {
|
|
10073
|
-
|
|
10366
|
+
// Fast path: already have keys in memory
|
|
10367
|
+
if (this.memoryKeyPair) {
|
|
10368
|
+
return this.memoryKeyPair;
|
|
10369
|
+
}
|
|
10370
|
+
// Prevent concurrent key generation race condition:
|
|
10371
|
+
// If another call is already generating keys, wait for it instead of starting a new one
|
|
10372
|
+
if (this.pendingKeyPair) {
|
|
10373
|
+
return this.pendingKeyPair;
|
|
10374
|
+
}
|
|
10375
|
+
// Create a promise that subsequent callers will wait on
|
|
10376
|
+
this.pendingKeyPair = this.loadOrGenerateKeyPair();
|
|
10377
|
+
try {
|
|
10378
|
+
const keyPair = await this.pendingKeyPair;
|
|
10379
|
+
return keyPair;
|
|
10380
|
+
}
|
|
10381
|
+
finally {
|
|
10382
|
+
// Clear pending promise so future calls can proceed
|
|
10383
|
+
this.pendingKeyPair = null;
|
|
10384
|
+
}
|
|
10385
|
+
}
|
|
10386
|
+
/**
|
|
10387
|
+
* Internal method that actually loads or generates keys.
|
|
10388
|
+
* Called only once per concurrent batch of ensureKeyPair() calls.
|
|
10389
|
+
*/
|
|
10390
|
+
async loadOrGenerateKeyPair() {
|
|
10391
|
+
// Double-check in case keys were set while waiting
|
|
10392
|
+
if (this.memoryKeyPair) {
|
|
10074
10393
|
return this.memoryKeyPair;
|
|
10394
|
+
}
|
|
10075
10395
|
const storedPublic = await this.storage.get(DPOP_STORAGE_KEYS.PUBLIC);
|
|
10076
10396
|
const storedPrivate = await this.storage.get(DPOP_STORAGE_KEYS.PRIVATE);
|
|
10077
10397
|
if (storedPublic && storedPrivate) {
|
|
@@ -10083,18 +10403,23 @@ class DPoPManager {
|
|
|
10083
10403
|
return this.memoryKeyPair;
|
|
10084
10404
|
}
|
|
10085
10405
|
catch (e) {
|
|
10086
|
-
console.warn('[DPoPManager] Corrupted DPoP keys in storage, regenerating
|
|
10406
|
+
console.warn('[DPoPManager] Corrupted DPoP keys in storage, regenerating');
|
|
10087
10407
|
}
|
|
10088
10408
|
}
|
|
10089
10409
|
// Generate new key pair
|
|
10090
10410
|
// IMPORTANT: This invalidates any existing refresh tokens bound to the old keys
|
|
10091
|
-
|
|
10092
|
-
// Adaptation: If storage supports raw objects (like IndexedDB or Native Keychain),
|
|
10411
|
+
// Adaptation: If storage supports raw objects (like Native Keychain on iOS/Android),
|
|
10093
10412
|
// we can generate Non-Extractable keys for maximum security.
|
|
10094
|
-
// If storage is text-only (LocalStorage)
|
|
10413
|
+
// If storage is text-only (LocalStorage) OR IndexedDB (CryptoKey doesn't survive reload),
|
|
10414
|
+
// we must use Extractable keys to serialize them as JWK.
|
|
10415
|
+
//
|
|
10416
|
+
// NOTE: IndexedDB CAN store CryptoKey objects, but they don't survive page reloads -
|
|
10417
|
+
// they come back as empty/corrupted objects. Only native secure storage can persist
|
|
10418
|
+
// non-extractable keys. Frontend should set supportsObjects: false for web storage.
|
|
10095
10419
|
const useHighSecurity = !!this.storage.supportsObjects;
|
|
10420
|
+
const extractable = !useHighSecurity;
|
|
10096
10421
|
const keyPair = await this.cryptoProvider.generateKeyPair({
|
|
10097
|
-
extractable
|
|
10422
|
+
extractable
|
|
10098
10423
|
});
|
|
10099
10424
|
// Save to storage
|
|
10100
10425
|
await this.storage.set(DPOP_STORAGE_KEYS.PUBLIC, keyPair.publicKey);
|
|
@@ -10128,6 +10453,21 @@ class DPoPManager {
|
|
|
10128
10453
|
}
|
|
10129
10454
|
return this.cryptoProvider.signProof(payload, keyPair);
|
|
10130
10455
|
}
|
|
10456
|
+
/**
|
|
10457
|
+
* Generate a short fingerprint of the public key for debugging
|
|
10458
|
+
* This helps identify if the same key is being used across operations
|
|
10459
|
+
*/
|
|
10460
|
+
getPublicKeyFingerprint(publicKey) {
|
|
10461
|
+
try {
|
|
10462
|
+
// Use the 'x' coordinate of the EC key as a fingerprint
|
|
10463
|
+
const x = publicKey.x || '';
|
|
10464
|
+
const y = publicKey.y || '';
|
|
10465
|
+
return `${x.substring(0, 8)}...${y.substring(0, 4)}`;
|
|
10466
|
+
}
|
|
10467
|
+
catch {
|
|
10468
|
+
return 'unknown';
|
|
10469
|
+
}
|
|
10470
|
+
}
|
|
10131
10471
|
/**
|
|
10132
10472
|
* Clears DPoP keys (e.g. on logout)
|
|
10133
10473
|
*/
|
|
@@ -10176,13 +10516,15 @@ class DefaultAuthProvider {
|
|
|
10176
10516
|
this.tokenManager = new AuthTokenManager(storage);
|
|
10177
10517
|
if (this.dpopEnabled) {
|
|
10178
10518
|
this.dpopManager = new DPoPManager(storage, config.dpop?.cryptoProvider, {
|
|
10179
|
-
// When DPoP keys are regenerated (corrupted/missing),
|
|
10180
|
-
// bound to the old keys
|
|
10181
|
-
//
|
|
10519
|
+
// When DPoP keys are regenerated (corrupted/missing), auth tokens
|
|
10520
|
+
// bound to the old keys become invalid. Clear only auth tokens, NOT DPoP keys.
|
|
10521
|
+
// Access token has cnf claim binding it to the old public key.
|
|
10522
|
+
// Refresh token has DPoP binding to the old key pair.
|
|
10523
|
+
// NOTE: Do NOT call clearAllTokens() here - it would wipe the freshly saved DPoP keys!
|
|
10182
10524
|
onKeysRegenerated: () => {
|
|
10183
|
-
|
|
10184
|
-
this.tokenManager.
|
|
10185
|
-
console.warn('[DefaultAuthProvider] Failed to clear
|
|
10525
|
+
// Clear only auth tokens, preserve DPoP keys (they were just regenerated and saved)
|
|
10526
|
+
this.tokenManager.clearAuthTokens().catch(err => {
|
|
10527
|
+
console.warn('[DefaultAuthProvider] Failed to clear tokens after key regeneration:', err);
|
|
10186
10528
|
});
|
|
10187
10529
|
}
|
|
10188
10530
|
});
|
|
@@ -10324,6 +10666,41 @@ class DefaultAuthProvider {
|
|
|
10324
10666
|
return null;
|
|
10325
10667
|
}
|
|
10326
10668
|
}
|
|
10669
|
+
/**
|
|
10670
|
+
* Store the provider token (Firebase JWT, Auth0 token, etc.)
|
|
10671
|
+
* Used for session recovery when refresh tokens expire.
|
|
10672
|
+
*
|
|
10673
|
+
* NOTE: This is called AUTOMATICALLY by loginWithToken(), so you typically
|
|
10674
|
+
* don't need to call this manually. The only reason to call this is if:
|
|
10675
|
+
*
|
|
10676
|
+
* 1. Your auth provider (Firebase, Auth0) refreshes tokens in the background
|
|
10677
|
+
* 2. You want to proactively update the stored token for better recovery chances
|
|
10678
|
+
*
|
|
10679
|
+
* The stored provider token is used as a fallback when:
|
|
10680
|
+
* - PERS refresh token expires (e.g., after 30 days of inactivity)
|
|
10681
|
+
* - SDK attempts recovery using the stored provider token
|
|
10682
|
+
* - If provider token is still valid → session recovered automatically
|
|
10683
|
+
* - If provider token is also expired → user must re-authenticate
|
|
10684
|
+
*
|
|
10685
|
+
* @example
|
|
10686
|
+
* // Optional: Update stored token when Firebase refreshes it
|
|
10687
|
+
* firebase.auth().onIdTokenChanged(async (user) => {
|
|
10688
|
+
* if (user) {
|
|
10689
|
+
* const freshToken = await user.getIdToken();
|
|
10690
|
+
* await sdk.auth.setProviderToken(freshToken);
|
|
10691
|
+
* }
|
|
10692
|
+
* });
|
|
10693
|
+
*/
|
|
10694
|
+
async setProviderToken(token) {
|
|
10695
|
+
await this.tokenManager.setProviderToken(token);
|
|
10696
|
+
}
|
|
10697
|
+
/**
|
|
10698
|
+
* Get the stored provider token (Firebase JWT, Auth0 token, etc.)
|
|
10699
|
+
* Used internally for session recovery when refresh tokens expire.
|
|
10700
|
+
*/
|
|
10701
|
+
async getProviderToken() {
|
|
10702
|
+
return this.tokenManager.getProviderToken();
|
|
10703
|
+
}
|
|
10327
10704
|
}
|
|
10328
10705
|
|
|
10329
10706
|
/**
|
|
@@ -10339,7 +10716,7 @@ class DefaultAuthProvider {
|
|
|
10339
10716
|
/** SDK package name */
|
|
10340
10717
|
const SDK_NAME = "@explorins/pers-sdk";
|
|
10341
10718
|
/** SDK version - injected from package.json at build time */
|
|
10342
|
-
const SDK_VERSION = "2.1.
|
|
10719
|
+
const SDK_VERSION = "2.1.33";
|
|
10343
10720
|
/** Full SDK identifier for headers */
|
|
10344
10721
|
const SDK_USER_AGENT = `${SDK_NAME}/${SDK_VERSION}`;
|
|
10345
10722
|
|
|
@@ -10488,7 +10865,9 @@ class PersApiClient {
|
|
|
10488
10865
|
// Handle 401 errors centrally through AuthService
|
|
10489
10866
|
if (status === 401) {
|
|
10490
10867
|
const backendError = ErrorUtils.extractBackendErrorDetails(error);
|
|
10491
|
-
// Check for
|
|
10868
|
+
// Check for FATAL auth error CODES (not just 401 status)
|
|
10869
|
+
// Fatal codes: REFRESH_TOKEN_EXPIRED, TOKEN_REVOKED, etc. → immediate logout
|
|
10870
|
+
// Non-fatal codes: TOKEN_EXPIRED → try refresh first
|
|
10492
10871
|
if (this.authService.isFatalAuthError(backendError.code)) {
|
|
10493
10872
|
// Definitive auth failure - no retry, session is invalid
|
|
10494
10873
|
await this.authService.handleAuthFailure();
|
|
@@ -16615,6 +16994,55 @@ class AnalyticsManager {
|
|
|
16615
16994
|
async getCampaignClaimAnalytics(request) {
|
|
16616
16995
|
return this.analyticsService.getCampaignClaimAnalytics(request);
|
|
16617
16996
|
}
|
|
16997
|
+
/**
|
|
16998
|
+
* Get redemption redeem analytics with aggregation
|
|
16999
|
+
*
|
|
17000
|
+
* Retrieves aggregated redemption redeem data with flexible grouping and metrics.
|
|
17001
|
+
* Perfect for charts, dashboards, and redeem pattern analysis across redemptions,
|
|
17002
|
+
* businesses, and time periods.
|
|
17003
|
+
*
|
|
17004
|
+
* @param request - Analytics request with filters, groupBy, and metrics
|
|
17005
|
+
* @returns Promise resolving to redemption redeem analytics data
|
|
17006
|
+
*
|
|
17007
|
+
* @example Redeems per redemption
|
|
17008
|
+
* ```typescript
|
|
17009
|
+
* const analytics = await sdk.analytics.getRedemptionRedeemAnalytics({
|
|
17010
|
+
* groupBy: ['redemptionId'],
|
|
17011
|
+
* metrics: ['count'],
|
|
17012
|
+
* sortBy: 'count',
|
|
17013
|
+
* sortOrder: 'DESC',
|
|
17014
|
+
* limit: 10
|
|
17015
|
+
* });
|
|
17016
|
+
*
|
|
17017
|
+
* console.log('Top redemptions by redeems:', analytics.results);
|
|
17018
|
+
* ```
|
|
17019
|
+
*
|
|
17020
|
+
* @example Redeems by status over time
|
|
17021
|
+
* ```typescript
|
|
17022
|
+
* const analytics = await sdk.analytics.getRedemptionRedeemAnalytics({
|
|
17023
|
+
* groupBy: ['month', 'status'],
|
|
17024
|
+
* metrics: ['count'],
|
|
17025
|
+
* sortBy: 'month',
|
|
17026
|
+
* sortOrder: 'DESC',
|
|
17027
|
+
* startDate: new Date('2026-01-01'),
|
|
17028
|
+
* endDate: new Date('2026-12-31')
|
|
17029
|
+
* });
|
|
17030
|
+
* ```
|
|
17031
|
+
*
|
|
17032
|
+
* @example Redeems by business
|
|
17033
|
+
* ```typescript
|
|
17034
|
+
* const analytics = await sdk.analytics.getRedemptionRedeemAnalytics({
|
|
17035
|
+
* filters: { status: 'COMPLETED' },
|
|
17036
|
+
* groupBy: ['businessId'],
|
|
17037
|
+
* metrics: ['count'],
|
|
17038
|
+
* sortBy: 'count',
|
|
17039
|
+
* sortOrder: 'DESC'
|
|
17040
|
+
* });
|
|
17041
|
+
* ```
|
|
17042
|
+
*/
|
|
17043
|
+
async getRedemptionRedeemAnalytics(request) {
|
|
17044
|
+
return this.analyticsService.getRedemptionRedeemAnalytics(request);
|
|
17045
|
+
}
|
|
16618
17046
|
/**
|
|
16619
17047
|
* Get user analytics with engagement metrics
|
|
16620
17048
|
*
|
|
@@ -20170,7 +20598,7 @@ const PersSDKProvider = ({ children, config }) => {
|
|
|
20170
20598
|
setIsAuthenticated(true);
|
|
20171
20599
|
}
|
|
20172
20600
|
}
|
|
20173
|
-
if (event.type === 'logout_success' || event.type === 'session_restoration_failed'
|
|
20601
|
+
if (event.type === 'logout_success' || event.type === 'session_restoration_failed') {
|
|
20174
20602
|
setUser(null);
|
|
20175
20603
|
setIsAuthenticated(false);
|
|
20176
20604
|
}
|
|
@@ -45797,6 +46225,7 @@ exports.DonationService = DonationService;
|
|
|
45797
46225
|
exports.EntityTypes = EntityTypes;
|
|
45798
46226
|
exports.ErrorDomains = ErrorDomains;
|
|
45799
46227
|
exports.ErrorUtils = ErrorUtils;
|
|
46228
|
+
exports.FATAL_AUTH_CODES = FATAL_AUTH_CODES;
|
|
45800
46229
|
exports.FileApi = FileApi;
|
|
45801
46230
|
exports.FileManager = FileManager;
|
|
45802
46231
|
exports.FileService = FileService;
|
|
@@ -45902,6 +46331,7 @@ exports.initializeReactNativePolyfills = initializeReactNativePolyfills;
|
|
|
45902
46331
|
exports.isConnectedMessage = isConnectedMessage;
|
|
45903
46332
|
exports.isErrorMessage = isErrorMessage;
|
|
45904
46333
|
exports.isEventMessage = isEventMessage;
|
|
46334
|
+
exports.isFatalAuthErrorInMessage = isFatalAuthErrorInMessage;
|
|
45905
46335
|
exports.isPaginatedResponse = isPaginatedResponse;
|
|
45906
46336
|
exports.isPingMessage = isPingMessage;
|
|
45907
46337
|
exports.isServerMessage = isServerMessage;
|