@sendoracloud/sdk-react-native 0.17.1 → 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 },
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 },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendoracloud/sdk-react-native",
3
- "version": "0.17.1",
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",