@sendoracloud/sdk-react-native 0.4.0 → 0.7.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/index.d.cts CHANGED
@@ -21,9 +21,11 @@ declare class Storage {
21
21
  set(key: string, value: string): void;
22
22
  remove(key: string): void;
23
23
  /**
24
- * Remove every sendora_*-prefixed key we know about from both
25
- * memory and AsyncStorage. Used by reset() so no stale identity
26
- * survives a logout, including keys future SDK versions might add.
24
+ * Remove every sendora_*-prefixed key from both memory AND
25
+ * AsyncStorage. Uses getAllKeys() to find keys this process never
26
+ * cached (e.g. orphaned writes from a prior SDK version that added
27
+ * a key not in our hydrate list). Falls back to cache enumeration
28
+ * if the AsyncStorage backend doesn't expose getAllKeys.
27
29
  */
28
30
  clearAll(): Promise<void>;
29
31
  }
@@ -39,12 +41,25 @@ declare class Storage {
39
41
  * user_id is preserved on success. Throws
40
42
  * EmailAlreadyTakenError on 409. Otherwise calls
41
43
  * /auth-service/signup as a fresh account.
42
- * signIn(email, password) — POST /auth-service/login. If the SDK
43
- * was anonymous, wipes local identity (anonId rotated, userId
44
- * cleared, tokens dropped) BEFORE installing the new session.
44
+ * signIn(email, password) — POST /auth-service/login. Wipes local
45
+ * identity (anonId rotated, userId cleared, tokens dropped)
46
+ * BEFORE the network call so any track() fired during the
47
+ * round-trip can't attach to the prior identity.
45
48
  *
46
- * Tokens persist in AsyncStorage so a cold app launch re-hydrates
47
- * the active session.
49
+ * All public auth ops are serialized through a single-flight chain
50
+ * so a UI double-submit can't mint two anonymous users or interleave
51
+ * a signIn + signOut.
52
+ *
53
+ * Tokens persist via the configured secure-storage adapter (defaults
54
+ * to AsyncStorage; consumers handling sensitive accounts should pass
55
+ * a Keychain / SecureStore-backed adapter via `secureStorage`).
56
+ *
57
+ * Response payloads are validated non-empty before persisting so a
58
+ * malformed/MITM'd response can't install an `id = ""` user.
59
+ *
60
+ * Access token expiry is tracked locally — getAccessToken() returns
61
+ * null past expiry and triggers a single-flight refresh on the next
62
+ * call.
48
63
  */
49
64
 
50
65
  interface AuthUser {
@@ -74,24 +89,36 @@ interface AuthHooks {
74
89
  * identity (analytics userId, push token registration, etc). */
75
90
  onIdentityChange: (userId: string | null) => void;
76
91
  /**
77
- * Called when the SDK switches from an anonymous user to a real
78
- * account via signIn(). The parent rotates anonId so events from
79
- * the new user can't carry over orphaned attribution.
92
+ * Called when the SDK switches from one identity to another via
93
+ * signIn() or non-anonymous signUp(). The parent rotates anonId
94
+ * + drops any pending events so the new user can't carry over
95
+ * orphaned attribution.
80
96
  */
81
97
  onAnonymousWipe: () => Promise<void>;
82
98
  apiUrl: string;
83
99
  publicKey: string;
100
+ debug: boolean;
84
101
  }
85
102
  declare class Auth {
86
103
  private hooks;
87
104
  private user;
88
105
  private accessToken;
106
+ private accessExpiresAt;
107
+ private inflight;
108
+ private refreshInflight;
89
109
  constructor(hooks: AuthHooks);
90
- /** Re-hydrate session from AsyncStorage. Called by the parent SDK
110
+ /** Re-hydrate session from storage. Called by the parent SDK
91
111
  * during init() after Storage.hydrate() has run. */
92
112
  hydrate(): void;
93
113
  getCurrentUser(): AuthUser | null;
94
- getAccessToken(): string | null;
114
+ /**
115
+ * Returns a non-expired access token or null. If the cached token
116
+ * is expired but a refresh token exists, transparently refreshes
117
+ * (single-flight — concurrent callers share one network call).
118
+ */
119
+ getAccessToken(): Promise<string | null>;
120
+ /** Sync read — returns whatever's cached even if expired. Prefer the async variant. */
121
+ getAccessTokenSync(): string | null;
95
122
  signInAnonymously(opts?: {
96
123
  name?: string;
97
124
  metadata?: Record<string, unknown>;
@@ -100,8 +127,71 @@ declare class Auth {
100
127
  name?: string;
101
128
  metadata?: Record<string, unknown>;
102
129
  }): Promise<AuthUser>;
103
- signIn(email: string, password: string): Promise<AuthUser>;
130
+ /**
131
+ * Sign in with email + password. When the user has confirmed MFA,
132
+ * returns `{mfaRequired, mfaChallengeToken}` instead of an
133
+ * `AuthUser`; caller must follow up with `challengeMfa()`.
134
+ */
135
+ signIn(email: string, password: string): Promise<AuthUser | {
136
+ mfaRequired: true;
137
+ mfaChallengeToken: string;
138
+ userId: string;
139
+ }>;
140
+ /**
141
+ * Exchange the MFA challenge token from `signIn` for a real session
142
+ * by submitting a TOTP or recovery code from the user's authenticator.
143
+ */
144
+ challengeMfa(mfaChallengeToken: string, code: string): Promise<AuthUser>;
145
+ sendMagicLink(email: string): Promise<void>;
146
+ verifyMagicLink(token: string): Promise<AuthUser>;
147
+ enrollMfa(): Promise<{
148
+ secret: string;
149
+ otpauthUrl: string;
150
+ recoveryCodes: string[];
151
+ }>;
152
+ confirmMfa(code: string): Promise<boolean>;
153
+ disableMfa(): Promise<void>;
154
+ listMySessions(): Promise<Array<{
155
+ id: string;
156
+ deviceInfo: string | null;
157
+ ipHash: string | null;
158
+ lastUsedAt: string | null;
159
+ createdAt: string;
160
+ }>>;
161
+ revokeSession(sessionId: string): Promise<void>;
162
+ revokeAllSessions(): Promise<void>;
163
+ /**
164
+ * Start OIDC sign-in. Returns the IdP's authorization URL — caller
165
+ * opens it via React Native's `Linking.openURL()` (or
166
+ * `WebBrowser.openAuthSessionAsync` from `expo-web-browser` when
167
+ * inside Expo). The IdP redirects through Sendora's callback to the
168
+ * `returnTo` URL with `#sendora_oidc_token=...` in the fragment.
169
+ *
170
+ * On the customer's app side, `returnTo` MUST be a custom URL scheme
171
+ * registered in Info.plist (iOS) / AndroidManifest.xml intent-filter
172
+ * (Android), e.g. `myapp://oidc-callback`. When the deep link fires,
173
+ * call `consumeSsoFragment(url)` on the captured URL.
174
+ */
175
+ startSso(returnTo: string): Promise<{
176
+ authorizationUrl: string;
177
+ }>;
178
+ /**
179
+ * Consume an OIDC callback URL captured via React Native's
180
+ * `Linking.addEventListener('url', ...)`. Reads the fragment for
181
+ * `sendora_oidc_token`, exchanges it for a session, and persists.
182
+ * Throws AuthError on `sendora_oidc_error=...` fragment.
183
+ */
184
+ consumeSsoFragment(callbackUrl: string): Promise<AuthUser>;
185
+ private bearerHeaders;
186
+ private unwrap;
187
+ /**
188
+ * Sign out: wipe local identity FIRST so the user is logged out on
189
+ * device even if the revoke network call hangs (airplane mode, 5xx).
190
+ * Revoke is fire-and-forget after the wipe.
191
+ */
104
192
  signOut(): Promise<void>;
193
+ private serialize;
194
+ private refreshAccessToken;
105
195
  private callAuth;
106
196
  private persist;
107
197
  private wipeLocalIdentity;
@@ -122,8 +212,10 @@ interface SendoraConfig {
122
212
  environment?: "prod" | "staging" | "dev";
123
213
  /** Optional project id scoping. */
124
214
  projectId?: string;
125
- /** If false, events buffer until consent.grant() is called. Default true (mobile apps usually collect OS-level consent). */
215
+ /** If false, events drop until setConsent(true) is called. Default true (mobile apps usually collect OS-level consent). */
126
216
  consentedByDefault?: boolean;
217
+ /** Verbose error logging (auth-error codes + Zod field errors). Default false. */
218
+ debug?: boolean;
127
219
  }
128
220
  type TraitValue = string | number | boolean | null | undefined;
129
221
  interface IdentifyTraits {
@@ -176,6 +268,8 @@ declare class SendoraSDK {
176
268
  private storage;
177
269
  private anonId;
178
270
  private userId;
271
+ private consentGranted;
272
+ private debug;
179
273
  /** Lazy-initialised in init() once we know the apiUrl + publicKey. */
180
274
  auth: Auth;
181
275
  /** Initialize the SDK. Must be awaited at app startup before identify/track. */
@@ -184,6 +278,8 @@ declare class SendoraSDK {
184
278
  getAnonymousId(): string;
185
279
  /** Current identified user id, if any. */
186
280
  getUserId(): string | null;
281
+ /** Toggle consent at runtime — when false, events drop instead of fire. */
282
+ setConsent(granted: boolean): void;
187
283
  /** Associate a user id + traits with the current anonymous install. */
188
284
  identify(userId: string, traits?: IdentifyTraits): void;
189
285
  /** Fire a custom product event. */
package/dist/index.d.ts CHANGED
@@ -21,9 +21,11 @@ declare class Storage {
21
21
  set(key: string, value: string): void;
22
22
  remove(key: string): void;
23
23
  /**
24
- * Remove every sendora_*-prefixed key we know about from both
25
- * memory and AsyncStorage. Used by reset() so no stale identity
26
- * survives a logout, including keys future SDK versions might add.
24
+ * Remove every sendora_*-prefixed key from both memory AND
25
+ * AsyncStorage. Uses getAllKeys() to find keys this process never
26
+ * cached (e.g. orphaned writes from a prior SDK version that added
27
+ * a key not in our hydrate list). Falls back to cache enumeration
28
+ * if the AsyncStorage backend doesn't expose getAllKeys.
27
29
  */
28
30
  clearAll(): Promise<void>;
29
31
  }
@@ -39,12 +41,25 @@ declare class Storage {
39
41
  * user_id is preserved on success. Throws
40
42
  * EmailAlreadyTakenError on 409. Otherwise calls
41
43
  * /auth-service/signup as a fresh account.
42
- * signIn(email, password) — POST /auth-service/login. If the SDK
43
- * was anonymous, wipes local identity (anonId rotated, userId
44
- * cleared, tokens dropped) BEFORE installing the new session.
44
+ * signIn(email, password) — POST /auth-service/login. Wipes local
45
+ * identity (anonId rotated, userId cleared, tokens dropped)
46
+ * BEFORE the network call so any track() fired during the
47
+ * round-trip can't attach to the prior identity.
45
48
  *
46
- * Tokens persist in AsyncStorage so a cold app launch re-hydrates
47
- * the active session.
49
+ * All public auth ops are serialized through a single-flight chain
50
+ * so a UI double-submit can't mint two anonymous users or interleave
51
+ * a signIn + signOut.
52
+ *
53
+ * Tokens persist via the configured secure-storage adapter (defaults
54
+ * to AsyncStorage; consumers handling sensitive accounts should pass
55
+ * a Keychain / SecureStore-backed adapter via `secureStorage`).
56
+ *
57
+ * Response payloads are validated non-empty before persisting so a
58
+ * malformed/MITM'd response can't install an `id = ""` user.
59
+ *
60
+ * Access token expiry is tracked locally — getAccessToken() returns
61
+ * null past expiry and triggers a single-flight refresh on the next
62
+ * call.
48
63
  */
49
64
 
50
65
  interface AuthUser {
@@ -74,24 +89,36 @@ interface AuthHooks {
74
89
  * identity (analytics userId, push token registration, etc). */
75
90
  onIdentityChange: (userId: string | null) => void;
76
91
  /**
77
- * Called when the SDK switches from an anonymous user to a real
78
- * account via signIn(). The parent rotates anonId so events from
79
- * the new user can't carry over orphaned attribution.
92
+ * Called when the SDK switches from one identity to another via
93
+ * signIn() or non-anonymous signUp(). The parent rotates anonId
94
+ * + drops any pending events so the new user can't carry over
95
+ * orphaned attribution.
80
96
  */
81
97
  onAnonymousWipe: () => Promise<void>;
82
98
  apiUrl: string;
83
99
  publicKey: string;
100
+ debug: boolean;
84
101
  }
85
102
  declare class Auth {
86
103
  private hooks;
87
104
  private user;
88
105
  private accessToken;
106
+ private accessExpiresAt;
107
+ private inflight;
108
+ private refreshInflight;
89
109
  constructor(hooks: AuthHooks);
90
- /** Re-hydrate session from AsyncStorage. Called by the parent SDK
110
+ /** Re-hydrate session from storage. Called by the parent SDK
91
111
  * during init() after Storage.hydrate() has run. */
92
112
  hydrate(): void;
93
113
  getCurrentUser(): AuthUser | null;
94
- getAccessToken(): string | null;
114
+ /**
115
+ * Returns a non-expired access token or null. If the cached token
116
+ * is expired but a refresh token exists, transparently refreshes
117
+ * (single-flight — concurrent callers share one network call).
118
+ */
119
+ getAccessToken(): Promise<string | null>;
120
+ /** Sync read — returns whatever's cached even if expired. Prefer the async variant. */
121
+ getAccessTokenSync(): string | null;
95
122
  signInAnonymously(opts?: {
96
123
  name?: string;
97
124
  metadata?: Record<string, unknown>;
@@ -100,8 +127,71 @@ declare class Auth {
100
127
  name?: string;
101
128
  metadata?: Record<string, unknown>;
102
129
  }): Promise<AuthUser>;
103
- signIn(email: string, password: string): Promise<AuthUser>;
130
+ /**
131
+ * Sign in with email + password. When the user has confirmed MFA,
132
+ * returns `{mfaRequired, mfaChallengeToken}` instead of an
133
+ * `AuthUser`; caller must follow up with `challengeMfa()`.
134
+ */
135
+ signIn(email: string, password: string): Promise<AuthUser | {
136
+ mfaRequired: true;
137
+ mfaChallengeToken: string;
138
+ userId: string;
139
+ }>;
140
+ /**
141
+ * Exchange the MFA challenge token from `signIn` for a real session
142
+ * by submitting a TOTP or recovery code from the user's authenticator.
143
+ */
144
+ challengeMfa(mfaChallengeToken: string, code: string): Promise<AuthUser>;
145
+ sendMagicLink(email: string): Promise<void>;
146
+ verifyMagicLink(token: string): Promise<AuthUser>;
147
+ enrollMfa(): Promise<{
148
+ secret: string;
149
+ otpauthUrl: string;
150
+ recoveryCodes: string[];
151
+ }>;
152
+ confirmMfa(code: string): Promise<boolean>;
153
+ disableMfa(): Promise<void>;
154
+ listMySessions(): Promise<Array<{
155
+ id: string;
156
+ deviceInfo: string | null;
157
+ ipHash: string | null;
158
+ lastUsedAt: string | null;
159
+ createdAt: string;
160
+ }>>;
161
+ revokeSession(sessionId: string): Promise<void>;
162
+ revokeAllSessions(): Promise<void>;
163
+ /**
164
+ * Start OIDC sign-in. Returns the IdP's authorization URL — caller
165
+ * opens it via React Native's `Linking.openURL()` (or
166
+ * `WebBrowser.openAuthSessionAsync` from `expo-web-browser` when
167
+ * inside Expo). The IdP redirects through Sendora's callback to the
168
+ * `returnTo` URL with `#sendora_oidc_token=...` in the fragment.
169
+ *
170
+ * On the customer's app side, `returnTo` MUST be a custom URL scheme
171
+ * registered in Info.plist (iOS) / AndroidManifest.xml intent-filter
172
+ * (Android), e.g. `myapp://oidc-callback`. When the deep link fires,
173
+ * call `consumeSsoFragment(url)` on the captured URL.
174
+ */
175
+ startSso(returnTo: string): Promise<{
176
+ authorizationUrl: string;
177
+ }>;
178
+ /**
179
+ * Consume an OIDC callback URL captured via React Native's
180
+ * `Linking.addEventListener('url', ...)`. Reads the fragment for
181
+ * `sendora_oidc_token`, exchanges it for a session, and persists.
182
+ * Throws AuthError on `sendora_oidc_error=...` fragment.
183
+ */
184
+ consumeSsoFragment(callbackUrl: string): Promise<AuthUser>;
185
+ private bearerHeaders;
186
+ private unwrap;
187
+ /**
188
+ * Sign out: wipe local identity FIRST so the user is logged out on
189
+ * device even if the revoke network call hangs (airplane mode, 5xx).
190
+ * Revoke is fire-and-forget after the wipe.
191
+ */
104
192
  signOut(): Promise<void>;
193
+ private serialize;
194
+ private refreshAccessToken;
105
195
  private callAuth;
106
196
  private persist;
107
197
  private wipeLocalIdentity;
@@ -122,8 +212,10 @@ interface SendoraConfig {
122
212
  environment?: "prod" | "staging" | "dev";
123
213
  /** Optional project id scoping. */
124
214
  projectId?: string;
125
- /** If false, events buffer until consent.grant() is called. Default true (mobile apps usually collect OS-level consent). */
215
+ /** If false, events drop until setConsent(true) is called. Default true (mobile apps usually collect OS-level consent). */
126
216
  consentedByDefault?: boolean;
217
+ /** Verbose error logging (auth-error codes + Zod field errors). Default false. */
218
+ debug?: boolean;
127
219
  }
128
220
  type TraitValue = string | number | boolean | null | undefined;
129
221
  interface IdentifyTraits {
@@ -176,6 +268,8 @@ declare class SendoraSDK {
176
268
  private storage;
177
269
  private anonId;
178
270
  private userId;
271
+ private consentGranted;
272
+ private debug;
179
273
  /** Lazy-initialised in init() once we know the apiUrl + publicKey. */
180
274
  auth: Auth;
181
275
  /** Initialize the SDK. Must be awaited at app startup before identify/track. */
@@ -184,6 +278,8 @@ declare class SendoraSDK {
184
278
  getAnonymousId(): string;
185
279
  /** Current identified user id, if any. */
186
280
  getUserId(): string | null;
281
+ /** Toggle consent at runtime — when false, events drop instead of fire. */
282
+ setConsent(granted: boolean): void;
187
283
  /** Associate a user id + traits with the current anonymous install. */
188
284
  identify(userId: string, traits?: IdentifyTraits): void;
189
285
  /** Fire a custom product event. */