@sendoracloud/sdk-react-native 0.17.0 → 0.17.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/README.md CHANGED
@@ -88,7 +88,7 @@ await SendoraCloud.reset();
88
88
  | `track(event, props?)` | Fire a custom product event. |
89
89
  | `screen(name, props?)` | Track a screen view. |
90
90
  | `registerPushToken(reg)` | Register an APNs or FCM push token. |
91
- | `auth.*` | Sign-in / sign-up / MFA / passkeys / OIDC / SSO. See `auth.ts`. |
91
+ | `auth.*` | Sign-in / sign-up / MFA / passkeys / OIDC / SSO + `auth.getRefreshToken()` + `auth.merge(anonRefresh, targetRefresh?)`. See `auth.ts`. |
92
92
  | `links.create<T>(input, opts?)` | Mint a Sendora deep link. Generic `T` for typed `linkData`. |
93
93
  | `links.prewarm<T>(input, opts?)` | Background-mint + cache so share-tap is instant. |
94
94
  | `links.handleUniversalLink(url)` | Resolve a warm-path Universal Link / App Link delivery. |
@@ -183,6 +183,39 @@ Full paste-ready recipes live in [`examples/links-share.tsx`](./examples/links-s
183
183
 
184
184
  `SendoraCloud.auth` covers anonymous sign-in, email + password, magic link, email OTP, TOTP MFA, recovery codes, OIDC / SAML SSO, Sign in with Apple, Google, Microsoft, LinkedIn, Facebook, Discord. See [auth quickstart](https://sendoracloud.com/docs/quickstart-react-native#auth).
185
185
 
186
+ ### Anonymous → identified lifecycle
187
+
188
+ The SDK creates an anonymous user on first `init()`. Every `track()` / `identify()` attaches to that `user_id` until the user signs up or signs in. Two transitions exist; both preserve analytics continuity, but the second one requires one extra line of code from your app:
189
+
190
+ **Case 1 — fresh signup.** `auth.signUp(email, password)` detects the anonymous refresh token and calls `/auth-service/upgrade` first, which flips `is_anonymous=false` + sets the password on the SAME row. The `user_id` is preserved — no funnel break. Falls back to a fresh `/signup` if the bound user is already identified (rare race).
191
+
192
+ ```ts
193
+ const { user } = await SendoraCloud.auth.signUp(email, password);
194
+ // user.id === the same id the anon user had. Done.
195
+ ```
196
+
197
+ **Case 2 — email already exists.** `signUp()` throws `EmailAlreadyTakenError` without consuming the anon refresh. You fall back to `signIn()` — but `signIn()` wipes the local anon identity, so anon events stay orphaned on the backend unless you transfer them with `auth.merge(anonRefresh)`. Capture the anon refresh BEFORE the signIn:
198
+
199
+ ```ts
200
+ try {
201
+ const { user } = await SendoraCloud.auth.signUp(email, password);
202
+ } catch (err) {
203
+ if (!(err instanceof EmailAlreadyTakenError)) throw err;
204
+
205
+ const anonRefresh = await SendoraCloud.auth.getRefreshToken();
206
+ const { user } = await SendoraCloud.auth.signIn(email, password);
207
+
208
+ // Best-effort, silent-fail-tolerant — anon row may be already drained.
209
+ if (anonRefresh) {
210
+ SendoraCloud.auth.merge(anonRefresh).catch(() => undefined);
211
+ }
212
+ }
213
+ ```
214
+
215
+ **Why merge isn't automatic on signIn.** Two people sharing a device (browser kiosk, family iPad) would silently get their anon-session events combined with a third party's real account. Forcing the merge call into your app keeps the data-mixing decision explicit. Wrap `signIn()` with the snippet above if you want auto behaviour.
216
+
217
+ Full reference + edge-case writeup: [/docs/quickstart-react-native#anon-existing-email](https://sendoracloud.com/docs/quickstart-react-native#anon-existing-email).
218
+
186
219
  ## Config
187
220
 
188
221
  ```ts
package/dist/index.cjs CHANGED
@@ -785,6 +785,75 @@ var Auth = class {
785
785
  })();
786
786
  return this.refreshInflight;
787
787
  }
788
+ /**
789
+ * Returns the currently-stored refresh token (or null when signed out).
790
+ * Primary use: capture the anonymous refresh token BEFORE calling
791
+ * `signIn()` so it can be transferred via `merge()` after the existing
792
+ * account is authenticated. See the "Anonymous → identified" section
793
+ * of the SDK README + /docs/quickstart-react-native#anon-existing-email.
794
+ */
795
+ getRefreshToken() {
796
+ return this.hooks.storage.get(REFRESH_KEY) ?? null;
797
+ }
798
+ /**
799
+ * Transfer every event + trait from an anonymous user to the
800
+ * currently-signed-in (target) user, then delete the anonymous row.
801
+ * `targetRefreshToken` defaults to the SDK's currently-stored refresh
802
+ * token — call this AFTER a successful `signIn()` so the storage
803
+ * already holds the target's refresh.
804
+ *
805
+ * Throws AuthError when:
806
+ * - `anonymousRefreshToken` is invalid / expired / revoked,
807
+ * - the bound source row is not anonymous (CONFLICT — use upgrade),
808
+ * - the target is anonymous (CONFLICT — sign in first),
809
+ * - both tokens resolve to the same user (CONFLICT — no-op).
810
+ *
811
+ * Mirrors Auth0 `accounts.link` + Firebase manual transfer semantics.
812
+ */
813
+ async merge(anonymousRefreshToken, targetRefreshToken) {
814
+ const target = targetRefreshToken ?? this.getRefreshToken();
815
+ if (!target) {
816
+ throw new AuthError(
817
+ "NO_TARGET_REFRESH",
818
+ "merge() needs a target refresh token. Call signIn() first or pass targetRefreshToken explicitly."
819
+ );
820
+ }
821
+ const res = await post(
822
+ { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
823
+ "/api/v1/auth-service/merge",
824
+ { anonymousRefreshToken, targetRefreshToken: target }
825
+ );
826
+ if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
827
+ let parsed;
828
+ try {
829
+ parsed = await res.json();
830
+ } catch {
831
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
832
+ }
833
+ if (!res.ok || !parsed.success || !parsed.data) {
834
+ throw new AuthError(
835
+ parsed.error?.code ?? `HTTP_${res.status}`,
836
+ parsed.error?.message ?? `HTTP ${res.status}`
837
+ );
838
+ }
839
+ const t = parsed.data.tokens;
840
+ if (t?.accessToken && t.refreshToken && typeof t.expiresIn === "number") {
841
+ this.accessToken = t.accessToken;
842
+ this.accessExpiresAt = Date.now() + t.expiresIn * 1e3;
843
+ this.hooks.storage.set(TOKEN_KEY, t.accessToken);
844
+ this.hooks.storage.set(TOKEN_EXPIRES_KEY, String(this.accessExpiresAt));
845
+ this.hooks.storage.set(REFRESH_KEY, t.refreshToken);
846
+ }
847
+ return {
848
+ mergedUserId: parsed.data.mergedUserId ?? "",
849
+ deletedAnonymousId: parsed.data.deletedAnonymousId ?? "",
850
+ eventsReassigned: parsed.data.eventsReassigned ?? 0,
851
+ profilesReassigned: parsed.data.profilesReassigned ?? 0,
852
+ profilesDropped: parsed.data.profilesDropped ?? 0,
853
+ entitiesReassigned: parsed.data.entitiesReassigned ?? 0,
854
+ entitiesDropped: parsed.data.entitiesDropped ?? 0
855
+ };
856
+ }
788
857
  async callAuth(path, body) {
789
858
  const res = await post(
790
859
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
@@ -848,6 +917,7 @@ function decodeJwtPayload(token) {
848
917
  }
849
918
 
850
919
  // src/links.ts
920
+ var import_react_native = require("react-native");
851
921
  var LinkError = class extends Error {
852
922
  constructor(code, message, statusCode = 0, details) {
853
923
  super(message);
@@ -925,7 +995,12 @@ function dynRequire(id) {
925
995
  }
926
996
  }
927
997
  function loadRn() {
928
- return dynRequire("react-native");
998
+ return {
999
+ Platform: import_react_native.Platform,
1000
+ Dimensions: import_react_native.Dimensions,
1001
+ NativeModules: import_react_native.NativeModules,
1002
+ Linking: import_react_native.Linking
1003
+ };
929
1004
  }
930
1005
  function readTimezone() {
931
1006
  try {
@@ -1406,6 +1481,7 @@ var Links = class {
1406
1481
  };
1407
1482
 
1408
1483
  // src/auto-track.ts
1484
+ var import_react_native2 = require("react-native");
1409
1485
  var DEFAULT_IDLE_MS = 30 * 60 * 1e3;
1410
1486
  function resolveFlags(cfg) {
1411
1487
  if (cfg === false) {
@@ -1449,14 +1525,7 @@ function installAutoTrack(args) {
1449
1525
  args.fire("app.opened", { sessionId });
1450
1526
  }
1451
1527
  if (flags.appBackground) {
1452
- let AppState = null;
1453
- try {
1454
- const dyn = globalThis.require;
1455
- const mod = dyn ? dyn("react-native") : null;
1456
- AppState = mod?.AppState ?? null;
1457
- } catch {
1458
- AppState = null;
1459
- }
1528
+ const AppState = import_react_native2.AppState;
1460
1529
  if (AppState && typeof AppState.addEventListener === "function") {
1461
1530
  const handler = (state) => {
1462
1531
  ensureSession(true);
package/dist/index.d.cts CHANGED
@@ -299,6 +299,38 @@ declare class Auth {
299
299
  * surface NOT_SIGNED_IN to the host app instead of looping.
300
300
  */
301
301
  private refreshAccessToken;
302
+ /**
303
+ * Returns the currently-stored refresh token (or null when signed out).
304
+ * Primary use: capture the anonymous refresh token BEFORE calling
305
+ * `signIn()` so it can be transferred via `merge()` after the existing
306
+ * account is authenticated. See the "Anonymous → identified" section
307
+ * of the SDK README + /docs/quickstart-react-native#anon-existing-email.
308
+ */
309
+ getRefreshToken(): string | null;
310
+ /**
311
+ * Transfer every event + trait from an anonymous user to the
312
+ * currently-signed-in (target) user, then delete the anonymous row.
313
+ * `targetRefreshToken` defaults to the SDK's currently-stored refresh
314
+ * token — call this AFTER a successful `signIn()` so the storage
315
+ * already holds the target's refresh.
316
+ *
317
+ * Throws AuthError when:
318
+ * - `anonymousRefreshToken` is invalid / expired / revoked,
319
+ * - the bound source row is not anonymous (CONFLICT — use upgrade),
320
+ * - the target is anonymous (CONFLICT — sign in first),
321
+ * - both tokens resolve to the same user (CONFLICT — no-op).
322
+ *
323
+ * Mirrors Auth0 `accounts.link` + Firebase manual transfer semantics.
324
+ */
325
+ merge(anonymousRefreshToken: string, targetRefreshToken?: string): Promise<{
326
+ mergedUserId: string;
327
+ deletedAnonymousId: string;
328
+ eventsReassigned: number;
329
+ profilesReassigned: number;
330
+ profilesDropped: number;
331
+ entitiesReassigned: number;
332
+ entitiesDropped: number;
333
+ }>;
302
334
  private callAuth;
303
335
  private persist;
304
336
  private wipeLocalIdentity;
package/dist/index.d.ts CHANGED
@@ -299,6 +299,38 @@ declare class Auth {
299
299
  * surface NOT_SIGNED_IN to the host app instead of looping.
300
300
  */
301
301
  private refreshAccessToken;
302
+ /**
303
+ * Returns the currently-stored refresh token (or null when signed out).
304
+ * Primary use: capture the anonymous refresh token BEFORE calling
305
+ * `signIn()` so it can be transferred via `merge()` after the existing
306
+ * account is authenticated. See the "Anonymous → identified" section
307
+ * of the SDK README + /docs/quickstart-react-native#anon-existing-email.
308
+ */
309
+ getRefreshToken(): string | null;
310
+ /**
311
+ * Transfer every event + trait from an anonymous user to the
312
+ * currently-signed-in (target) user, then delete the anonymous row.
313
+ * `targetRefreshToken` defaults to the SDK's currently-stored refresh
314
+ * token — call this AFTER a successful `signIn()` so the storage
315
+ * already holds the target's refresh.
316
+ *
317
+ * Throws AuthError when:
318
+ * - `anonymousRefreshToken` is invalid / expired / revoked,
319
+ * - the bound source row is not anonymous (CONFLICT — use upgrade),
320
+ * - the target is anonymous (CONFLICT — sign in first),
321
+ * - both tokens resolve to the same user (CONFLICT — no-op).
322
+ *
323
+ * Mirrors Auth0 `accounts.link` + Firebase manual transfer semantics.
324
+ */
325
+ merge(anonymousRefreshToken: string, targetRefreshToken?: string): Promise<{
326
+ mergedUserId: string;
327
+ deletedAnonymousId: string;
328
+ eventsReassigned: number;
329
+ profilesReassigned: number;
330
+ profilesDropped: number;
331
+ entitiesReassigned: number;
332
+ entitiesDropped: number;
333
+ }>;
302
334
  private callAuth;
303
335
  private persist;
304
336
  private wipeLocalIdentity;
package/dist/index.js CHANGED
@@ -742,6 +742,75 @@ var Auth = class {
742
742
  })();
743
743
  return this.refreshInflight;
744
744
  }
745
+ /**
746
+ * Returns the currently-stored refresh token (or null when signed out).
747
+ * Primary use: capture the anonymous refresh token BEFORE calling
748
+ * `signIn()` so it can be transferred via `merge()` after the existing
749
+ * account is authenticated. See the "Anonymous → identified" section
750
+ * of the SDK README + /docs/quickstart-react-native#anon-existing-email.
751
+ */
752
+ getRefreshToken() {
753
+ return this.hooks.storage.get(REFRESH_KEY) ?? null;
754
+ }
755
+ /**
756
+ * Transfer every event + trait from an anonymous user to the
757
+ * currently-signed-in (target) user, then delete the anonymous row.
758
+ * `targetRefreshToken` defaults to the SDK's currently-stored refresh
759
+ * token — call this AFTER a successful `signIn()` so the storage
760
+ * already holds the target's refresh.
761
+ *
762
+ * Throws AuthError when:
763
+ * - `anonymousRefreshToken` is invalid / expired / revoked,
764
+ * - the bound source row is not anonymous (CONFLICT — use upgrade),
765
+ * - the target is anonymous (CONFLICT — sign in first),
766
+ * - both tokens resolve to the same user (CONFLICT — no-op).
767
+ *
768
+ * Mirrors Auth0 `accounts.link` + Firebase manual transfer semantics.
769
+ */
770
+ async merge(anonymousRefreshToken, targetRefreshToken) {
771
+ const target = targetRefreshToken ?? this.getRefreshToken();
772
+ if (!target) {
773
+ throw new AuthError(
774
+ "NO_TARGET_REFRESH",
775
+ "merge() needs a target refresh token. Call signIn() first or pass targetRefreshToken explicitly."
776
+ );
777
+ }
778
+ const res = await post(
779
+ { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
780
+ "/api/v1/auth-service/merge",
781
+ { anonymousRefreshToken, targetRefreshToken: target }
782
+ );
783
+ if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
784
+ let parsed;
785
+ try {
786
+ parsed = await res.json();
787
+ } catch {
788
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
789
+ }
790
+ if (!res.ok || !parsed.success || !parsed.data) {
791
+ throw new AuthError(
792
+ parsed.error?.code ?? `HTTP_${res.status}`,
793
+ parsed.error?.message ?? `HTTP ${res.status}`
794
+ );
795
+ }
796
+ const t = parsed.data.tokens;
797
+ if (t?.accessToken && t.refreshToken && typeof t.expiresIn === "number") {
798
+ this.accessToken = t.accessToken;
799
+ this.accessExpiresAt = Date.now() + t.expiresIn * 1e3;
800
+ this.hooks.storage.set(TOKEN_KEY, t.accessToken);
801
+ this.hooks.storage.set(TOKEN_EXPIRES_KEY, String(this.accessExpiresAt));
802
+ this.hooks.storage.set(REFRESH_KEY, t.refreshToken);
803
+ }
804
+ return {
805
+ mergedUserId: parsed.data.mergedUserId ?? "",
806
+ deletedAnonymousId: parsed.data.deletedAnonymousId ?? "",
807
+ eventsReassigned: parsed.data.eventsReassigned ?? 0,
808
+ profilesReassigned: parsed.data.profilesReassigned ?? 0,
809
+ profilesDropped: parsed.data.profilesDropped ?? 0,
810
+ entitiesReassigned: parsed.data.entitiesReassigned ?? 0,
811
+ entitiesDropped: parsed.data.entitiesDropped ?? 0
812
+ };
813
+ }
745
814
  async callAuth(path, body) {
746
815
  const res = await post(
747
816
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
@@ -805,6 +874,12 @@ function decodeJwtPayload(token) {
805
874
  }
806
875
 
807
876
  // src/links.ts
877
+ import {
878
+ Dimensions as RnDimensions,
879
+ Linking as RnLinking,
880
+ NativeModules as RnNativeModules,
881
+ Platform as RnPlatform
882
+ } from "react-native";
808
883
  var LinkError = class extends Error {
809
884
  constructor(code, message, statusCode = 0, details) {
810
885
  super(message);
@@ -882,7 +957,12 @@ function dynRequire(id) {
882
957
  }
883
958
  }
884
959
  function loadRn() {
885
- return dynRequire("react-native");
960
+ return {
961
+ Platform: RnPlatform,
962
+ Dimensions: RnDimensions,
963
+ NativeModules: RnNativeModules,
964
+ Linking: RnLinking
965
+ };
886
966
  }
887
967
  function readTimezone() {
888
968
  try {
@@ -1363,6 +1443,7 @@ var Links = class {
1363
1443
  };
1364
1444
 
1365
1445
  // src/auto-track.ts
1446
+ import { AppState as RnAppState } from "react-native";
1366
1447
  var DEFAULT_IDLE_MS = 30 * 60 * 1e3;
1367
1448
  function resolveFlags(cfg) {
1368
1449
  if (cfg === false) {
@@ -1406,14 +1487,7 @@ function installAutoTrack(args) {
1406
1487
  args.fire("app.opened", { sessionId });
1407
1488
  }
1408
1489
  if (flags.appBackground) {
1409
- let AppState = null;
1410
- try {
1411
- const dyn = globalThis.require;
1412
- const mod = dyn ? dyn("react-native") : null;
1413
- AppState = mod?.AppState ?? null;
1414
- } catch {
1415
- AppState = null;
1416
- }
1490
+ const AppState = RnAppState;
1417
1491
  if (AppState && typeof AppState.addEventListener === "function") {
1418
1492
  const handler = (state) => {
1419
1493
  ensureSession(true);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendoracloud/sdk-react-native",
3
- "version": "0.17.0",
3
+ "version": "0.17.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",