@sendoracloud/sdk-react-native 0.10.3 → 0.11.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/README.md CHANGED
@@ -2,14 +2,26 @@
2
2
 
3
3
  Official React Native + Expo SDK for [SendoraCloud](https://sendoracloud.com). Works in Expo Go, managed dev clients, and bare React Native apps.
4
4
 
5
+ > 📖 **First time on React Native?** Read the [5-minute quickstart](https://sendoracloud.com/docs/quickstart-react-native) — it covers the Hermes CSPRNG polyfill, anonymous-first auth, push token receipts, and the demo end-to-end push send.
6
+
5
7
  ## Install
6
8
 
7
9
  ```bash
8
10
  npx expo install @sendoracloud/sdk-react-native @react-native-async-storage/async-storage
11
+ npm install react-native-get-random-values
9
12
  ```
10
13
 
11
14
  `@react-native-async-storage/async-storage` is a required peer dependency — used to persist the anonymous device id across app restarts.
12
15
 
16
+ `react-native-get-random-values` is a required runtime polyfill — Hermes (and JavaScriptCore) doesn't expose `crypto.getRandomValues` reliably, and Sendora refuses to mint anonymous IDs from `Math.random`. Add this as the **first import** in your entry file (`index.js` or `index.tsx`):
17
+
18
+ ```ts
19
+ // MUST be the first import in your entry file
20
+ import "react-native-get-random-values";
21
+ ```
22
+
23
+ If you forget, `init()` throws with a paste-ready remediation block — it never fails silently.
24
+
13
25
  ## Quick start
14
26
 
15
27
  ```ts
@@ -36,11 +48,16 @@ SendoraCloud.screen("Pricing");
36
48
  // Register the device push token (get it from expo-notifications)
37
49
  import * as Notifications from "expo-notifications";
38
50
  const { data } = await Notifications.getExpoPushTokenAsync();
39
- await SendoraCloud.registerPushToken({
51
+ const receipt = await SendoraCloud.registerPushToken({
40
52
  token: data,
41
53
  platform: Platform.OS as "ios" | "android",
54
+ // bundleId is OPTIONAL but strongly recommended for multi-env apps:
55
+ // Sendora uses it to route to the right APNs / FCM credentials so a
56
+ // dev token never receives a prod push.
42
57
  bundleId: "com.yourcompany.app",
43
58
  });
59
+ // Returns { tokenId, created } — log it for debugging fan-out issues.
60
+ console.log("registered:", receipt.tokenId, "new?", receipt.created);
44
61
 
45
62
  // On logout
46
63
  SendoraCloud.reset();
@@ -69,9 +86,46 @@ SendoraCloud.reset();
69
86
  environment?: "prod" | "staging" | "dev"; // Routing hint.
70
87
  projectId?: string; // Optional project scoping.
71
88
  consentedByDefault?: boolean; // Defaults to true.
89
+ /**
90
+ * Auto-collect lifecycle events. Default true. Object form:
91
+ * { appOpen?, sessionStart?, appBackground? } for granular control.
92
+ * - appOpen — `app.opened` once per init.
93
+ * - sessionStart — `session.start` once per session (idle >sessionIdleMs).
94
+ * - appBackground — AppState 'change' fires `app.foregrounded` /
95
+ * `app.backgrounded`.
96
+ * Note: `screen.viewed` is NOT auto — call `SendoraCloud.screen(name)`
97
+ * from your navigation listener (RN has no canonical router).
98
+ */
99
+ autoTrack?: boolean | { appOpen?: boolean; sessionStart?: boolean; appBackground?: boolean };
100
+ sessionIdleMs?: number; // Idle threshold for session expiry. Default 30 min.
101
+ }
102
+ ```
103
+
104
+ ## Stability
105
+
106
+ This SDK is on the **0.10.x line**. Patch bumps (0.10.x → 0.10.y) are backwards-compatible; minor bumps may include breaking changes — always read the [CHANGELOG](https://github.com/sendoracloud/sdk-react-native/blob/main/CHANGELOG.md) before upgrading. The next major (1.0) is targeted once the HttpOnly-cookie SSR auth + auto-trait extraction features have soaked in production for two consecutive months without a schema change. Once 1.0 ships, semver is strict.
107
+
108
+ ## JWT verification (custom backend)
109
+
110
+ If your backend verifies Sendora-issued access tokens directly (rather than calling Sendora APIs that re-verify on every hit), use the OIDC-standard JWKS auto-discovery pattern — never hardcode the JWKS URL:
111
+
112
+ ```ts
113
+ import { createRemoteJWKSet, jwtVerify, decodeJwt } from "jose";
114
+
115
+ const JWKS_BY_ISS = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
116
+ function getJwks(iss: string) {
117
+ if (!JWKS_BY_ISS.has(iss)) {
118
+ JWKS_BY_ISS.set(iss, createRemoteJWKSet(new URL(`${iss}/.well-known/jwks.json`)));
119
+ }
120
+ return JWKS_BY_ISS.get(iss)!;
72
121
  }
122
+
123
+ const claims = decodeJwt(token);
124
+ const { payload } = await jwtVerify(token, getJwks(claims.iss as string));
73
125
  ```
74
126
 
127
+ The `iss` claim is a per-org Sendora URL (e.g. `https://api.sendoracloud.com/api/v1/auth-service/<orgId>`); appending `/.well-known/jwks.json` resolves on the same host. This means a customer can rotate signing keys without your code redeploying. **Don't** copy a hardcoded JWKS URL from a tutorial — it'll break in 12 months when an org rotates.
128
+
75
129
  ## Security
76
130
 
77
131
  - **Secret keys refused on the client.** The SDK only accepts `pk_(live|test)_<alphanumerics>` keys — `sk_*` (any case) or malformed values throw at init. Secret keys must stay on your backend.
package/dist/index.cjs CHANGED
@@ -799,8 +799,88 @@ function decodeJwtPayload(token) {
799
799
  }
800
800
  }
801
801
 
802
+ // src/auto-track.ts
803
+ var DEFAULT_IDLE_MS = 30 * 60 * 1e3;
804
+ function resolveFlags(cfg) {
805
+ if (cfg === false) {
806
+ return { appOpen: false, sessionStart: false, appBackground: false };
807
+ }
808
+ if (cfg === true || cfg === void 0) {
809
+ return { appOpen: true, sessionStart: true, appBackground: true };
810
+ }
811
+ return {
812
+ appOpen: cfg.appOpen ?? true,
813
+ sessionStart: cfg.sessionStart ?? true,
814
+ appBackground: cfg.appBackground ?? true
815
+ };
816
+ }
817
+ function installAutoTrack(args) {
818
+ const flags = resolveFlags(args.config.autoTrack);
819
+ const idleMs = args.config.sessionIdleMs ?? DEFAULT_IDLE_MS;
820
+ let lastActivity = Date.now();
821
+ const cleanups = [];
822
+ function newSessionId() {
823
+ return `sess_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
824
+ }
825
+ function ensureSession(emitStart) {
826
+ const now = Date.now();
827
+ let id = args.getSessionId();
828
+ if (!id || now - lastActivity > idleMs) {
829
+ id = newSessionId();
830
+ args.setSessionId(id);
831
+ if (emitStart && flags.sessionStart) {
832
+ args.fire("session.start", { sessionId: id });
833
+ }
834
+ }
835
+ lastActivity = now;
836
+ return id;
837
+ }
838
+ const sessionId = ensureSession(
839
+ /* emitStart */
840
+ flags.sessionStart
841
+ );
842
+ if (flags.appOpen) {
843
+ args.fire("app.opened", { sessionId });
844
+ }
845
+ if (flags.appBackground) {
846
+ let AppState = null;
847
+ try {
848
+ AppState = require("react-native").AppState;
849
+ } catch {
850
+ AppState = null;
851
+ }
852
+ if (AppState && typeof AppState.addEventListener === "function") {
853
+ const handler = (state) => {
854
+ ensureSession(true);
855
+ if (state === "background" || state === "inactive") {
856
+ args.fire("app.backgrounded", { sessionId: args.getSessionId() });
857
+ } else if (state === "active") {
858
+ args.fire("app.foregrounded", { sessionId: args.getSessionId() });
859
+ }
860
+ };
861
+ const sub = AppState.addEventListener("change", handler);
862
+ cleanups.push(() => {
863
+ if (typeof sub === "function") {
864
+ return;
865
+ }
866
+ if (sub && typeof sub.remove === "function") {
867
+ sub.remove();
868
+ }
869
+ });
870
+ }
871
+ }
872
+ return () => {
873
+ for (const fn of cleanups) {
874
+ try {
875
+ fn();
876
+ } catch {
877
+ }
878
+ }
879
+ };
880
+ }
881
+
802
882
  // src/index.ts
803
- var SDK_VERSION = "0.10.3";
883
+ var SDK_VERSION = "0.11.0";
804
884
  var DEFAULT_API_URL = "https://api.sendoracloud.com";
805
885
  var ANON_KEY = "anon_id";
806
886
  var USER_ID_KEY = "user_id";
@@ -816,15 +896,33 @@ var MAX_PUSH_METADATA_BYTES = 4096;
816
896
  var MAX_USER_ID_LEN = 256;
817
897
  var MAX_TRAITS_BYTES = 32 * 1024;
818
898
  var MAX_PROPERTIES_BYTES = 32 * 1024;
899
+ function assertCSPRNG() {
900
+ const g = globalThis;
901
+ if (g.crypto?.getRandomValues) {
902
+ return g.crypto;
903
+ }
904
+ const msg = [
905
+ "[sendoracloud] crypto.getRandomValues is unavailable in this runtime.",
906
+ "",
907
+ "Sendora refuses to mint anonymous IDs from Math.random \u2014 predictable IDs",
908
+ "are a security floor regression, not a tradeoff.",
909
+ "",
910
+ "Fix (React Native + Expo):",
911
+ " 1) npm install react-native-get-random-values",
912
+ " 2) Add this as the FIRST line of your app's entry file (index.js or",
913
+ " index.tsx \u2014 before any other import including Sendora):",
914
+ "",
915
+ " import 'react-native-get-random-values';",
916
+ "",
917
+ " 3) Reload the JS bundle (full restart, not Fast Refresh).",
918
+ "",
919
+ "Docs: https://sendoracloud.com/docs/quickstart-react-native#csprng"
920
+ ].join("\n");
921
+ throw new Error(msg);
922
+ }
819
923
  function uuid() {
820
924
  const bytes = new Uint8Array(16);
821
- const g = globalThis;
822
- if (!g.crypto?.getRandomValues) {
823
- throw new Error(
824
- "[sendoracloud] crypto.getRandomValues unavailable \u2014 refusing to mint IDs without a CSPRNG. Upgrade Hermes / your RN runtime."
825
- );
826
- }
827
- g.crypto.getRandomValues(bytes);
925
+ assertCSPRNG().getRandomValues(bytes);
828
926
  bytes[6] = bytes[6] & 15 | 64;
829
927
  bytes[8] = bytes[8] & 63 | 128;
830
928
  const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
@@ -846,9 +944,12 @@ var SendoraSDK = class {
846
944
  this.userId = null;
847
945
  this.consentGranted = true;
848
946
  this.debug = false;
947
+ this.sessionId = "";
948
+ this.autoTrackCleanup = null;
849
949
  }
850
950
  /** Initialize the SDK. Must be awaited at app startup before identify/track. */
851
951
  async init(config) {
952
+ assertCSPRNG();
852
953
  const rawKey = String(config.apiKey ?? config.publicKey ?? "").trim();
853
954
  if (/^sk_/i.test(rawKey) || /sk_(prod|staging|dev|live|test)_/i.test(rawKey)) {
854
955
  throw new Error(
@@ -919,6 +1020,20 @@ var SendoraSDK = class {
919
1020
  });
920
1021
  this.auth.hydrate();
921
1022
  this.ready = true;
1023
+ if (config.autoTrack !== false) {
1024
+ this.autoTrackCleanup = installAutoTrack({
1025
+ config: this.config,
1026
+ fire: (eventType, properties) => this.track(eventType, properties),
1027
+ getSessionId: () => this.sessionId,
1028
+ setSessionId: (id) => {
1029
+ this.sessionId = id;
1030
+ }
1031
+ });
1032
+ }
1033
+ }
1034
+ /** Current logical session id, if auto-track is enabled. */
1035
+ getSessionId() {
1036
+ return this.sessionId;
922
1037
  }
923
1038
  /** Stable anonymous identifier for this install — survives app restarts via AsyncStorage. */
924
1039
  getAnonymousId() {
@@ -988,7 +1103,9 @@ var SendoraSDK = class {
988
1103
  */
989
1104
  async registerPushToken(reg) {
990
1105
  this.assertReady();
991
- if (!this.config) return;
1106
+ if (!this.config) {
1107
+ throw new Error("[sendoracloud] init() must complete before registerPushToken().");
1108
+ }
992
1109
  if (!reg.token || byteLength(reg.token) > MAX_PUSH_TOKEN_BYTES) {
993
1110
  throw new Error("[sendoracloud] push token is empty or exceeds 4KB.");
994
1111
  }
@@ -998,7 +1115,7 @@ var SendoraSDK = class {
998
1115
  throw new Error("[sendoracloud] push token metadata exceeds 4KB.");
999
1116
  }
1000
1117
  }
1001
- await post(
1118
+ const data = await post(
1002
1119
  { apiUrl: this.config.apiUrl, publicKey: this.config.publicKey, debug: this.debug },
1003
1120
  "/api/v1/push/tokens",
1004
1121
  {
@@ -1011,6 +1128,10 @@ var SendoraSDK = class {
1011
1128
  metadata: reg.metadata ?? {}
1012
1129
  }
1013
1130
  );
1131
+ if (!data?.tokenId) {
1132
+ throw new Error("[sendoracloud] push token registered but server returned no tokenId \u2014 upgrade backend to >= s58.16.");
1133
+ }
1134
+ return { tokenId: data.tokenId, created: data.created === true };
1014
1135
  }
1015
1136
  /**
1016
1137
  * Clear identity + rotate the anonymous id. Call on logout. Awaits
@@ -1020,9 +1141,17 @@ var SendoraSDK = class {
1020
1141
  async reset() {
1021
1142
  this.userId = null;
1022
1143
  this.anonId = uuid();
1144
+ this.sessionId = "";
1023
1145
  await this.storage.clearAll();
1024
1146
  this.storage.set(ANON_KEY, this.anonId);
1025
1147
  }
1148
+ /** Tear down auto-track listeners. Call in test harnesses. */
1149
+ destroy() {
1150
+ if (this.autoTrackCleanup) {
1151
+ this.autoTrackCleanup();
1152
+ this.autoTrackCleanup = null;
1153
+ }
1154
+ }
1026
1155
  async sendEvent(kind, payload) {
1027
1156
  if (!this.config) return;
1028
1157
  if (!this.consentGranted) return;
package/dist/index.d.cts CHANGED
@@ -309,6 +309,27 @@ interface SendoraConfig {
309
309
  consentedByDefault?: boolean;
310
310
  /** Verbose error logging (auth-error codes + Zod field errors). Default false. */
311
311
  debug?: boolean;
312
+ /**
313
+ * Auto-collect lifecycle events. Mirrors Firebase Analytics' auto-collected
314
+ * surface. `true` = all enabled (default). Pass an object for granular
315
+ * control or `false` to disable.
316
+ *
317
+ * - `appOpen` — fire `app.opened` on init. Default true.
318
+ * - `sessionStart` — emit `session.start` once per logical session
319
+ * (idle >`sessionIdleMs` closes session). Default true.
320
+ * - `appBackground` — emit `app.backgrounded`/`app.foregrounded` on
321
+ * AppState transitions. Default true.
322
+ *
323
+ * Note: screen view tracking is NOT auto — RN has no canonical router.
324
+ * Call `SendoraCloud.screen(name)` from your navigation listener.
325
+ */
326
+ autoTrack?: boolean | {
327
+ appOpen?: boolean;
328
+ sessionStart?: boolean;
329
+ appBackground?: boolean;
330
+ };
331
+ /** Idle threshold (ms) for session expiry; default 30 minutes. */
332
+ sessionIdleMs?: number;
312
333
  }
313
334
  type TraitValue = string | number | boolean | null | undefined;
314
335
  interface IdentifyTraits {
@@ -324,11 +345,25 @@ type PushPlatform = "ios" | "android";
324
345
  interface PushTokenRegistration {
325
346
  token: string;
326
347
  platform: PushPlatform;
327
- /** App bundle / package identifier, for fan-out to the right credentials. */
348
+ /**
349
+ * App bundle / package identifier, for fan-out to the right credentials.
350
+ * Optional but strongly recommended for multi-env apps so dev / staging
351
+ * tokens never get a prod APNs / FCM push.
352
+ */
328
353
  bundleId?: string;
329
354
  /** Arbitrary device metadata stored alongside the token. */
330
355
  metadata?: Record<string, string>;
331
356
  }
357
+ /**
358
+ * Returned by `registerPushToken` so the caller can log a receipt or run
359
+ * a follow-up `testPush(userId)` against the freshly stored token. `created`
360
+ * distinguishes a brand-new token row from an idempotent re-registration of
361
+ * a token Sendora already had on file.
362
+ */
363
+ interface PushTokenReceipt {
364
+ tokenId: string;
365
+ created: boolean;
366
+ }
332
367
 
333
368
  /**
334
369
  * @sendoracloud/sdk-react-native — React Native + Expo SDK.
@@ -363,10 +398,14 @@ declare class SendoraSDK {
363
398
  private userId;
364
399
  private consentGranted;
365
400
  private debug;
401
+ private sessionId;
402
+ private autoTrackCleanup;
366
403
  /** Lazy-initialised in init() once we know the apiUrl + publicKey. */
367
404
  auth: Auth;
368
405
  /** Initialize the SDK. Must be awaited at app startup before identify/track. */
369
406
  init(config: SendoraConfig): Promise<void>;
407
+ /** Current logical session id, if auto-track is enabled. */
408
+ getSessionId(): string;
370
409
  /** Stable anonymous identifier for this install — survives app restarts via AsyncStorage. */
371
410
  getAnonymousId(): string;
372
411
  /** Current identified user id, if any. */
@@ -385,16 +424,18 @@ declare class SendoraSDK {
385
424
  * obtaining the token — typically via `expo-notifications` or
386
425
  * `@react-native-firebase/messaging`.
387
426
  */
388
- registerPushToken(reg: PushTokenRegistration): Promise<void>;
427
+ registerPushToken(reg: PushTokenRegistration): Promise<PushTokenReceipt>;
389
428
  /**
390
429
  * Clear identity + rotate the anonymous id. Call on logout. Awaits
391
430
  * the AsyncStorage writes so a caller who kills the app immediately
392
431
  * after doesn't leave stale identity on disk.
393
432
  */
394
433
  reset(): Promise<void>;
434
+ /** Tear down auto-track listeners. Call in test harnesses. */
435
+ destroy(): void;
395
436
  private sendEvent;
396
437
  private assertReady;
397
438
  }
398
439
  declare const SendoraCloud: SendoraSDK;
399
440
 
400
- export { Auth, AuthError, type AuthTokens, type AuthUser, EmailAlreadyTakenError, type IdentifyTraits, type PushTokenRegistration, SendoraCloud, type SendoraConfig, type TrackProperties, SendoraCloud as default };
441
+ export { Auth, AuthError, type AuthTokens, type AuthUser, EmailAlreadyTakenError, type IdentifyTraits, type PushTokenReceipt, type PushTokenRegistration, SendoraCloud, type SendoraConfig, type TrackProperties, SendoraCloud as default };
package/dist/index.d.ts CHANGED
@@ -309,6 +309,27 @@ interface SendoraConfig {
309
309
  consentedByDefault?: boolean;
310
310
  /** Verbose error logging (auth-error codes + Zod field errors). Default false. */
311
311
  debug?: boolean;
312
+ /**
313
+ * Auto-collect lifecycle events. Mirrors Firebase Analytics' auto-collected
314
+ * surface. `true` = all enabled (default). Pass an object for granular
315
+ * control or `false` to disable.
316
+ *
317
+ * - `appOpen` — fire `app.opened` on init. Default true.
318
+ * - `sessionStart` — emit `session.start` once per logical session
319
+ * (idle >`sessionIdleMs` closes session). Default true.
320
+ * - `appBackground` — emit `app.backgrounded`/`app.foregrounded` on
321
+ * AppState transitions. Default true.
322
+ *
323
+ * Note: screen view tracking is NOT auto — RN has no canonical router.
324
+ * Call `SendoraCloud.screen(name)` from your navigation listener.
325
+ */
326
+ autoTrack?: boolean | {
327
+ appOpen?: boolean;
328
+ sessionStart?: boolean;
329
+ appBackground?: boolean;
330
+ };
331
+ /** Idle threshold (ms) for session expiry; default 30 minutes. */
332
+ sessionIdleMs?: number;
312
333
  }
313
334
  type TraitValue = string | number | boolean | null | undefined;
314
335
  interface IdentifyTraits {
@@ -324,11 +345,25 @@ type PushPlatform = "ios" | "android";
324
345
  interface PushTokenRegistration {
325
346
  token: string;
326
347
  platform: PushPlatform;
327
- /** App bundle / package identifier, for fan-out to the right credentials. */
348
+ /**
349
+ * App bundle / package identifier, for fan-out to the right credentials.
350
+ * Optional but strongly recommended for multi-env apps so dev / staging
351
+ * tokens never get a prod APNs / FCM push.
352
+ */
328
353
  bundleId?: string;
329
354
  /** Arbitrary device metadata stored alongside the token. */
330
355
  metadata?: Record<string, string>;
331
356
  }
357
+ /**
358
+ * Returned by `registerPushToken` so the caller can log a receipt or run
359
+ * a follow-up `testPush(userId)` against the freshly stored token. `created`
360
+ * distinguishes a brand-new token row from an idempotent re-registration of
361
+ * a token Sendora already had on file.
362
+ */
363
+ interface PushTokenReceipt {
364
+ tokenId: string;
365
+ created: boolean;
366
+ }
332
367
 
333
368
  /**
334
369
  * @sendoracloud/sdk-react-native — React Native + Expo SDK.
@@ -363,10 +398,14 @@ declare class SendoraSDK {
363
398
  private userId;
364
399
  private consentGranted;
365
400
  private debug;
401
+ private sessionId;
402
+ private autoTrackCleanup;
366
403
  /** Lazy-initialised in init() once we know the apiUrl + publicKey. */
367
404
  auth: Auth;
368
405
  /** Initialize the SDK. Must be awaited at app startup before identify/track. */
369
406
  init(config: SendoraConfig): Promise<void>;
407
+ /** Current logical session id, if auto-track is enabled. */
408
+ getSessionId(): string;
370
409
  /** Stable anonymous identifier for this install — survives app restarts via AsyncStorage. */
371
410
  getAnonymousId(): string;
372
411
  /** Current identified user id, if any. */
@@ -385,16 +424,18 @@ declare class SendoraSDK {
385
424
  * obtaining the token — typically via `expo-notifications` or
386
425
  * `@react-native-firebase/messaging`.
387
426
  */
388
- registerPushToken(reg: PushTokenRegistration): Promise<void>;
427
+ registerPushToken(reg: PushTokenRegistration): Promise<PushTokenReceipt>;
389
428
  /**
390
429
  * Clear identity + rotate the anonymous id. Call on logout. Awaits
391
430
  * the AsyncStorage writes so a caller who kills the app immediately
392
431
  * after doesn't leave stale identity on disk.
393
432
  */
394
433
  reset(): Promise<void>;
434
+ /** Tear down auto-track listeners. Call in test harnesses. */
435
+ destroy(): void;
395
436
  private sendEvent;
396
437
  private assertReady;
397
438
  }
398
439
  declare const SendoraCloud: SendoraSDK;
399
440
 
400
- export { Auth, AuthError, type AuthTokens, type AuthUser, EmailAlreadyTakenError, type IdentifyTraits, type PushTokenRegistration, SendoraCloud, type SendoraConfig, type TrackProperties, SendoraCloud as default };
441
+ export { Auth, AuthError, type AuthTokens, type AuthUser, EmailAlreadyTakenError, type IdentifyTraits, type PushTokenReceipt, type PushTokenRegistration, SendoraCloud, type SendoraConfig, type TrackProperties, SendoraCloud as default };
package/dist/index.js CHANGED
@@ -1,3 +1,10 @@
1
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
+ }) : x)(function(x) {
4
+ if (typeof require !== "undefined") return require.apply(this, arguments);
5
+ throw Error('Dynamic require of "' + x + '" is not supported');
6
+ });
7
+
1
8
  // src/storage.ts
2
9
  var PREFIX = "sendora_";
3
10
  var asyncStoragePromise = null;
@@ -759,8 +766,88 @@ function decodeJwtPayload(token) {
759
766
  }
760
767
  }
761
768
 
769
+ // src/auto-track.ts
770
+ var DEFAULT_IDLE_MS = 30 * 60 * 1e3;
771
+ function resolveFlags(cfg) {
772
+ if (cfg === false) {
773
+ return { appOpen: false, sessionStart: false, appBackground: false };
774
+ }
775
+ if (cfg === true || cfg === void 0) {
776
+ return { appOpen: true, sessionStart: true, appBackground: true };
777
+ }
778
+ return {
779
+ appOpen: cfg.appOpen ?? true,
780
+ sessionStart: cfg.sessionStart ?? true,
781
+ appBackground: cfg.appBackground ?? true
782
+ };
783
+ }
784
+ function installAutoTrack(args) {
785
+ const flags = resolveFlags(args.config.autoTrack);
786
+ const idleMs = args.config.sessionIdleMs ?? DEFAULT_IDLE_MS;
787
+ let lastActivity = Date.now();
788
+ const cleanups = [];
789
+ function newSessionId() {
790
+ return `sess_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
791
+ }
792
+ function ensureSession(emitStart) {
793
+ const now = Date.now();
794
+ let id = args.getSessionId();
795
+ if (!id || now - lastActivity > idleMs) {
796
+ id = newSessionId();
797
+ args.setSessionId(id);
798
+ if (emitStart && flags.sessionStart) {
799
+ args.fire("session.start", { sessionId: id });
800
+ }
801
+ }
802
+ lastActivity = now;
803
+ return id;
804
+ }
805
+ const sessionId = ensureSession(
806
+ /* emitStart */
807
+ flags.sessionStart
808
+ );
809
+ if (flags.appOpen) {
810
+ args.fire("app.opened", { sessionId });
811
+ }
812
+ if (flags.appBackground) {
813
+ let AppState = null;
814
+ try {
815
+ AppState = __require("react-native").AppState;
816
+ } catch {
817
+ AppState = null;
818
+ }
819
+ if (AppState && typeof AppState.addEventListener === "function") {
820
+ const handler = (state) => {
821
+ ensureSession(true);
822
+ if (state === "background" || state === "inactive") {
823
+ args.fire("app.backgrounded", { sessionId: args.getSessionId() });
824
+ } else if (state === "active") {
825
+ args.fire("app.foregrounded", { sessionId: args.getSessionId() });
826
+ }
827
+ };
828
+ const sub = AppState.addEventListener("change", handler);
829
+ cleanups.push(() => {
830
+ if (typeof sub === "function") {
831
+ return;
832
+ }
833
+ if (sub && typeof sub.remove === "function") {
834
+ sub.remove();
835
+ }
836
+ });
837
+ }
838
+ }
839
+ return () => {
840
+ for (const fn of cleanups) {
841
+ try {
842
+ fn();
843
+ } catch {
844
+ }
845
+ }
846
+ };
847
+ }
848
+
762
849
  // src/index.ts
763
- var SDK_VERSION = "0.10.3";
850
+ var SDK_VERSION = "0.11.0";
764
851
  var DEFAULT_API_URL = "https://api.sendoracloud.com";
765
852
  var ANON_KEY = "anon_id";
766
853
  var USER_ID_KEY = "user_id";
@@ -776,15 +863,33 @@ var MAX_PUSH_METADATA_BYTES = 4096;
776
863
  var MAX_USER_ID_LEN = 256;
777
864
  var MAX_TRAITS_BYTES = 32 * 1024;
778
865
  var MAX_PROPERTIES_BYTES = 32 * 1024;
866
+ function assertCSPRNG() {
867
+ const g = globalThis;
868
+ if (g.crypto?.getRandomValues) {
869
+ return g.crypto;
870
+ }
871
+ const msg = [
872
+ "[sendoracloud] crypto.getRandomValues is unavailable in this runtime.",
873
+ "",
874
+ "Sendora refuses to mint anonymous IDs from Math.random \u2014 predictable IDs",
875
+ "are a security floor regression, not a tradeoff.",
876
+ "",
877
+ "Fix (React Native + Expo):",
878
+ " 1) npm install react-native-get-random-values",
879
+ " 2) Add this as the FIRST line of your app's entry file (index.js or",
880
+ " index.tsx \u2014 before any other import including Sendora):",
881
+ "",
882
+ " import 'react-native-get-random-values';",
883
+ "",
884
+ " 3) Reload the JS bundle (full restart, not Fast Refresh).",
885
+ "",
886
+ "Docs: https://sendoracloud.com/docs/quickstart-react-native#csprng"
887
+ ].join("\n");
888
+ throw new Error(msg);
889
+ }
779
890
  function uuid() {
780
891
  const bytes = new Uint8Array(16);
781
- const g = globalThis;
782
- if (!g.crypto?.getRandomValues) {
783
- throw new Error(
784
- "[sendoracloud] crypto.getRandomValues unavailable \u2014 refusing to mint IDs without a CSPRNG. Upgrade Hermes / your RN runtime."
785
- );
786
- }
787
- g.crypto.getRandomValues(bytes);
892
+ assertCSPRNG().getRandomValues(bytes);
788
893
  bytes[6] = bytes[6] & 15 | 64;
789
894
  bytes[8] = bytes[8] & 63 | 128;
790
895
  const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
@@ -806,9 +911,12 @@ var SendoraSDK = class {
806
911
  this.userId = null;
807
912
  this.consentGranted = true;
808
913
  this.debug = false;
914
+ this.sessionId = "";
915
+ this.autoTrackCleanup = null;
809
916
  }
810
917
  /** Initialize the SDK. Must be awaited at app startup before identify/track. */
811
918
  async init(config) {
919
+ assertCSPRNG();
812
920
  const rawKey = String(config.apiKey ?? config.publicKey ?? "").trim();
813
921
  if (/^sk_/i.test(rawKey) || /sk_(prod|staging|dev|live|test)_/i.test(rawKey)) {
814
922
  throw new Error(
@@ -879,6 +987,20 @@ var SendoraSDK = class {
879
987
  });
880
988
  this.auth.hydrate();
881
989
  this.ready = true;
990
+ if (config.autoTrack !== false) {
991
+ this.autoTrackCleanup = installAutoTrack({
992
+ config: this.config,
993
+ fire: (eventType, properties) => this.track(eventType, properties),
994
+ getSessionId: () => this.sessionId,
995
+ setSessionId: (id) => {
996
+ this.sessionId = id;
997
+ }
998
+ });
999
+ }
1000
+ }
1001
+ /** Current logical session id, if auto-track is enabled. */
1002
+ getSessionId() {
1003
+ return this.sessionId;
882
1004
  }
883
1005
  /** Stable anonymous identifier for this install — survives app restarts via AsyncStorage. */
884
1006
  getAnonymousId() {
@@ -948,7 +1070,9 @@ var SendoraSDK = class {
948
1070
  */
949
1071
  async registerPushToken(reg) {
950
1072
  this.assertReady();
951
- if (!this.config) return;
1073
+ if (!this.config) {
1074
+ throw new Error("[sendoracloud] init() must complete before registerPushToken().");
1075
+ }
952
1076
  if (!reg.token || byteLength(reg.token) > MAX_PUSH_TOKEN_BYTES) {
953
1077
  throw new Error("[sendoracloud] push token is empty or exceeds 4KB.");
954
1078
  }
@@ -958,7 +1082,7 @@ var SendoraSDK = class {
958
1082
  throw new Error("[sendoracloud] push token metadata exceeds 4KB.");
959
1083
  }
960
1084
  }
961
- await post(
1085
+ const data = await post(
962
1086
  { apiUrl: this.config.apiUrl, publicKey: this.config.publicKey, debug: this.debug },
963
1087
  "/api/v1/push/tokens",
964
1088
  {
@@ -971,6 +1095,10 @@ var SendoraSDK = class {
971
1095
  metadata: reg.metadata ?? {}
972
1096
  }
973
1097
  );
1098
+ if (!data?.tokenId) {
1099
+ throw new Error("[sendoracloud] push token registered but server returned no tokenId \u2014 upgrade backend to >= s58.16.");
1100
+ }
1101
+ return { tokenId: data.tokenId, created: data.created === true };
974
1102
  }
975
1103
  /**
976
1104
  * Clear identity + rotate the anonymous id. Call on logout. Awaits
@@ -980,9 +1108,17 @@ var SendoraSDK = class {
980
1108
  async reset() {
981
1109
  this.userId = null;
982
1110
  this.anonId = uuid();
1111
+ this.sessionId = "";
983
1112
  await this.storage.clearAll();
984
1113
  this.storage.set(ANON_KEY, this.anonId);
985
1114
  }
1115
+ /** Tear down auto-track listeners. Call in test harnesses. */
1116
+ destroy() {
1117
+ if (this.autoTrackCleanup) {
1118
+ this.autoTrackCleanup();
1119
+ this.autoTrackCleanup = null;
1120
+ }
1121
+ }
986
1122
  async sendEvent(kind, payload) {
987
1123
  if (!this.config) return;
988
1124
  if (!this.consentGranted) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendoracloud/sdk-react-native",
3
- "version": "0.10.3",
3
+ "version": "0.11.0",
4
4
  "description": "Sendora Cloud React Native + Expo SDK — analytics, identity, push-token registration, auth. Expo Go compatible, no native modules beyond AsyncStorage.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",