@sendoracloud/sdk-react-native 1.0.3 → 1.0.5

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
@@ -237,6 +237,8 @@ var Auth = class {
237
237
  this.accessExpiresAt = 0;
238
238
  this.inflight = Promise.resolve();
239
239
  this.refreshInflight = null;
240
+ this.takeoverListeners = /* @__PURE__ */ new Set();
241
+ this.lastTakeover = null;
240
242
  /**
241
243
  * Proactive refresh (s58.72). Fires when access-token age crosses
242
244
  * ~80% of TTL. Pairs with the AppState foreground listener so a
@@ -299,6 +301,47 @@ var Auth = class {
299
301
  getCurrentUser() {
300
302
  return this.user;
301
303
  }
304
+ /**
305
+ * Subscribe to device-takeover events (s58.111+). Backend retires
306
+ * the anonymous user_id whenever a previously-anon device signs in
307
+ * to an identified account. Use this to clean up any local mirror
308
+ * keyed by the anonymous id — your own users table, an analytics
309
+ * cache, an audience filter — without needing a webhook receiver.
310
+ *
311
+ * Listener is invoked from `signIn`, `loginSocial`, `verifyMagicLink`,
312
+ * `verifyEmailOtp`, `passkey authentication`, and SSO completion
313
+ * (via `consumeSsoFragment`). Returns an unsubscribe function.
314
+ *
315
+ * Listeners are best-effort: errors thrown inside them are swallowed
316
+ * + logged in dev mode. Auth state never depends on listener success.
317
+ */
318
+ onDeviceTakeover(listener) {
319
+ this.takeoverListeners.add(listener);
320
+ return () => {
321
+ this.takeoverListeners.delete(listener);
322
+ };
323
+ }
324
+ /**
325
+ * Inspect the most recent device-takeover event observed by the
326
+ * SDK. Cleared on signOut. Returns null when no takeover has
327
+ * happened during this session — useful for late subscribers or
328
+ * post-sign-in side-effects (e.g. app-launch hydrate).
329
+ */
330
+ getLastDeviceTakeover() {
331
+ return this.lastTakeover;
332
+ }
333
+ /** Internal — fan out to subscribers + cache the latest. */
334
+ fireDeviceTakeover(retiredAnonUserId, identifiedUserId) {
335
+ const evt = { retiredAnonUserId, identifiedUserId, at: /* @__PURE__ */ new Date() };
336
+ this.lastTakeover = evt;
337
+ for (const fn of this.takeoverListeners) {
338
+ try {
339
+ fn(evt);
340
+ } catch (err) {
341
+ if (this.hooks.debug) console.warn("[sendoracloud] onDeviceTakeover listener threw", err);
342
+ }
343
+ }
344
+ }
302
345
  /**
303
346
  * Returns a non-expired access token or null. If the cached token
304
347
  * is expired but a refresh token exists, transparently refreshes
@@ -684,10 +727,13 @@ var Auth = class {
684
727
  * call `consumeSsoFragment(url)` on the captured URL.
685
728
  */
686
729
  async startSso(returnTo) {
730
+ const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
731
+ const body = { returnTo };
732
+ if (prevAnonRefreshToken) body.prevAnonRefreshToken = prevAnonRefreshToken;
687
733
  const res = await post(
688
734
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
689
735
  "/api/v1/auth-service/sso/oidc/start",
690
- { returnTo }
736
+ body
691
737
  );
692
738
  if (!res || !res.ok) throw new AuthError("SSO_START_FAILED", `HTTP ${res?.status ?? "network"}`);
693
739
  const parsed = await res.json();
@@ -707,10 +753,13 @@ var Auth = class {
707
753
  * and SAML fragments transparently.
708
754
  */
709
755
  async startSaml(returnTo) {
756
+ const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
757
+ const body = { returnTo };
758
+ if (prevAnonRefreshToken) body.prevAnonRefreshToken = prevAnonRefreshToken;
710
759
  const res = await post(
711
760
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
712
761
  "/api/v1/auth-service/sso/saml/start",
713
- { returnTo }
762
+ body
714
763
  );
715
764
  if (!res || !res.ok) throw new AuthError("SAML_START_FAILED", `HTTP ${res?.status ?? "network"}`);
716
765
  const parsed = await res.json();
@@ -735,6 +784,7 @@ var Auth = class {
735
784
  if (err) throw new AuthError("SSO_FAILED", decodeURIComponent(err));
736
785
  const refresh = params.get("sendora_oidc_token") ?? params.get("sendora_saml_token");
737
786
  if (!refresh) throw new AuthError("SSO_NO_TOKEN", "Callback URL has no SSO token");
787
+ const retiredAnonUserId = params.get("sendora_retired_anon");
738
788
  const res = await post(
739
789
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
740
790
  "/api/v1/auth-service/token/refresh",
@@ -763,7 +813,8 @@ var Auth = class {
763
813
  refreshToken: newRefresh,
764
814
  expiresIn,
765
815
  tokenType: "Bearer"
766
- }
816
+ },
817
+ ...retiredAnonUserId ? { retiredAnonUserId } : {}
767
818
  });
768
819
  return user;
769
820
  });
@@ -1004,11 +1055,15 @@ var Auth = class {
1004
1055
  this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
1005
1056
  this.hooks.onIdentityChange(data.user.id);
1006
1057
  this.startProactiveRefreshCron();
1058
+ if (data.retiredAnonUserId) {
1059
+ this.fireDeviceTakeover(data.retiredAnonUserId, data.user.id);
1060
+ }
1007
1061
  }
1008
1062
  async wipeLocalIdentity() {
1009
1063
  this.user = null;
1010
1064
  this.accessToken = null;
1011
1065
  this.accessExpiresAt = 0;
1066
+ this.lastTakeover = null;
1012
1067
  this.hooks.storage.remove(USER_KEY);
1013
1068
  this.hooks.storage.remove(TOKEN_KEY);
1014
1069
  this.hooks.storage.remove(TOKEN_EXPIRES_KEY);
package/dist/index.d.cts CHANGED
@@ -99,6 +99,19 @@ interface AuthHooks {
99
99
  publicKey: string;
100
100
  debug: boolean;
101
101
  }
102
+ /**
103
+ * Detail handed to onDeviceTakeover subscribers. Fires once per
104
+ * signIn / loginSocial / verifyMagicLink / verifyEmailOtp /
105
+ * passkey-auth / SSO completion that retires an anonymous row.
106
+ */
107
+ interface DeviceTakeoverEvent {
108
+ /** The anonymous user_id Sendora just hard-deleted. */
109
+ retiredAnonUserId: string;
110
+ /** The identified user_id that took the device over. */
111
+ identifiedUserId: string;
112
+ /** Local clock at the time the SDK observed the event. */
113
+ at: Date;
114
+ }
102
115
  declare class Auth {
103
116
  private hooks;
104
117
  private user;
@@ -106,6 +119,8 @@ declare class Auth {
106
119
  private accessExpiresAt;
107
120
  private inflight;
108
121
  private refreshInflight;
122
+ private takeoverListeners;
123
+ private lastTakeover;
109
124
  /**
110
125
  * Resolves when the parent SDK has finished its async init() —
111
126
  * AsyncStorage hydrated + auth.hydrate() restored the cached
@@ -142,6 +157,30 @@ declare class Auth {
142
157
  */
143
158
  hydrate(): void;
144
159
  getCurrentUser(): AuthUser | null;
160
+ /**
161
+ * Subscribe to device-takeover events (s58.111+). Backend retires
162
+ * the anonymous user_id whenever a previously-anon device signs in
163
+ * to an identified account. Use this to clean up any local mirror
164
+ * keyed by the anonymous id — your own users table, an analytics
165
+ * cache, an audience filter — without needing a webhook receiver.
166
+ *
167
+ * Listener is invoked from `signIn`, `loginSocial`, `verifyMagicLink`,
168
+ * `verifyEmailOtp`, `passkey authentication`, and SSO completion
169
+ * (via `consumeSsoFragment`). Returns an unsubscribe function.
170
+ *
171
+ * Listeners are best-effort: errors thrown inside them are swallowed
172
+ * + logged in dev mode. Auth state never depends on listener success.
173
+ */
174
+ onDeviceTakeover(listener: (evt: DeviceTakeoverEvent) => void): () => void;
175
+ /**
176
+ * Inspect the most recent device-takeover event observed by the
177
+ * SDK. Cleared on signOut. Returns null when no takeover has
178
+ * happened during this session — useful for late subscribers or
179
+ * post-sign-in side-effects (e.g. app-launch hydrate).
180
+ */
181
+ getLastDeviceTakeover(): DeviceTakeoverEvent | null;
182
+ /** Internal — fan out to subscribers + cache the latest. */
183
+ private fireDeviceTakeover;
145
184
  /**
146
185
  * Returns a non-expired access token or null. If the cached token
147
186
  * is expired but a refresh token exists, transparently refreshes
package/dist/index.d.ts CHANGED
@@ -99,6 +99,19 @@ interface AuthHooks {
99
99
  publicKey: string;
100
100
  debug: boolean;
101
101
  }
102
+ /**
103
+ * Detail handed to onDeviceTakeover subscribers. Fires once per
104
+ * signIn / loginSocial / verifyMagicLink / verifyEmailOtp /
105
+ * passkey-auth / SSO completion that retires an anonymous row.
106
+ */
107
+ interface DeviceTakeoverEvent {
108
+ /** The anonymous user_id Sendora just hard-deleted. */
109
+ retiredAnonUserId: string;
110
+ /** The identified user_id that took the device over. */
111
+ identifiedUserId: string;
112
+ /** Local clock at the time the SDK observed the event. */
113
+ at: Date;
114
+ }
102
115
  declare class Auth {
103
116
  private hooks;
104
117
  private user;
@@ -106,6 +119,8 @@ declare class Auth {
106
119
  private accessExpiresAt;
107
120
  private inflight;
108
121
  private refreshInflight;
122
+ private takeoverListeners;
123
+ private lastTakeover;
109
124
  /**
110
125
  * Resolves when the parent SDK has finished its async init() —
111
126
  * AsyncStorage hydrated + auth.hydrate() restored the cached
@@ -142,6 +157,30 @@ declare class Auth {
142
157
  */
143
158
  hydrate(): void;
144
159
  getCurrentUser(): AuthUser | null;
160
+ /**
161
+ * Subscribe to device-takeover events (s58.111+). Backend retires
162
+ * the anonymous user_id whenever a previously-anon device signs in
163
+ * to an identified account. Use this to clean up any local mirror
164
+ * keyed by the anonymous id — your own users table, an analytics
165
+ * cache, an audience filter — without needing a webhook receiver.
166
+ *
167
+ * Listener is invoked from `signIn`, `loginSocial`, `verifyMagicLink`,
168
+ * `verifyEmailOtp`, `passkey authentication`, and SSO completion
169
+ * (via `consumeSsoFragment`). Returns an unsubscribe function.
170
+ *
171
+ * Listeners are best-effort: errors thrown inside them are swallowed
172
+ * + logged in dev mode. Auth state never depends on listener success.
173
+ */
174
+ onDeviceTakeover(listener: (evt: DeviceTakeoverEvent) => void): () => void;
175
+ /**
176
+ * Inspect the most recent device-takeover event observed by the
177
+ * SDK. Cleared on signOut. Returns null when no takeover has
178
+ * happened during this session — useful for late subscribers or
179
+ * post-sign-in side-effects (e.g. app-launch hydrate).
180
+ */
181
+ getLastDeviceTakeover(): DeviceTakeoverEvent | null;
182
+ /** Internal — fan out to subscribers + cache the latest. */
183
+ private fireDeviceTakeover;
145
184
  /**
146
185
  * Returns a non-expired access token or null. If the cached token
147
186
  * is expired but a refresh token exists, transparently refreshes
package/dist/index.js CHANGED
@@ -194,6 +194,8 @@ var Auth = class {
194
194
  this.accessExpiresAt = 0;
195
195
  this.inflight = Promise.resolve();
196
196
  this.refreshInflight = null;
197
+ this.takeoverListeners = /* @__PURE__ */ new Set();
198
+ this.lastTakeover = null;
197
199
  /**
198
200
  * Proactive refresh (s58.72). Fires when access-token age crosses
199
201
  * ~80% of TTL. Pairs with the AppState foreground listener so a
@@ -256,6 +258,47 @@ var Auth = class {
256
258
  getCurrentUser() {
257
259
  return this.user;
258
260
  }
261
+ /**
262
+ * Subscribe to device-takeover events (s58.111+). Backend retires
263
+ * the anonymous user_id whenever a previously-anon device signs in
264
+ * to an identified account. Use this to clean up any local mirror
265
+ * keyed by the anonymous id — your own users table, an analytics
266
+ * cache, an audience filter — without needing a webhook receiver.
267
+ *
268
+ * Listener is invoked from `signIn`, `loginSocial`, `verifyMagicLink`,
269
+ * `verifyEmailOtp`, `passkey authentication`, and SSO completion
270
+ * (via `consumeSsoFragment`). Returns an unsubscribe function.
271
+ *
272
+ * Listeners are best-effort: errors thrown inside them are swallowed
273
+ * + logged in dev mode. Auth state never depends on listener success.
274
+ */
275
+ onDeviceTakeover(listener) {
276
+ this.takeoverListeners.add(listener);
277
+ return () => {
278
+ this.takeoverListeners.delete(listener);
279
+ };
280
+ }
281
+ /**
282
+ * Inspect the most recent device-takeover event observed by the
283
+ * SDK. Cleared on signOut. Returns null when no takeover has
284
+ * happened during this session — useful for late subscribers or
285
+ * post-sign-in side-effects (e.g. app-launch hydrate).
286
+ */
287
+ getLastDeviceTakeover() {
288
+ return this.lastTakeover;
289
+ }
290
+ /** Internal — fan out to subscribers + cache the latest. */
291
+ fireDeviceTakeover(retiredAnonUserId, identifiedUserId) {
292
+ const evt = { retiredAnonUserId, identifiedUserId, at: /* @__PURE__ */ new Date() };
293
+ this.lastTakeover = evt;
294
+ for (const fn of this.takeoverListeners) {
295
+ try {
296
+ fn(evt);
297
+ } catch (err) {
298
+ if (this.hooks.debug) console.warn("[sendoracloud] onDeviceTakeover listener threw", err);
299
+ }
300
+ }
301
+ }
259
302
  /**
260
303
  * Returns a non-expired access token or null. If the cached token
261
304
  * is expired but a refresh token exists, transparently refreshes
@@ -641,10 +684,13 @@ var Auth = class {
641
684
  * call `consumeSsoFragment(url)` on the captured URL.
642
685
  */
643
686
  async startSso(returnTo) {
687
+ const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
688
+ const body = { returnTo };
689
+ if (prevAnonRefreshToken) body.prevAnonRefreshToken = prevAnonRefreshToken;
644
690
  const res = await post(
645
691
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
646
692
  "/api/v1/auth-service/sso/oidc/start",
647
- { returnTo }
693
+ body
648
694
  );
649
695
  if (!res || !res.ok) throw new AuthError("SSO_START_FAILED", `HTTP ${res?.status ?? "network"}`);
650
696
  const parsed = await res.json();
@@ -664,10 +710,13 @@ var Auth = class {
664
710
  * and SAML fragments transparently.
665
711
  */
666
712
  async startSaml(returnTo) {
713
+ const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
714
+ const body = { returnTo };
715
+ if (prevAnonRefreshToken) body.prevAnonRefreshToken = prevAnonRefreshToken;
667
716
  const res = await post(
668
717
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
669
718
  "/api/v1/auth-service/sso/saml/start",
670
- { returnTo }
719
+ body
671
720
  );
672
721
  if (!res || !res.ok) throw new AuthError("SAML_START_FAILED", `HTTP ${res?.status ?? "network"}`);
673
722
  const parsed = await res.json();
@@ -692,6 +741,7 @@ var Auth = class {
692
741
  if (err) throw new AuthError("SSO_FAILED", decodeURIComponent(err));
693
742
  const refresh = params.get("sendora_oidc_token") ?? params.get("sendora_saml_token");
694
743
  if (!refresh) throw new AuthError("SSO_NO_TOKEN", "Callback URL has no SSO token");
744
+ const retiredAnonUserId = params.get("sendora_retired_anon");
695
745
  const res = await post(
696
746
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
697
747
  "/api/v1/auth-service/token/refresh",
@@ -720,7 +770,8 @@ var Auth = class {
720
770
  refreshToken: newRefresh,
721
771
  expiresIn,
722
772
  tokenType: "Bearer"
723
- }
773
+ },
774
+ ...retiredAnonUserId ? { retiredAnonUserId } : {}
724
775
  });
725
776
  return user;
726
777
  });
@@ -961,11 +1012,15 @@ var Auth = class {
961
1012
  this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
962
1013
  this.hooks.onIdentityChange(data.user.id);
963
1014
  this.startProactiveRefreshCron();
1015
+ if (data.retiredAnonUserId) {
1016
+ this.fireDeviceTakeover(data.retiredAnonUserId, data.user.id);
1017
+ }
964
1018
  }
965
1019
  async wipeLocalIdentity() {
966
1020
  this.user = null;
967
1021
  this.accessToken = null;
968
1022
  this.accessExpiresAt = 0;
1023
+ this.lastTakeover = null;
969
1024
  this.hooks.storage.remove(USER_KEY);
970
1025
  this.hooks.storage.remove(TOKEN_KEY);
971
1026
  this.hooks.storage.remove(TOKEN_EXPIRES_KEY);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendoracloud/sdk-react-native",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
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",