@sendoracloud/sdk-react-native 1.0.4 → 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
@@ -741,6 +784,7 @@ var Auth = class {
741
784
  if (err) throw new AuthError("SSO_FAILED", decodeURIComponent(err));
742
785
  const refresh = params.get("sendora_oidc_token") ?? params.get("sendora_saml_token");
743
786
  if (!refresh) throw new AuthError("SSO_NO_TOKEN", "Callback URL has no SSO token");
787
+ const retiredAnonUserId = params.get("sendora_retired_anon");
744
788
  const res = await post(
745
789
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
746
790
  "/api/v1/auth-service/token/refresh",
@@ -769,7 +813,8 @@ var Auth = class {
769
813
  refreshToken: newRefresh,
770
814
  expiresIn,
771
815
  tokenType: "Bearer"
772
- }
816
+ },
817
+ ...retiredAnonUserId ? { retiredAnonUserId } : {}
773
818
  });
774
819
  return user;
775
820
  });
@@ -1010,11 +1055,15 @@ var Auth = class {
1010
1055
  this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
1011
1056
  this.hooks.onIdentityChange(data.user.id);
1012
1057
  this.startProactiveRefreshCron();
1058
+ if (data.retiredAnonUserId) {
1059
+ this.fireDeviceTakeover(data.retiredAnonUserId, data.user.id);
1060
+ }
1013
1061
  }
1014
1062
  async wipeLocalIdentity() {
1015
1063
  this.user = null;
1016
1064
  this.accessToken = null;
1017
1065
  this.accessExpiresAt = 0;
1066
+ this.lastTakeover = null;
1018
1067
  this.hooks.storage.remove(USER_KEY);
1019
1068
  this.hooks.storage.remove(TOKEN_KEY);
1020
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
@@ -698,6 +741,7 @@ var Auth = class {
698
741
  if (err) throw new AuthError("SSO_FAILED", decodeURIComponent(err));
699
742
  const refresh = params.get("sendora_oidc_token") ?? params.get("sendora_saml_token");
700
743
  if (!refresh) throw new AuthError("SSO_NO_TOKEN", "Callback URL has no SSO token");
744
+ const retiredAnonUserId = params.get("sendora_retired_anon");
701
745
  const res = await post(
702
746
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
703
747
  "/api/v1/auth-service/token/refresh",
@@ -726,7 +770,8 @@ var Auth = class {
726
770
  refreshToken: newRefresh,
727
771
  expiresIn,
728
772
  tokenType: "Bearer"
729
- }
773
+ },
774
+ ...retiredAnonUserId ? { retiredAnonUserId } : {}
730
775
  });
731
776
  return user;
732
777
  });
@@ -967,11 +1012,15 @@ var Auth = class {
967
1012
  this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
968
1013
  this.hooks.onIdentityChange(data.user.id);
969
1014
  this.startProactiveRefreshCron();
1015
+ if (data.retiredAnonUserId) {
1016
+ this.fireDeviceTakeover(data.retiredAnonUserId, data.user.id);
1017
+ }
970
1018
  }
971
1019
  async wipeLocalIdentity() {
972
1020
  this.user = null;
973
1021
  this.accessToken = null;
974
1022
  this.accessExpiresAt = 0;
1023
+ this.lastTakeover = null;
975
1024
  this.hooks.storage.remove(USER_KEY);
976
1025
  this.hooks.storage.remove(TOKEN_KEY);
977
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.4",
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",