@sendoracloud/sdk-react-native 0.10.2 → 0.10.4

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();
@@ -72,6 +89,31 @@ SendoraCloud.reset();
72
89
  }
73
90
  ```
74
91
 
92
+ ## Stability
93
+
94
+ 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.
95
+
96
+ ## JWT verification (custom backend)
97
+
98
+ 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:
99
+
100
+ ```ts
101
+ import { createRemoteJWKSet, jwtVerify, decodeJwt } from "jose";
102
+
103
+ const JWKS_BY_ISS = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
104
+ function getJwks(iss: string) {
105
+ if (!JWKS_BY_ISS.has(iss)) {
106
+ JWKS_BY_ISS.set(iss, createRemoteJWKSet(new URL(`${iss}/.well-known/jwks.json`)));
107
+ }
108
+ return JWKS_BY_ISS.get(iss)!;
109
+ }
110
+
111
+ const claims = decodeJwt(token);
112
+ const { payload } = await jwtVerify(token, getJwks(claims.iss as string));
113
+ ```
114
+
115
+ 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.
116
+
75
117
  ## Security
76
118
 
77
119
  - **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
@@ -231,9 +231,14 @@ var Auth = class {
231
231
  this.inflight = Promise.resolve();
232
232
  this.refreshInflight = null;
233
233
  this.hooks = hooks;
234
+ this.readyPromise = new Promise((res) => {
235
+ this.resolveReady = res;
236
+ });
234
237
  }
235
238
  /** Re-hydrate session from storage. Called by the parent SDK
236
- * during init() after Storage.hydrate() has run. */
239
+ * during init() after Storage.hydrate() has run. Marks the
240
+ * internal ready promise as resolved so queued auth calls
241
+ * unblock. */
237
242
  hydrate() {
238
243
  const cachedUser = this.hooks.storage.get(USER_KEY);
239
244
  const cachedToken = this.hooks.storage.get(TOKEN_KEY);
@@ -253,6 +258,7 @@ var Auth = class {
253
258
  this.hooks.storage.remove(REFRESH_KEY);
254
259
  }
255
260
  }
261
+ this.resolveReady();
256
262
  }
257
263
  getCurrentUser() {
258
264
  return this.user;
@@ -705,7 +711,11 @@ var Auth = class {
705
711
  }
706
712
  // --- internals ---
707
713
  serialize(op) {
708
- const next = this.inflight.then(op, op);
714
+ const guarded = async () => {
715
+ await this.readyPromise;
716
+ return op();
717
+ };
718
+ const next = this.inflight.then(guarded, guarded);
709
719
  this.inflight = next.catch(() => void 0);
710
720
  return next;
711
721
  }
@@ -790,7 +800,7 @@ function decodeJwtPayload(token) {
790
800
  }
791
801
 
792
802
  // src/index.ts
793
- var SDK_VERSION = "0.10.2";
803
+ var SDK_VERSION = "0.10.4";
794
804
  var DEFAULT_API_URL = "https://api.sendoracloud.com";
795
805
  var ANON_KEY = "anon_id";
796
806
  var USER_ID_KEY = "user_id";
@@ -806,15 +816,33 @@ var MAX_PUSH_METADATA_BYTES = 4096;
806
816
  var MAX_USER_ID_LEN = 256;
807
817
  var MAX_TRAITS_BYTES = 32 * 1024;
808
818
  var MAX_PROPERTIES_BYTES = 32 * 1024;
819
+ function assertCSPRNG() {
820
+ const g = globalThis;
821
+ if (g.crypto?.getRandomValues) {
822
+ return g.crypto;
823
+ }
824
+ const msg = [
825
+ "[sendoracloud] crypto.getRandomValues is unavailable in this runtime.",
826
+ "",
827
+ "Sendora refuses to mint anonymous IDs from Math.random \u2014 predictable IDs",
828
+ "are a security floor regression, not a tradeoff.",
829
+ "",
830
+ "Fix (React Native + Expo):",
831
+ " 1) npm install react-native-get-random-values",
832
+ " 2) Add this as the FIRST line of your app's entry file (index.js or",
833
+ " index.tsx \u2014 before any other import including Sendora):",
834
+ "",
835
+ " import 'react-native-get-random-values';",
836
+ "",
837
+ " 3) Reload the JS bundle (full restart, not Fast Refresh).",
838
+ "",
839
+ "Docs: https://sendoracloud.com/docs/quickstart-react-native#csprng"
840
+ ].join("\n");
841
+ throw new Error(msg);
842
+ }
809
843
  function uuid() {
810
844
  const bytes = new Uint8Array(16);
811
- const g = globalThis;
812
- if (!g.crypto?.getRandomValues) {
813
- throw new Error(
814
- "[sendoracloud] crypto.getRandomValues unavailable \u2014 refusing to mint IDs without a CSPRNG. Upgrade Hermes / your RN runtime."
815
- );
816
- }
817
- g.crypto.getRandomValues(bytes);
845
+ assertCSPRNG().getRandomValues(bytes);
818
846
  bytes[6] = bytes[6] & 15 | 64;
819
847
  bytes[8] = bytes[8] & 63 | 128;
820
848
  const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
@@ -839,6 +867,7 @@ var SendoraSDK = class {
839
867
  }
840
868
  /** Initialize the SDK. Must be awaited at app startup before identify/track. */
841
869
  async init(config) {
870
+ assertCSPRNG();
842
871
  const rawKey = String(config.apiKey ?? config.publicKey ?? "").trim();
843
872
  if (/^sk_/i.test(rawKey) || /sk_(prod|staging|dev|live|test)_/i.test(rawKey)) {
844
873
  throw new Error(
@@ -978,7 +1007,9 @@ var SendoraSDK = class {
978
1007
  */
979
1008
  async registerPushToken(reg) {
980
1009
  this.assertReady();
981
- if (!this.config) return;
1010
+ if (!this.config) {
1011
+ throw new Error("[sendoracloud] init() must complete before registerPushToken().");
1012
+ }
982
1013
  if (!reg.token || byteLength(reg.token) > MAX_PUSH_TOKEN_BYTES) {
983
1014
  throw new Error("[sendoracloud] push token is empty or exceeds 4KB.");
984
1015
  }
@@ -988,7 +1019,7 @@ var SendoraSDK = class {
988
1019
  throw new Error("[sendoracloud] push token metadata exceeds 4KB.");
989
1020
  }
990
1021
  }
991
- await post(
1022
+ const data = await post(
992
1023
  { apiUrl: this.config.apiUrl, publicKey: this.config.publicKey, debug: this.debug },
993
1024
  "/api/v1/push/tokens",
994
1025
  {
@@ -1001,6 +1032,10 @@ var SendoraSDK = class {
1001
1032
  metadata: reg.metadata ?? {}
1002
1033
  }
1003
1034
  );
1035
+ if (!data?.tokenId) {
1036
+ throw new Error("[sendoracloud] push token registered but server returned no tokenId \u2014 upgrade backend to >= s58.16.");
1037
+ }
1038
+ return { tokenId: data.tokenId, created: data.created === true };
1004
1039
  }
1005
1040
  /**
1006
1041
  * Clear identity + rotate the anonymous id. Call on logout. Awaits
package/dist/index.d.cts CHANGED
@@ -106,9 +106,26 @@ declare class Auth {
106
106
  private accessExpiresAt;
107
107
  private inflight;
108
108
  private refreshInflight;
109
+ /**
110
+ * Resolves when the parent SDK has finished its async init() —
111
+ * AsyncStorage hydrated + auth.hydrate() restored the cached
112
+ * session. All public auth methods await this internally so a
113
+ * customer who forgets `await sendora.init()` before showing the
114
+ * signup UI doesn't hit the cold-start race where the upgrade
115
+ * path's `storage.get(REFRESH_KEY)` returns null even though a
116
+ * valid anonymous session is sitting on disk.
117
+ *
118
+ * Industry-standard "SDK gates on internal readiness" pattern
119
+ * (Firebase Auth, Auth0, Clerk all do equivalent internal
120
+ * gating).
121
+ */
122
+ private readyPromise;
123
+ private resolveReady;
109
124
  constructor(hooks: AuthHooks);
110
125
  /** Re-hydrate session from storage. Called by the parent SDK
111
- * during init() after Storage.hydrate() has run. */
126
+ * during init() after Storage.hydrate() has run. Marks the
127
+ * internal ready promise as resolved so queued auth calls
128
+ * unblock. */
112
129
  hydrate(): void;
113
130
  getCurrentUser(): AuthUser | null;
114
131
  /**
@@ -307,11 +324,25 @@ type PushPlatform = "ios" | "android";
307
324
  interface PushTokenRegistration {
308
325
  token: string;
309
326
  platform: PushPlatform;
310
- /** App bundle / package identifier, for fan-out to the right credentials. */
327
+ /**
328
+ * App bundle / package identifier, for fan-out to the right credentials.
329
+ * Optional but strongly recommended for multi-env apps so dev / staging
330
+ * tokens never get a prod APNs / FCM push.
331
+ */
311
332
  bundleId?: string;
312
333
  /** Arbitrary device metadata stored alongside the token. */
313
334
  metadata?: Record<string, string>;
314
335
  }
336
+ /**
337
+ * Returned by `registerPushToken` so the caller can log a receipt or run
338
+ * a follow-up `testPush(userId)` against the freshly stored token. `created`
339
+ * distinguishes a brand-new token row from an idempotent re-registration of
340
+ * a token Sendora already had on file.
341
+ */
342
+ interface PushTokenReceipt {
343
+ tokenId: string;
344
+ created: boolean;
345
+ }
315
346
 
316
347
  /**
317
348
  * @sendoracloud/sdk-react-native — React Native + Expo SDK.
@@ -368,7 +399,7 @@ declare class SendoraSDK {
368
399
  * obtaining the token — typically via `expo-notifications` or
369
400
  * `@react-native-firebase/messaging`.
370
401
  */
371
- registerPushToken(reg: PushTokenRegistration): Promise<void>;
402
+ registerPushToken(reg: PushTokenRegistration): Promise<PushTokenReceipt>;
372
403
  /**
373
404
  * Clear identity + rotate the anonymous id. Call on logout. Awaits
374
405
  * the AsyncStorage writes so a caller who kills the app immediately
@@ -380,4 +411,4 @@ declare class SendoraSDK {
380
411
  }
381
412
  declare const SendoraCloud: SendoraSDK;
382
413
 
383
- export { Auth, AuthError, type AuthTokens, type AuthUser, EmailAlreadyTakenError, type IdentifyTraits, type PushTokenRegistration, SendoraCloud, type SendoraConfig, type TrackProperties, SendoraCloud as default };
414
+ 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
@@ -106,9 +106,26 @@ declare class Auth {
106
106
  private accessExpiresAt;
107
107
  private inflight;
108
108
  private refreshInflight;
109
+ /**
110
+ * Resolves when the parent SDK has finished its async init() —
111
+ * AsyncStorage hydrated + auth.hydrate() restored the cached
112
+ * session. All public auth methods await this internally so a
113
+ * customer who forgets `await sendora.init()` before showing the
114
+ * signup UI doesn't hit the cold-start race where the upgrade
115
+ * path's `storage.get(REFRESH_KEY)` returns null even though a
116
+ * valid anonymous session is sitting on disk.
117
+ *
118
+ * Industry-standard "SDK gates on internal readiness" pattern
119
+ * (Firebase Auth, Auth0, Clerk all do equivalent internal
120
+ * gating).
121
+ */
122
+ private readyPromise;
123
+ private resolveReady;
109
124
  constructor(hooks: AuthHooks);
110
125
  /** Re-hydrate session from storage. Called by the parent SDK
111
- * during init() after Storage.hydrate() has run. */
126
+ * during init() after Storage.hydrate() has run. Marks the
127
+ * internal ready promise as resolved so queued auth calls
128
+ * unblock. */
112
129
  hydrate(): void;
113
130
  getCurrentUser(): AuthUser | null;
114
131
  /**
@@ -307,11 +324,25 @@ type PushPlatform = "ios" | "android";
307
324
  interface PushTokenRegistration {
308
325
  token: string;
309
326
  platform: PushPlatform;
310
- /** App bundle / package identifier, for fan-out to the right credentials. */
327
+ /**
328
+ * App bundle / package identifier, for fan-out to the right credentials.
329
+ * Optional but strongly recommended for multi-env apps so dev / staging
330
+ * tokens never get a prod APNs / FCM push.
331
+ */
311
332
  bundleId?: string;
312
333
  /** Arbitrary device metadata stored alongside the token. */
313
334
  metadata?: Record<string, string>;
314
335
  }
336
+ /**
337
+ * Returned by `registerPushToken` so the caller can log a receipt or run
338
+ * a follow-up `testPush(userId)` against the freshly stored token. `created`
339
+ * distinguishes a brand-new token row from an idempotent re-registration of
340
+ * a token Sendora already had on file.
341
+ */
342
+ interface PushTokenReceipt {
343
+ tokenId: string;
344
+ created: boolean;
345
+ }
315
346
 
316
347
  /**
317
348
  * @sendoracloud/sdk-react-native — React Native + Expo SDK.
@@ -368,7 +399,7 @@ declare class SendoraSDK {
368
399
  * obtaining the token — typically via `expo-notifications` or
369
400
  * `@react-native-firebase/messaging`.
370
401
  */
371
- registerPushToken(reg: PushTokenRegistration): Promise<void>;
402
+ registerPushToken(reg: PushTokenRegistration): Promise<PushTokenReceipt>;
372
403
  /**
373
404
  * Clear identity + rotate the anonymous id. Call on logout. Awaits
374
405
  * the AsyncStorage writes so a caller who kills the app immediately
@@ -380,4 +411,4 @@ declare class SendoraSDK {
380
411
  }
381
412
  declare const SendoraCloud: SendoraSDK;
382
413
 
383
- export { Auth, AuthError, type AuthTokens, type AuthUser, EmailAlreadyTakenError, type IdentifyTraits, type PushTokenRegistration, SendoraCloud, type SendoraConfig, type TrackProperties, SendoraCloud as default };
414
+ 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
@@ -191,9 +191,14 @@ var Auth = class {
191
191
  this.inflight = Promise.resolve();
192
192
  this.refreshInflight = null;
193
193
  this.hooks = hooks;
194
+ this.readyPromise = new Promise((res) => {
195
+ this.resolveReady = res;
196
+ });
194
197
  }
195
198
  /** Re-hydrate session from storage. Called by the parent SDK
196
- * during init() after Storage.hydrate() has run. */
199
+ * during init() after Storage.hydrate() has run. Marks the
200
+ * internal ready promise as resolved so queued auth calls
201
+ * unblock. */
197
202
  hydrate() {
198
203
  const cachedUser = this.hooks.storage.get(USER_KEY);
199
204
  const cachedToken = this.hooks.storage.get(TOKEN_KEY);
@@ -213,6 +218,7 @@ var Auth = class {
213
218
  this.hooks.storage.remove(REFRESH_KEY);
214
219
  }
215
220
  }
221
+ this.resolveReady();
216
222
  }
217
223
  getCurrentUser() {
218
224
  return this.user;
@@ -665,7 +671,11 @@ var Auth = class {
665
671
  }
666
672
  // --- internals ---
667
673
  serialize(op) {
668
- const next = this.inflight.then(op, op);
674
+ const guarded = async () => {
675
+ await this.readyPromise;
676
+ return op();
677
+ };
678
+ const next = this.inflight.then(guarded, guarded);
669
679
  this.inflight = next.catch(() => void 0);
670
680
  return next;
671
681
  }
@@ -750,7 +760,7 @@ function decodeJwtPayload(token) {
750
760
  }
751
761
 
752
762
  // src/index.ts
753
- var SDK_VERSION = "0.10.2";
763
+ var SDK_VERSION = "0.10.4";
754
764
  var DEFAULT_API_URL = "https://api.sendoracloud.com";
755
765
  var ANON_KEY = "anon_id";
756
766
  var USER_ID_KEY = "user_id";
@@ -766,15 +776,33 @@ var MAX_PUSH_METADATA_BYTES = 4096;
766
776
  var MAX_USER_ID_LEN = 256;
767
777
  var MAX_TRAITS_BYTES = 32 * 1024;
768
778
  var MAX_PROPERTIES_BYTES = 32 * 1024;
779
+ function assertCSPRNG() {
780
+ const g = globalThis;
781
+ if (g.crypto?.getRandomValues) {
782
+ return g.crypto;
783
+ }
784
+ const msg = [
785
+ "[sendoracloud] crypto.getRandomValues is unavailable in this runtime.",
786
+ "",
787
+ "Sendora refuses to mint anonymous IDs from Math.random \u2014 predictable IDs",
788
+ "are a security floor regression, not a tradeoff.",
789
+ "",
790
+ "Fix (React Native + Expo):",
791
+ " 1) npm install react-native-get-random-values",
792
+ " 2) Add this as the FIRST line of your app's entry file (index.js or",
793
+ " index.tsx \u2014 before any other import including Sendora):",
794
+ "",
795
+ " import 'react-native-get-random-values';",
796
+ "",
797
+ " 3) Reload the JS bundle (full restart, not Fast Refresh).",
798
+ "",
799
+ "Docs: https://sendoracloud.com/docs/quickstart-react-native#csprng"
800
+ ].join("\n");
801
+ throw new Error(msg);
802
+ }
769
803
  function uuid() {
770
804
  const bytes = new Uint8Array(16);
771
- const g = globalThis;
772
- if (!g.crypto?.getRandomValues) {
773
- throw new Error(
774
- "[sendoracloud] crypto.getRandomValues unavailable \u2014 refusing to mint IDs without a CSPRNG. Upgrade Hermes / your RN runtime."
775
- );
776
- }
777
- g.crypto.getRandomValues(bytes);
805
+ assertCSPRNG().getRandomValues(bytes);
778
806
  bytes[6] = bytes[6] & 15 | 64;
779
807
  bytes[8] = bytes[8] & 63 | 128;
780
808
  const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
@@ -799,6 +827,7 @@ var SendoraSDK = class {
799
827
  }
800
828
  /** Initialize the SDK. Must be awaited at app startup before identify/track. */
801
829
  async init(config) {
830
+ assertCSPRNG();
802
831
  const rawKey = String(config.apiKey ?? config.publicKey ?? "").trim();
803
832
  if (/^sk_/i.test(rawKey) || /sk_(prod|staging|dev|live|test)_/i.test(rawKey)) {
804
833
  throw new Error(
@@ -938,7 +967,9 @@ var SendoraSDK = class {
938
967
  */
939
968
  async registerPushToken(reg) {
940
969
  this.assertReady();
941
- if (!this.config) return;
970
+ if (!this.config) {
971
+ throw new Error("[sendoracloud] init() must complete before registerPushToken().");
972
+ }
942
973
  if (!reg.token || byteLength(reg.token) > MAX_PUSH_TOKEN_BYTES) {
943
974
  throw new Error("[sendoracloud] push token is empty or exceeds 4KB.");
944
975
  }
@@ -948,7 +979,7 @@ var SendoraSDK = class {
948
979
  throw new Error("[sendoracloud] push token metadata exceeds 4KB.");
949
980
  }
950
981
  }
951
- await post(
982
+ const data = await post(
952
983
  { apiUrl: this.config.apiUrl, publicKey: this.config.publicKey, debug: this.debug },
953
984
  "/api/v1/push/tokens",
954
985
  {
@@ -961,6 +992,10 @@ var SendoraSDK = class {
961
992
  metadata: reg.metadata ?? {}
962
993
  }
963
994
  );
995
+ if (!data?.tokenId) {
996
+ throw new Error("[sendoracloud] push token registered but server returned no tokenId \u2014 upgrade backend to >= s58.16.");
997
+ }
998
+ return { tokenId: data.tokenId, created: data.created === true };
964
999
  }
965
1000
  /**
966
1001
  * Clear identity + rotate the anonymous id. Call on logout. Awaits
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendoracloud/sdk-react-native",
3
- "version": "0.10.2",
3
+ "version": "0.10.4",
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",