@sendoracloud/sdk-react-native 1.6.0 → 1.6.2

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.cjs CHANGED
@@ -219,6 +219,82 @@ async function request(opts, method, path, body, extraHeaders, reqOpts) {
219
219
  return res;
220
220
  }
221
221
 
222
+ // package.json
223
+ var package_default = {
224
+ name: "@sendoracloud/sdk-react-native",
225
+ version: "1.6.2",
226
+ description: "Sendora Cloud React Native + Expo SDK \u2014 analytics, identity, push-token registration, auth, deep links (Branch / Firebase Dynamic Links parity). Auth + analytics + deep links work in Expo Go; push token registration requires a Dev Client (EAS Build or `npx expo prebuild`).",
227
+ type: "module",
228
+ main: "./dist/index.cjs",
229
+ module: "./dist/index.js",
230
+ types: "./dist/index.d.ts",
231
+ exports: {
232
+ ".": {
233
+ types: "./dist/index.d.ts",
234
+ import: "./dist/index.js",
235
+ require: "./dist/index.cjs"
236
+ },
237
+ "./contact-widget": {
238
+ types: "./dist/contact-widget.d.ts",
239
+ import: "./dist/contact-widget.js",
240
+ require: "./dist/contact-widget.cjs"
241
+ }
242
+ },
243
+ files: [
244
+ "dist",
245
+ "examples",
246
+ "README.md",
247
+ "LICENSE"
248
+ ],
249
+ keywords: [
250
+ "sendora",
251
+ "react-native",
252
+ "expo",
253
+ "analytics",
254
+ "tracking",
255
+ "customer-engagement"
256
+ ],
257
+ dependencies: {
258
+ "react-native-get-random-values": "^1.11.0"
259
+ },
260
+ peerDependencies: {
261
+ "@react-native-async-storage/async-storage": ">=1.17.0",
262
+ "react-native": ">=0.70.0",
263
+ "react-native-webview": ">=13.0.0"
264
+ },
265
+ peerDependenciesMeta: {
266
+ "@react-native-async-storage/async-storage": {
267
+ optional: false
268
+ },
269
+ "react-native": {
270
+ optional: false
271
+ },
272
+ "react-native-webview": {
273
+ optional: true
274
+ }
275
+ },
276
+ scripts: {
277
+ build: "tsup src/index.ts src/contact-widget.tsx --format esm,cjs --dts --clean --external react --external react-native --external react-native-webview",
278
+ typecheck: "tsc --noEmit"
279
+ },
280
+ devDependencies: {
281
+ "@types/react": "^19.2.16",
282
+ "react-native": "^0.85.3",
283
+ "react-native-webview": "^13.16.1",
284
+ tsup: "^8.0.0",
285
+ typescript: "^5.0.0"
286
+ },
287
+ license: "MIT",
288
+ repository: {
289
+ type: "git",
290
+ url: "https://github.com/sendoracloud/sdk-react-native"
291
+ },
292
+ homepage: "https://sendoracloud.com/sdks"
293
+ };
294
+
295
+ // src/version.ts
296
+ var SDK_VERSION = package_default.version;
297
+
222
298
  // src/auth.ts
223
299
  var import_react_native = require("react-native");
224
300
  var TOKEN_KEY = "auth_access_token";
@@ -277,6 +353,14 @@ var Auth = class {
277
353
  this.refreshCooldownUntil = 0;
278
354
  this.takeoverListeners = /* @__PURE__ */ new Set();
279
355
  this.lastTakeover = null;
356
+ /**
357
+ * Anon refresh token captured at `signIn()` time, stashed keyed to the
358
+ * issued `mfaChallengeToken` so the later `challengeMfa()` can forward it
359
+ * for device-takeover (s58.111). Before this, the MFA branch wiped the anon
360
+ * identity before the challenge resolved → the takeover hint was lost → one
361
+ * device ended with two user_ids + duplicate pushes (audit s58.203 fix).
362
+ */
363
+ this.pendingAnonTakeover = null;
280
364
  /**
281
365
  * Proactive refresh (s58.72). Fires when access-token age crosses
282
366
  * ~80% of TTL. Pairs with the AppState foreground listener so a
@@ -496,12 +580,7 @@ var Auth = class {
496
580
  */
497
581
  signIn(email, password) {
498
582
  return this.serialize(async () => {
499
- let prevAnonRefreshToken;
500
- if (this.user?.isAnonymous) {
501
- const stashed = this.hooks.storage.get(REFRESH_KEY);
502
- if (stashed) prevAnonRefreshToken = stashed;
503
- }
504
- if (this.user !== null) await this.wipeLocalIdentity();
583
+ const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
505
584
  const res = await post(
506
585
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
507
586
  "/api/v1/auth-service/login",
@@ -519,12 +598,16 @@ var Auth = class {
519
598
  }
520
599
  const r = parsed.data;
521
600
  if (r.mfaRequired === true && typeof r.mfaChallengeToken === "string") {
601
+ if (prevAnonRefreshToken) {
602
+ this.pendingAnonTakeover = { challengeToken: r.mfaChallengeToken, prevAnonRefreshToken };
603
+ }
522
604
  const u = r.user;
523
605
  return { mfaRequired: true, mfaChallengeToken: r.mfaChallengeToken, userId: u?.id ?? "" };
524
606
  }
525
607
  if (!isAuthApiResponse(parsed.data)) {
526
608
  throw new AuthError(parsed.error?.code ?? "AUTH_ERROR", parsed.error?.message ?? "Login failed");
527
609
  }
610
+ if (this.user !== null) await this.wipeLocalIdentity();
528
611
  this.persist(parsed.data);
529
612
  return parsed.data.user;
530
613
  });
@@ -606,13 +689,15 @@ var Auth = class {
606
689
  */
607
690
  challengeMfa(mfaChallengeToken, code) {
608
691
  return this.serialize(async () => {
609
- const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
692
+ const stashed = this.pendingAnonTakeover?.challengeToken === mfaChallengeToken ? this.pendingAnonTakeover.prevAnonRefreshToken : void 0;
693
+ const prevAnonRefreshToken = stashed ?? this.readPrevAnonRefreshToken();
610
694
  const body = {
611
695
  challengeToken: mfaChallengeToken,
612
696
  code
613
697
  };
614
698
  if (prevAnonRefreshToken) body.prevAnonRefreshToken = prevAnonRefreshToken;
615
699
  const data = await this.callAuth("/api/v1/auth-service/mfa/challenge", body);
700
+ if (this.user !== null) await this.wipeLocalIdentity();
616
701
  this.persist(data);
617
702
  return data.user;
618
703
  });
@@ -1152,6 +1237,7 @@ var Auth = class {
1152
1237
  this.accessToken = null;
1153
1238
  this.accessExpiresAt = 0;
1154
1239
  this.lastTakeover = null;
1240
+ this.pendingAnonTakeover = null;
1155
1241
  this.hooks.storage.remove(USER_KEY);
1156
1242
  this.hooks.storage.remove(TOKEN_KEY);
1157
1243
  this.hooks.storage.remove(TOKEN_EXPIRES_KEY);
@@ -1839,7 +1925,6 @@ function installAutoTrack(args) {
1839
1925
  }
1840
1926
 
1841
1927
  // src/index.ts
1842
- var SDK_VERSION = "1.0.0";
1843
1928
  var DEFAULT_API_URL = "https://api.sendoracloud.com";
1844
1929
  var ANON_KEY = "anon_id";
1845
1930
  var USER_ID_KEY = "user_id";
@@ -2149,7 +2234,13 @@ var SendoraSDK = class {
2149
2234
  { apiUrl: this.config.apiUrl, publicKey: this.config.publicKey, debug: this.debug },
2150
2235
  "/api/v1/push/tokens",
2151
2236
  {
2152
- userId: this.userId,
2237
+ // Conditional spread — the backend's registerPushTokenSchema makes
2238
+ // userId `.optional()`, which accepts a missing key but REJECTS
2239
+ // `null` (422 "userId: Expected string, received null"). Anonymous
2240
+ // installs (pre-identify()) carry a null userId, so OMIT the field
2241
+ // rather than send null — anon devices must still be able to
2242
+ // register. Mirrors the same conditional-spread sendEvent() uses.
2243
+ ...this.userId ? { userId: this.userId } : {},
2153
2244
  anonymousId: this.anonId,
2154
2245
  token: reg.token,
2155
2246
  platform: reg.platform,
@@ -2191,7 +2282,7 @@ var SendoraSDK = class {
2191
2282
  if (clickAction !== void 0) body.clickAction = clickAction;
2192
2283
  await post(
2193
2284
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2194
- "/push/track-open",
2285
+ "/api/v1/push/track-open",
2195
2286
  body
2196
2287
  );
2197
2288
  },
@@ -2210,7 +2301,7 @@ var SendoraSDK = class {
2210
2301
  if (!self.config) throw new Error("[sendoracloud] init() must complete before push.liveActivities.startToken().");
2211
2302
  const res = await post(
2212
2303
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2213
- "/push/live-activities/start-token",
2304
+ "/api/v1/push/live-activities/start-token",
2214
2305
  {
2215
2306
  pushToken: input.pushToken,
2216
2307
  activityType: input.activityType,
@@ -2244,7 +2335,7 @@ var SendoraSDK = class {
2244
2335
  if (!self.config) throw new Error("[sendoracloud] init() must complete before push.geofences.list().");
2245
2336
  const res = await getJson(
2246
2337
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2247
- "/push/geofences/list-for-device"
2338
+ "/api/v1/push/geofences/list-for-device"
2248
2339
  );
2249
2340
  if (!res || !res.ok) {
2250
2341
  throw new Error(`[sendoracloud] geofences list failed (HTTP ${res?.status ?? "network"}).`);
@@ -2262,7 +2353,7 @@ var SendoraSDK = class {
2262
2353
  if (!self.config) throw new Error("[sendoracloud] init() must complete before push.geofences.recordEvent().");
2263
2354
  const res = await post(
2264
2355
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2265
- "/push/geofences/event",
2356
+ "/api/v1/push/geofences/event",
2266
2357
  {
2267
2358
  geofenceId: input.geofenceId,
2268
2359
  kind: input.kind,
@@ -2306,7 +2397,7 @@ var SendoraSDK = class {
2306
2397
  if (!self.config) throw new Error("[sendoracloud] init() must complete before attribution.reportInstall().");
2307
2398
  const res = await post(
2308
2399
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2309
- "/attribution/install",
2400
+ "/api/v1/attribution/install",
2310
2401
  input ?? {}
2311
2402
  );
2312
2403
  if (!res || !res.ok) throw new Error(`[sendoracloud] attribution.reportInstall failed (${res?.status ?? "network"})`);
@@ -2318,7 +2409,7 @@ var SendoraSDK = class {
2318
2409
  if (!self.config) throw new Error("[sendoracloud] init() must complete before attribution.checkDeferred().");
2319
2410
  const res = await post(
2320
2411
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2321
- "/attribution/deferred",
2412
+ "/api/v1/attribution/deferred",
2322
2413
  input ?? {}
2323
2414
  );
2324
2415
  if (!res || !res.ok) throw new Error(`[sendoracloud] attribution.checkDeferred failed (${res?.status ?? "network"})`);
@@ -2352,7 +2443,7 @@ var SendoraSDK = class {
2352
2443
  if (!self.config) throw new Error("[sendoracloud] init() must complete before support.createTicket().");
2353
2444
  const res = await post(
2354
2445
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2355
- "/support/tickets",
2446
+ "/api/v1/support/tickets",
2356
2447
  input
2357
2448
  );
2358
2449
  if (!res || !res.ok) throw new Error(`[sendoracloud] support.createTicket failed (${res?.status ?? "network"})`);
@@ -2364,7 +2455,7 @@ var SendoraSDK = class {
2364
2455
  if (!self.config) throw new Error("[sendoracloud] init() must complete before support.submitCsat().");
2365
2456
  const res = await post(
2366
2457
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2367
- "/support/csat",
2458
+ "/api/v1/support/csat",
2368
2459
  input
2369
2460
  );
2370
2461
  if (!res || !res.ok) throw new Error(`[sendoracloud] support.submitCsat failed (${res?.status ?? "network"})`);
@@ -2377,11 +2468,12 @@ var SendoraSDK = class {
2377
2468
  const token = await self.auth.getAccessToken();
2378
2469
  if (!token) return 0;
2379
2470
  try {
2380
- const res = await fetch(
2381
- `${self.config.apiUrl}/api/v1/widgets/${encodeURIComponent(widgetId)}/my-tickets/unread-count`,
2382
- { headers: { Authorization: `Bearer ${token}` } }
2471
+ const res = await getJson(
2472
+ { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2473
+ `/api/v1/widgets/${encodeURIComponent(widgetId)}/my-tickets/unread-count`,
2474
+ { Authorization: `Bearer ${token}` }
2383
2475
  );
2384
- if (!res.ok) return 0;
2476
+ if (!res || !res.ok) return 0;
2385
2477
  const parsed = await res.json();
2386
2478
  return parsed?.data?.count ?? 0;
2387
2479
  } catch {
@@ -2410,12 +2502,11 @@ var SendoraSDK = class {
2410
2502
  if (!input.query || input.query.trim().length === 0) return [];
2411
2503
  const params = new URLSearchParams({ query: input.query });
2412
2504
  if (input.limit) params.set("limit", String(input.limit));
2413
- const url = `${self.config.apiUrl}/api/v1/kb/search?${params.toString()}`;
2414
- const res = await fetch(url, {
2415
- method: "GET",
2416
- headers: { "x-api-key": self.config.publicKey }
2417
- });
2418
- if (!res.ok) throw new Error(`[sendoracloud] kb.search failed (${res.status})`);
2505
+ const res = await getJson(
2506
+ { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2507
+ `/api/v1/kb/search?${params.toString()}`
2508
+ );
2509
+ if (!res || !res.ok) throw new Error(`[sendoracloud] kb.search failed (${res?.status ?? "network"})`);
2419
2510
  const parsed = await res.json();
2420
2511
  if (!parsed.success || !Array.isArray(parsed.data)) {
2421
2512
  throw new Error("[sendoracloud] kb.search: bad response");
@@ -2453,7 +2544,7 @@ var SendoraSDK = class {
2453
2544
  if (!self.config) throw new Error("[sendoracloud] init() must complete before consent.record().");
2454
2545
  const res = await post(
2455
2546
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2456
- "/consent/record",
2547
+ "/api/v1/consent/record",
2457
2548
  input
2458
2549
  );
2459
2550
  if (!res || !res.ok) throw new Error(`[sendoracloud] consent.record failed (${res?.status ?? "network"})`);
@@ -2465,7 +2556,7 @@ var SendoraSDK = class {
2465
2556
  if (!self.config) throw new Error("[sendoracloud] init() must complete before consent.requestDeletion().");
2466
2557
  const res = await post(
2467
2558
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2468
- "/consent/deletion-request",
2559
+ "/api/v1/consent/deletion-request",
2469
2560
  input
2470
2561
  );
2471
2562
  if (!res || !res.ok) throw new Error(`[sendoracloud] consent.requestDeletion failed (${res?.status ?? "network"})`);
@@ -2501,11 +2592,11 @@ var SendoraSDK = class {
2501
2592
  fetchConfig: async (surveyId) => {
2502
2593
  if (!self.config) throw new Error("[sendoracloud] init() must complete before surveys.fetchConfig().");
2503
2594
  if (typeof surveyId !== "string" || surveyId.length === 0) return null;
2504
- const url = `${self.config.apiUrl}/api/v1/surveys/${encodeURIComponent(surveyId)}/config`;
2505
- const res = await fetch(url, {
2506
- method: "GET",
2507
- headers: { "x-api-key": self.config.publicKey }
2508
- });
2595
+ const res = await getJson(
2596
+ { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2597
+ `/api/v1/surveys/${encodeURIComponent(surveyId)}/config`
2598
+ );
2599
+ if (!res) return null;
2509
2600
  if (res.status === 404) return null;
2510
2601
  if (!res.ok) throw new Error(`[sendoracloud] surveys.fetchConfig failed (${res.status})`);
2511
2602
  const parsed = await res.json();
@@ -2516,7 +2607,7 @@ var SendoraSDK = class {
2516
2607
  if (!self.config) throw new Error("[sendoracloud] init() must complete before surveys.submitResponse().");
2517
2608
  const res = await post(
2518
2609
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2519
- "/surveys/responses",
2610
+ "/api/v1/surveys/responses",
2520
2611
  { completed: true, ...input }
2521
2612
  );
2522
2613
  if (!res || !res.ok) throw new Error(`[sendoracloud] surveys.submitResponse failed (${res?.status ?? "network"})`);
@@ -2550,12 +2641,11 @@ var SendoraSDK = class {
2550
2641
  return {
2551
2642
  fetchActive: async () => {
2552
2643
  if (!self.config) throw new Error("[sendoracloud] init() must complete before messages.fetchActive().");
2553
- const url = `${self.config.apiUrl}/api/v1/in-app-messages/active`;
2554
- const res = await fetch(url, {
2555
- method: "GET",
2556
- headers: { "x-api-key": self.config.publicKey }
2557
- });
2558
- if (!res.ok) throw new Error(`[sendoracloud] messages.fetchActive failed (${res.status})`);
2644
+ const res = await getJson(
2645
+ { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2646
+ "/api/v1/in-app-messages/active"
2647
+ );
2648
+ if (!res || !res.ok) throw new Error(`[sendoracloud] messages.fetchActive failed (${res?.status ?? "network"})`);
2559
2649
  const parsed = await res.json();
2560
2650
  if (!parsed.success || !Array.isArray(parsed.data)) {
2561
2651
  throw new Error("[sendoracloud] messages.fetchActive: bad response");
@@ -2566,7 +2656,7 @@ var SendoraSDK = class {
2566
2656
  if (!self.config) throw new Error("[sendoracloud] init() must complete before messages.recordImpression().");
2567
2657
  const res = await post(
2568
2658
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2569
- "/in-app-messages/impressions",
2659
+ "/api/v1/in-app-messages/impressions",
2570
2660
  input
2571
2661
  );
2572
2662
  if (!res || !res.ok) throw new Error(`[sendoracloud] messages.recordImpression failed (${res?.status ?? "network"})`);
@@ -2604,7 +2694,7 @@ var SendoraSDK = class {
2604
2694
  };
2605
2695
  const res = await post(
2606
2696
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2607
- "/feature-flags/evaluate",
2697
+ "/api/v1/feature-flags/evaluate",
2608
2698
  body
2609
2699
  );
2610
2700
  if (!res || !res.ok) throw new Error(`[sendoracloud] flags.evaluate failed (${res?.status ?? "network"})`);
@@ -2621,7 +2711,7 @@ var SendoraSDK = class {
2621
2711
  };
2622
2712
  const res = await post(
2623
2713
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2624
- "/feature-flags/evaluate-all",
2714
+ "/api/v1/feature-flags/evaluate-all",
2625
2715
  body
2626
2716
  );
2627
2717
  if (!res || !res.ok) throw new Error(`[sendoracloud] flags.evaluateAll failed (${res?.status ?? "network"})`);
@@ -2656,7 +2746,7 @@ var SendoraSDK = class {
2656
2746
  };
2657
2747
  const res = await post(
2658
2748
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2659
- "/chatbot/message",
2749
+ "/api/v1/chatbot/message",
2660
2750
  body
2661
2751
  );
2662
2752
  if (!res || !res.ok) throw new Error(`[sendoracloud] chatbot.sendMessage failed (${res?.status ?? "network"})`);
package/dist/index.d.cts CHANGED
@@ -50,9 +50,16 @@ declare class Storage {
50
50
  * so a UI double-submit can't mint two anonymous users or interleave
51
51
  * a signIn + signOut.
52
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`).
53
+ * Tokens (including the refresh token) persist in AsyncStorage, not
54
+ * the iOS Keychain / Android Keystore. AsyncStorage is unencrypted
55
+ * app-sandbox storage: readable by anyone with filesystem/backup
56
+ * access to a jailbroken/rooted or unlocked device, but isolated from
57
+ * other apps by the OS sandbox. There is NO `secureStorage` adapter
58
+ * option today — earlier docs claimed a Keychain/SecureStore-backed
59
+ * adapter that was never implemented. If you ship a follow-up that
60
+ * moves the refresh token to expo-secure-store / react-native-keychain
61
+ * (optional peer dep, Metro-resolvable static import — never an inline
62
+ * require), keep the `auth_refresh_token` storage key for back-compat.
56
63
  *
57
64
  * Response payloads are validated non-empty before persisting so a
58
65
  * malformed/MITM'd response can't install an `id = ""` user.
@@ -134,6 +141,14 @@ declare class Auth {
134
141
  private refreshCooldownUntil;
135
142
  private takeoverListeners;
136
143
  private lastTakeover;
144
+ /**
145
+ * Anon refresh token captured at `signIn()` time, stashed keyed to the
146
+ * issued `mfaChallengeToken` so the later `challengeMfa()` can forward it
147
+ * for device-takeover (s58.111). Before this, the MFA branch wiped the anon
148
+ * identity before the challenge resolved → the takeover hint was lost → one
149
+ * device ended with two user_ids + duplicate pushes (audit s58.203 fix).
150
+ */
151
+ private pendingAnonTakeover;
137
152
  /**
138
153
  * Resolves when the parent SDK has finished its async init() —
139
154
  * AsyncStorage hydrated + auth.hydrate() restored the cached
package/dist/index.d.ts CHANGED
@@ -50,9 +50,16 @@ declare class Storage {
50
50
  * so a UI double-submit can't mint two anonymous users or interleave
51
51
  * a signIn + signOut.
52
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`).
53
+ * Tokens (including the refresh token) persist in AsyncStorage, not
54
+ * the iOS Keychain / Android Keystore. AsyncStorage is unencrypted
55
+ * app-sandbox storage: readable by anyone with filesystem/backup
56
+ * access to a jailbroken/rooted or unlocked device, but isolated from
57
+ * other apps by the OS sandbox. There is NO `secureStorage` adapter
58
+ * option today — earlier docs claimed a Keychain/SecureStore-backed
59
+ * adapter that was never implemented. If you ship a follow-up that
60
+ * moves the refresh token to expo-secure-store / react-native-keychain
61
+ * (optional peer dep, Metro-resolvable static import — never an inline
62
+ * require), keep the `auth_refresh_token` storage key for back-compat.
56
63
  *
57
64
  * Response payloads are validated non-empty before persisting so a
58
65
  * malformed/MITM'd response can't install an `id = ""` user.
@@ -134,6 +141,14 @@ declare class Auth {
134
141
  private refreshCooldownUntil;
135
142
  private takeoverListeners;
136
143
  private lastTakeover;
144
+ /**
145
+ * Anon refresh token captured at `signIn()` time, stashed keyed to the
146
+ * issued `mfaChallengeToken` so the later `challengeMfa()` can forward it
147
+ * for device-takeover (s58.111). Before this, the MFA branch wiped the anon
148
+ * identity before the challenge resolved → the takeover hint was lost → one
149
+ * device ended with two user_ids + duplicate pushes (audit s58.203 fix).
150
+ */
151
+ private pendingAnonTakeover;
137
152
  /**
138
153
  * Resolves when the parent SDK has finished its async init() —
139
154
  * AsyncStorage hydrated + auth.hydrate() restored the cached
package/dist/index.js CHANGED
@@ -176,6 +176,82 @@ async function request(opts, method, path, body, extraHeaders, reqOpts) {
176
176
  return res;
177
177
  }
178
178
 
179
+ // package.json
180
+ var package_default = {
181
+ name: "@sendoracloud/sdk-react-native",
182
+ version: "1.6.2",
183
+ description: "Sendora Cloud React Native + Expo SDK \u2014 analytics, identity, push-token registration, auth, deep links (Branch / Firebase Dynamic Links parity). Auth + analytics + deep links work in Expo Go; push token registration requires a Dev Client (EAS Build or `npx expo prebuild`).",
184
+ type: "module",
185
+ main: "./dist/index.cjs",
186
+ module: "./dist/index.js",
187
+ types: "./dist/index.d.ts",
188
+ exports: {
189
+ ".": {
190
+ types: "./dist/index.d.ts",
191
+ import: "./dist/index.js",
192
+ require: "./dist/index.cjs"
193
+ },
194
+ "./contact-widget": {
195
+ types: "./dist/contact-widget.d.ts",
196
+ import: "./dist/contact-widget.js",
197
+ require: "./dist/contact-widget.cjs"
198
+ }
199
+ },
200
+ files: [
201
+ "dist",
202
+ "examples",
203
+ "README.md",
204
+ "LICENSE"
205
+ ],
206
+ keywords: [
207
+ "sendora",
208
+ "react-native",
209
+ "expo",
210
+ "analytics",
211
+ "tracking",
212
+ "customer-engagement"
213
+ ],
214
+ dependencies: {
215
+ "react-native-get-random-values": "^1.11.0"
216
+ },
217
+ peerDependencies: {
218
+ "@react-native-async-storage/async-storage": ">=1.17.0",
219
+ "react-native": ">=0.70.0",
220
+ "react-native-webview": ">=13.0.0"
221
+ },
222
+ peerDependenciesMeta: {
223
+ "@react-native-async-storage/async-storage": {
224
+ optional: false
225
+ },
226
+ "react-native": {
227
+ optional: false
228
+ },
229
+ "react-native-webview": {
230
+ optional: true
231
+ }
232
+ },
233
+ scripts: {
234
+ build: "tsup src/index.ts src/contact-widget.tsx --format esm,cjs --dts --clean --external react --external react-native --external react-native-webview",
235
+ typecheck: "tsc --noEmit"
236
+ },
237
+ devDependencies: {
238
+ "@types/react": "^19.2.16",
239
+ "react-native": "^0.85.3",
240
+ "react-native-webview": "^13.16.1",
241
+ tsup: "^8.0.0",
242
+ typescript: "^5.0.0"
243
+ },
244
+ license: "MIT",
245
+ repository: {
246
+ type: "git",
247
+ url: "https://github.com/sendoracloud/sdk-react-native"
248
+ },
249
+ homepage: "https://sendoracloud.com/sdks"
250
+ };
251
+
252
+ // src/version.ts
253
+ var SDK_VERSION = package_default.version;
254
+
179
255
  // src/auth.ts
180
256
  import { AppState as RnAppState } from "react-native";
181
257
  var TOKEN_KEY = "auth_access_token";
@@ -234,6 +310,14 @@ var Auth = class {
234
310
  this.refreshCooldownUntil = 0;
235
311
  this.takeoverListeners = /* @__PURE__ */ new Set();
236
312
  this.lastTakeover = null;
313
+ /**
314
+ * Anon refresh token captured at `signIn()` time, stashed keyed to the
315
+ * issued `mfaChallengeToken` so the later `challengeMfa()` can forward it
316
+ * for device-takeover (s58.111). Before this, the MFA branch wiped the anon
317
+ * identity before the challenge resolved → the takeover hint was lost → one
318
+ * device ended with two user_ids + duplicate pushes (audit s58.203 fix).
319
+ */
320
+ this.pendingAnonTakeover = null;
237
321
  /**
238
322
  * Proactive refresh (s58.72). Fires when access-token age crosses
239
323
  * ~80% of TTL. Pairs with the AppState foreground listener so a
@@ -453,12 +537,7 @@ var Auth = class {
453
537
  */
454
538
  signIn(email, password) {
455
539
  return this.serialize(async () => {
456
- let prevAnonRefreshToken;
457
- if (this.user?.isAnonymous) {
458
- const stashed = this.hooks.storage.get(REFRESH_KEY);
459
- if (stashed) prevAnonRefreshToken = stashed;
460
- }
461
- if (this.user !== null) await this.wipeLocalIdentity();
540
+ const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
462
541
  const res = await post(
463
542
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
464
543
  "/api/v1/auth-service/login",
@@ -476,12 +555,16 @@ var Auth = class {
476
555
  }
477
556
  const r = parsed.data;
478
557
  if (r.mfaRequired === true && typeof r.mfaChallengeToken === "string") {
558
+ if (prevAnonRefreshToken) {
559
+ this.pendingAnonTakeover = { challengeToken: r.mfaChallengeToken, prevAnonRefreshToken };
560
+ }
479
561
  const u = r.user;
480
562
  return { mfaRequired: true, mfaChallengeToken: r.mfaChallengeToken, userId: u?.id ?? "" };
481
563
  }
482
564
  if (!isAuthApiResponse(parsed.data)) {
483
565
  throw new AuthError(parsed.error?.code ?? "AUTH_ERROR", parsed.error?.message ?? "Login failed");
484
566
  }
567
+ if (this.user !== null) await this.wipeLocalIdentity();
485
568
  this.persist(parsed.data);
486
569
  return parsed.data.user;
487
570
  });
@@ -563,13 +646,15 @@ var Auth = class {
563
646
  */
564
647
  challengeMfa(mfaChallengeToken, code) {
565
648
  return this.serialize(async () => {
566
- const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
649
+ const stashed = this.pendingAnonTakeover?.challengeToken === mfaChallengeToken ? this.pendingAnonTakeover.prevAnonRefreshToken : void 0;
650
+ const prevAnonRefreshToken = stashed ?? this.readPrevAnonRefreshToken();
567
651
  const body = {
568
652
  challengeToken: mfaChallengeToken,
569
653
  code
570
654
  };
571
655
  if (prevAnonRefreshToken) body.prevAnonRefreshToken = prevAnonRefreshToken;
572
656
  const data = await this.callAuth("/api/v1/auth-service/mfa/challenge", body);
657
+ if (this.user !== null) await this.wipeLocalIdentity();
573
658
  this.persist(data);
574
659
  return data.user;
575
660
  });
@@ -1109,6 +1194,7 @@ var Auth = class {
1109
1194
  this.accessToken = null;
1110
1195
  this.accessExpiresAt = 0;
1111
1196
  this.lastTakeover = null;
1197
+ this.pendingAnonTakeover = null;
1112
1198
  this.hooks.storage.remove(USER_KEY);
1113
1199
  this.hooks.storage.remove(TOKEN_KEY);
1114
1200
  this.hooks.storage.remove(TOKEN_EXPIRES_KEY);
@@ -1801,7 +1887,6 @@ function installAutoTrack(args) {
1801
1887
  }
1802
1888
 
1803
1889
  // src/index.ts
1804
- var SDK_VERSION = "1.0.0";
1805
1890
  var DEFAULT_API_URL = "https://api.sendoracloud.com";
1806
1891
  var ANON_KEY = "anon_id";
1807
1892
  var USER_ID_KEY = "user_id";
@@ -2111,7 +2196,13 @@ var SendoraSDK = class {
2111
2196
  { apiUrl: this.config.apiUrl, publicKey: this.config.publicKey, debug: this.debug },
2112
2197
  "/api/v1/push/tokens",
2113
2198
  {
2114
- userId: this.userId,
2199
+ // Conditional spread — the backend's registerPushTokenSchema makes
2200
+ // userId `.optional()`, which accepts a missing key but REJECTS
2201
+ // `null` (422 "userId: Expected string, received null"). Anonymous
2202
+ // installs (pre-identify()) carry a null userId, so OMIT the field
2203
+ // rather than send null — anon devices must still be able to
2204
+ // register. Mirrors the same conditional-spread sendEvent() uses.
2205
+ ...this.userId ? { userId: this.userId } : {},
2115
2206
  anonymousId: this.anonId,
2116
2207
  token: reg.token,
2117
2208
  platform: reg.platform,
@@ -2153,7 +2244,7 @@ var SendoraSDK = class {
2153
2244
  if (clickAction !== void 0) body.clickAction = clickAction;
2154
2245
  await post(
2155
2246
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2156
- "/push/track-open",
2247
+ "/api/v1/push/track-open",
2157
2248
  body
2158
2249
  );
2159
2250
  },
@@ -2172,7 +2263,7 @@ var SendoraSDK = class {
2172
2263
  if (!self.config) throw new Error("[sendoracloud] init() must complete before push.liveActivities.startToken().");
2173
2264
  const res = await post(
2174
2265
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2175
- "/push/live-activities/start-token",
2266
+ "/api/v1/push/live-activities/start-token",
2176
2267
  {
2177
2268
  pushToken: input.pushToken,
2178
2269
  activityType: input.activityType,
@@ -2206,7 +2297,7 @@ var SendoraSDK = class {
2206
2297
  if (!self.config) throw new Error("[sendoracloud] init() must complete before push.geofences.list().");
2207
2298
  const res = await getJson(
2208
2299
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2209
- "/push/geofences/list-for-device"
2300
+ "/api/v1/push/geofences/list-for-device"
2210
2301
  );
2211
2302
  if (!res || !res.ok) {
2212
2303
  throw new Error(`[sendoracloud] geofences list failed (HTTP ${res?.status ?? "network"}).`);
@@ -2224,7 +2315,7 @@ var SendoraSDK = class {
2224
2315
  if (!self.config) throw new Error("[sendoracloud] init() must complete before push.geofences.recordEvent().");
2225
2316
  const res = await post(
2226
2317
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2227
- "/push/geofences/event",
2318
+ "/api/v1/push/geofences/event",
2228
2319
  {
2229
2320
  geofenceId: input.geofenceId,
2230
2321
  kind: input.kind,
@@ -2268,7 +2359,7 @@ var SendoraSDK = class {
2268
2359
  if (!self.config) throw new Error("[sendoracloud] init() must complete before attribution.reportInstall().");
2269
2360
  const res = await post(
2270
2361
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2271
- "/attribution/install",
2362
+ "/api/v1/attribution/install",
2272
2363
  input ?? {}
2273
2364
  );
2274
2365
  if (!res || !res.ok) throw new Error(`[sendoracloud] attribution.reportInstall failed (${res?.status ?? "network"})`);
@@ -2280,7 +2371,7 @@ var SendoraSDK = class {
2280
2371
  if (!self.config) throw new Error("[sendoracloud] init() must complete before attribution.checkDeferred().");
2281
2372
  const res = await post(
2282
2373
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2283
- "/attribution/deferred",
2374
+ "/api/v1/attribution/deferred",
2284
2375
  input ?? {}
2285
2376
  );
2286
2377
  if (!res || !res.ok) throw new Error(`[sendoracloud] attribution.checkDeferred failed (${res?.status ?? "network"})`);
@@ -2314,7 +2405,7 @@ var SendoraSDK = class {
2314
2405
  if (!self.config) throw new Error("[sendoracloud] init() must complete before support.createTicket().");
2315
2406
  const res = await post(
2316
2407
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2317
- "/support/tickets",
2408
+ "/api/v1/support/tickets",
2318
2409
  input
2319
2410
  );
2320
2411
  if (!res || !res.ok) throw new Error(`[sendoracloud] support.createTicket failed (${res?.status ?? "network"})`);
@@ -2326,7 +2417,7 @@ var SendoraSDK = class {
2326
2417
  if (!self.config) throw new Error("[sendoracloud] init() must complete before support.submitCsat().");
2327
2418
  const res = await post(
2328
2419
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2329
- "/support/csat",
2420
+ "/api/v1/support/csat",
2330
2421
  input
2331
2422
  );
2332
2423
  if (!res || !res.ok) throw new Error(`[sendoracloud] support.submitCsat failed (${res?.status ?? "network"})`);
@@ -2339,11 +2430,12 @@ var SendoraSDK = class {
2339
2430
  const token = await self.auth.getAccessToken();
2340
2431
  if (!token) return 0;
2341
2432
  try {
2342
- const res = await fetch(
2343
- `${self.config.apiUrl}/api/v1/widgets/${encodeURIComponent(widgetId)}/my-tickets/unread-count`,
2344
- { headers: { Authorization: `Bearer ${token}` } }
2433
+ const res = await getJson(
2434
+ { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2435
+ `/api/v1/widgets/${encodeURIComponent(widgetId)}/my-tickets/unread-count`,
2436
+ { Authorization: `Bearer ${token}` }
2345
2437
  );
2346
- if (!res.ok) return 0;
2438
+ if (!res || !res.ok) return 0;
2347
2439
  const parsed = await res.json();
2348
2440
  return parsed?.data?.count ?? 0;
2349
2441
  } catch {
@@ -2372,12 +2464,11 @@ var SendoraSDK = class {
2372
2464
  if (!input.query || input.query.trim().length === 0) return [];
2373
2465
  const params = new URLSearchParams({ query: input.query });
2374
2466
  if (input.limit) params.set("limit", String(input.limit));
2375
- const url = `${self.config.apiUrl}/api/v1/kb/search?${params.toString()}`;
2376
- const res = await fetch(url, {
2377
- method: "GET",
2378
- headers: { "x-api-key": self.config.publicKey }
2379
- });
2380
- if (!res.ok) throw new Error(`[sendoracloud] kb.search failed (${res.status})`);
2467
+ const res = await getJson(
2468
+ { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2469
+ `/api/v1/kb/search?${params.toString()}`
2470
+ );
2471
+ if (!res || !res.ok) throw new Error(`[sendoracloud] kb.search failed (${res?.status ?? "network"})`);
2381
2472
  const parsed = await res.json();
2382
2473
  if (!parsed.success || !Array.isArray(parsed.data)) {
2383
2474
  throw new Error("[sendoracloud] kb.search: bad response");
@@ -2415,7 +2506,7 @@ var SendoraSDK = class {
2415
2506
  if (!self.config) throw new Error("[sendoracloud] init() must complete before consent.record().");
2416
2507
  const res = await post(
2417
2508
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2418
- "/consent/record",
2509
+ "/api/v1/consent/record",
2419
2510
  input
2420
2511
  );
2421
2512
  if (!res || !res.ok) throw new Error(`[sendoracloud] consent.record failed (${res?.status ?? "network"})`);
@@ -2427,7 +2518,7 @@ var SendoraSDK = class {
2427
2518
  if (!self.config) throw new Error("[sendoracloud] init() must complete before consent.requestDeletion().");
2428
2519
  const res = await post(
2429
2520
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2430
- "/consent/deletion-request",
2521
+ "/api/v1/consent/deletion-request",
2431
2522
  input
2432
2523
  );
2433
2524
  if (!res || !res.ok) throw new Error(`[sendoracloud] consent.requestDeletion failed (${res?.status ?? "network"})`);
@@ -2463,11 +2554,11 @@ var SendoraSDK = class {
2463
2554
  fetchConfig: async (surveyId) => {
2464
2555
  if (!self.config) throw new Error("[sendoracloud] init() must complete before surveys.fetchConfig().");
2465
2556
  if (typeof surveyId !== "string" || surveyId.length === 0) return null;
2466
- const url = `${self.config.apiUrl}/api/v1/surveys/${encodeURIComponent(surveyId)}/config`;
2467
- const res = await fetch(url, {
2468
- method: "GET",
2469
- headers: { "x-api-key": self.config.publicKey }
2470
- });
2557
+ const res = await getJson(
2558
+ { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2559
+ `/api/v1/surveys/${encodeURIComponent(surveyId)}/config`
2560
+ );
2561
+ if (!res) return null;
2471
2562
  if (res.status === 404) return null;
2472
2563
  if (!res.ok) throw new Error(`[sendoracloud] surveys.fetchConfig failed (${res.status})`);
2473
2564
  const parsed = await res.json();
@@ -2478,7 +2569,7 @@ var SendoraSDK = class {
2478
2569
  if (!self.config) throw new Error("[sendoracloud] init() must complete before surveys.submitResponse().");
2479
2570
  const res = await post(
2480
2571
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2481
- "/surveys/responses",
2572
+ "/api/v1/surveys/responses",
2482
2573
  { completed: true, ...input }
2483
2574
  );
2484
2575
  if (!res || !res.ok) throw new Error(`[sendoracloud] surveys.submitResponse failed (${res?.status ?? "network"})`);
@@ -2512,12 +2603,11 @@ var SendoraSDK = class {
2512
2603
  return {
2513
2604
  fetchActive: async () => {
2514
2605
  if (!self.config) throw new Error("[sendoracloud] init() must complete before messages.fetchActive().");
2515
- const url = `${self.config.apiUrl}/api/v1/in-app-messages/active`;
2516
- const res = await fetch(url, {
2517
- method: "GET",
2518
- headers: { "x-api-key": self.config.publicKey }
2519
- });
2520
- if (!res.ok) throw new Error(`[sendoracloud] messages.fetchActive failed (${res.status})`);
2606
+ const res = await getJson(
2607
+ { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2608
+ "/api/v1/in-app-messages/active"
2609
+ );
2610
+ if (!res || !res.ok) throw new Error(`[sendoracloud] messages.fetchActive failed (${res?.status ?? "network"})`);
2521
2611
  const parsed = await res.json();
2522
2612
  if (!parsed.success || !Array.isArray(parsed.data)) {
2523
2613
  throw new Error("[sendoracloud] messages.fetchActive: bad response");
@@ -2528,7 +2618,7 @@ var SendoraSDK = class {
2528
2618
  if (!self.config) throw new Error("[sendoracloud] init() must complete before messages.recordImpression().");
2529
2619
  const res = await post(
2530
2620
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2531
- "/in-app-messages/impressions",
2621
+ "/api/v1/in-app-messages/impressions",
2532
2622
  input
2533
2623
  );
2534
2624
  if (!res || !res.ok) throw new Error(`[sendoracloud] messages.recordImpression failed (${res?.status ?? "network"})`);
@@ -2566,7 +2656,7 @@ var SendoraSDK = class {
2566
2656
  };
2567
2657
  const res = await post(
2568
2658
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2569
- "/feature-flags/evaluate",
2659
+ "/api/v1/feature-flags/evaluate",
2570
2660
  body
2571
2661
  );
2572
2662
  if (!res || !res.ok) throw new Error(`[sendoracloud] flags.evaluate failed (${res?.status ?? "network"})`);
@@ -2583,7 +2673,7 @@ var SendoraSDK = class {
2583
2673
  };
2584
2674
  const res = await post(
2585
2675
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2586
- "/feature-flags/evaluate-all",
2676
+ "/api/v1/feature-flags/evaluate-all",
2587
2677
  body
2588
2678
  );
2589
2679
  if (!res || !res.ok) throw new Error(`[sendoracloud] flags.evaluateAll failed (${res?.status ?? "network"})`);
@@ -2618,7 +2708,7 @@ var SendoraSDK = class {
2618
2708
  };
2619
2709
  const res = await post(
2620
2710
  { apiUrl: self.config.apiUrl, publicKey: self.config.publicKey, debug: self.debug },
2621
- "/chatbot/message",
2711
+ "/api/v1/chatbot/message",
2622
2712
  body
2623
2713
  );
2624
2714
  if (!res || !res.ok) throw new Error(`[sendoracloud] chatbot.sendMessage failed (${res?.status ?? "network"})`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendoracloud/sdk-react-native",
3
- "version": "1.6.0",
3
+ "version": "1.6.2",
4
4
  "description": "Sendora Cloud React Native + Expo SDK — analytics, identity, push-token registration, auth, deep links (Branch / Firebase Dynamic Links parity). Auth + analytics + deep links work in Expo Go; push token registration requires a Dev Client (EAS Build or `npx expo prebuild`).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",