@sendoracloud/sdk-react-native 1.0.4 → 1.1.0

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
@@ -136,16 +136,36 @@ var Storage = class {
136
136
  };
137
137
 
138
138
  // src/http.ts
139
- async function post(opts, path, body, extraHeaders) {
140
- return request(opts, "POST", path, body, extraHeaders);
139
+ var NetworkTimeoutError = class extends Error {
140
+ constructor(method, path, timeoutMs) {
141
+ super(`Request timed out: ${method} ${path} (${timeoutMs}ms)`);
142
+ this.name = "NetworkTimeoutError";
143
+ this.method = method;
144
+ this.path = path;
145
+ this.timeoutMs = timeoutMs;
146
+ }
147
+ };
148
+ var DEFAULT_AUTH_TIMEOUT_MS = 1e4;
149
+ var DEFAULT_INGEST_TIMEOUT_MS = 3e4;
150
+ function defaultTimeoutFor(path) {
151
+ if (path.startsWith("/api/v1/auth-service/")) return DEFAULT_AUTH_TIMEOUT_MS;
152
+ return DEFAULT_INGEST_TIMEOUT_MS;
153
+ }
154
+ async function post(opts, path, body, extraHeaders, reqOpts) {
155
+ return request(opts, "POST", path, body, extraHeaders, reqOpts);
141
156
  }
142
- async function getJson(opts, path, extraHeaders) {
143
- return request(opts, "GET", path, void 0, extraHeaders);
157
+ async function getJson(opts, path, extraHeaders, reqOpts) {
158
+ return request(opts, "GET", path, void 0, extraHeaders, reqOpts);
144
159
  }
145
- async function del(opts, path, extraHeaders) {
146
- return request(opts, "DELETE", path, void 0, extraHeaders);
160
+ async function del(opts, path, extraHeaders, reqOpts) {
161
+ return request(opts, "DELETE", path, void 0, extraHeaders, reqOpts);
147
162
  }
148
- async function request(opts, method, path, body, extraHeaders) {
163
+ async function request(opts, method, path, body, extraHeaders, reqOpts) {
164
+ const timeoutMs = reqOpts?.timeoutMs ?? defaultTimeoutFor(path);
165
+ const controller = new AbortController();
166
+ const timer = setTimeout(() => {
167
+ controller.abort();
168
+ }, timeoutMs);
149
169
  let res;
150
170
  try {
151
171
  res = await fetch(`${opts.apiUrl}${path}`, {
@@ -160,15 +180,20 @@ async function request(opts, method, path, body, extraHeaders) {
160
180
  "x-api-key": opts.publicKey,
161
181
  ...extraHeaders ?? {}
162
182
  },
163
- body: body !== void 0 ? JSON.stringify(body) : void 0
183
+ body: body !== void 0 ? JSON.stringify(body) : void 0,
184
+ signal: controller.signal
164
185
  });
165
186
  } catch (err) {
187
+ clearTimeout(timer);
188
+ const aborted = err?.name === "AbortError";
189
+ if (aborted) throw new NetworkTimeoutError(method, path, timeoutMs);
166
190
  console.warn(
167
191
  `[sendoracloud] ${method} ${path} failed at the network layer:`,
168
192
  err instanceof Error ? err.message : String(err)
169
193
  );
170
194
  return null;
171
195
  }
196
+ clearTimeout(timer);
172
197
  if (!res.ok) {
173
198
  const isAuthPath = path.startsWith("/api/v1/auth-service/");
174
199
  if (isAuthPath && !opts.debug) {
@@ -237,6 +262,21 @@ var Auth = class {
237
262
  this.accessExpiresAt = 0;
238
263
  this.inflight = Promise.resolve();
239
264
  this.refreshInflight = null;
265
+ /**
266
+ * Wave 62 — refresh failure back-pressure.
267
+ *
268
+ * Consecutive network-timeout failures don't wipe identity (refresh
269
+ * may still be valid; iOS NSURLSession was just stalled) but do
270
+ * back off so the next caller doesn't immediately fire another
271
+ * fetch that will also time out. After `REFRESH_FAILURE_COOLDOWN_MS`
272
+ * the counter resets + retries are allowed. AppState foreground
273
+ * also clears it so a user returning to the app gets an immediate
274
+ * fresh attempt.
275
+ */
276
+ this.refreshFailureCount = 0;
277
+ this.refreshCooldownUntil = 0;
278
+ this.takeoverListeners = /* @__PURE__ */ new Set();
279
+ this.lastTakeover = null;
240
280
  /**
241
281
  * Proactive refresh (s58.72). Fires when access-token age crosses
242
282
  * ~80% of TTL. Pairs with the AppState foreground listener so a
@@ -300,15 +340,79 @@ var Auth = class {
300
340
  return this.user;
301
341
  }
302
342
  /**
303
- * Returns a non-expired access token or null. If the cached token
304
- * is expired but a refresh token exists, transparently refreshes
305
- * (single-flight concurrent callers share one network call).
343
+ * Subscribe to device-takeover events (s58.111+). Backend retires
344
+ * the anonymous user_id whenever a previously-anon device signs in
345
+ * to an identified account. Use this to clean up any local mirror
346
+ * keyed by the anonymous id — your own users table, an analytics
347
+ * cache, an audience filter — without needing a webhook receiver.
348
+ *
349
+ * Listener is invoked from `signIn`, `loginSocial`, `verifyMagicLink`,
350
+ * `verifyEmailOtp`, `passkey authentication`, and SSO completion
351
+ * (via `consumeSsoFragment`). Returns an unsubscribe function.
352
+ *
353
+ * Listeners are best-effort: errors thrown inside them are swallowed
354
+ * + logged in dev mode. Auth state never depends on listener success.
355
+ */
356
+ onDeviceTakeover(listener) {
357
+ this.takeoverListeners.add(listener);
358
+ return () => {
359
+ this.takeoverListeners.delete(listener);
360
+ };
361
+ }
362
+ /**
363
+ * Inspect the most recent device-takeover event observed by the
364
+ * SDK. Cleared on signOut. Returns null when no takeover has
365
+ * happened during this session — useful for late subscribers or
366
+ * post-sign-in side-effects (e.g. app-launch hydrate).
367
+ */
368
+ getLastDeviceTakeover() {
369
+ return this.lastTakeover;
370
+ }
371
+ /** Internal — fan out to subscribers + cache the latest. */
372
+ fireDeviceTakeover(retiredAnonUserId, identifiedUserId) {
373
+ const evt = { retiredAnonUserId, identifiedUserId, at: /* @__PURE__ */ new Date() };
374
+ this.lastTakeover = evt;
375
+ for (const fn of this.takeoverListeners) {
376
+ try {
377
+ fn(evt);
378
+ } catch (err) {
379
+ if (this.hooks.debug) console.warn("[sendoracloud] onDeviceTakeover listener threw", err);
380
+ }
381
+ }
382
+ }
383
+ /**
384
+ * Returns an access token or null. Cold-launch hardening (Wave 62):
385
+ *
386
+ * 1. Non-expired cached → return it. (Fast path.)
387
+ * 2. Expired AND refresh inflight → return stale cached anyway.
388
+ * Backend grace window (60s — see auth-service refresh-rotation
389
+ * middleware) accepts slightly-stale access tokens, so the
390
+ * caller's downstream RPC will succeed against grace while
391
+ * the in-flight refresh resolves out of band. Distinct from
392
+ * pre-Wave-62 behaviour where 5 concurrent callers all shared
393
+ * the same in-flight promise — when that promise hung (iOS
394
+ * NSURLSession cold-launch contention) every caller hung too.
395
+ * 3. Expired + no inflight + refresh available + not in cooldown →
396
+ * fire refresh, await it.
397
+ * 4. Expired + in cooldown → return stale cached. Caller's RPC
398
+ * either succeeds against grace or fails fast on 401, which is
399
+ * a better UX than blocking the whole bridge waiting on a
400
+ * network that we already know is stalled.
401
+ *
402
+ * Returns null only when there's NO usable token at all (no cached
403
+ * + no refresh available).
306
404
  */
307
405
  async getAccessToken() {
308
406
  if (!this.accessToken) return null;
309
407
  if (this.accessExpiresAt && Date.now() < this.accessExpiresAt - REFRESH_SAFETY_MS) {
310
408
  return this.accessToken;
311
409
  }
410
+ if (this.refreshInflight) {
411
+ return this.accessToken;
412
+ }
413
+ if (Date.now() < this.refreshCooldownUntil) {
414
+ return this.accessToken;
415
+ }
312
416
  return this.refreshAccessToken();
313
417
  }
314
418
  /** Sync read — returns whatever's cached even if expired. Prefer the async variant. */
@@ -741,6 +845,7 @@ var Auth = class {
741
845
  if (err) throw new AuthError("SSO_FAILED", decodeURIComponent(err));
742
846
  const refresh = params.get("sendora_oidc_token") ?? params.get("sendora_saml_token");
743
847
  if (!refresh) throw new AuthError("SSO_NO_TOKEN", "Callback URL has no SSO token");
848
+ const retiredAnonUserId = params.get("sendora_retired_anon");
744
849
  const res = await post(
745
850
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
746
851
  "/api/v1/auth-service/token/refresh",
@@ -769,7 +874,8 @@ var Auth = class {
769
874
  refreshToken: newRefresh,
770
875
  expiresIn,
771
876
  tokenType: "Bearer"
772
- }
877
+ },
878
+ ...retiredAnonUserId ? { retiredAnonUserId } : {}
773
879
  });
774
880
  return user;
775
881
  });
@@ -845,6 +951,8 @@ var Auth = class {
845
951
  if (Number.isFinite(freshExpires) && freshExpires > Date.now()) {
846
952
  this.accessToken = freshAccess;
847
953
  this.accessExpiresAt = freshExpires;
954
+ this.refreshFailureCount = 0;
955
+ this.refreshCooldownUntil = 0;
848
956
  return freshAccess;
849
957
  }
850
958
  }
@@ -857,11 +965,19 @@ var Auth = class {
857
965
  this.hooks.storage.set(TOKEN_KEY, data.tokens.accessToken);
858
966
  this.hooks.storage.set(TOKEN_EXPIRES_KEY, String(this.accessExpiresAt));
859
967
  this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
968
+ this.refreshFailureCount = 0;
969
+ this.refreshCooldownUntil = 0;
860
970
  return data.tokens.accessToken;
861
971
  } catch (err) {
862
972
  if (err instanceof AuthError && isDeadRefreshError(err.code)) {
863
973
  await this.wipeLocalIdentity().catch(() => void 0);
974
+ this.refreshFailureCount = 0;
975
+ this.refreshCooldownUntil = 0;
976
+ return null;
864
977
  }
978
+ this.refreshFailureCount += 1;
979
+ const backoffMs = Math.min(3e4, 1e3 * Math.pow(2, this.refreshFailureCount - 1));
980
+ this.refreshCooldownUntil = Date.now() + backoffMs;
865
981
  return null;
866
982
  } finally {
867
983
  this.refreshInflight = null;
@@ -869,6 +985,20 @@ var Auth = class {
869
985
  })();
870
986
  return this.refreshInflight;
871
987
  }
988
+ /**
989
+ * Wave 62 — AppState resume hook. Clears stuck backoff so a user
990
+ * who backgrounded the app during a network blip + returns now
991
+ * gets an immediate fresh refresh attempt instead of waiting on
992
+ * the cooldown timer.
993
+ *
994
+ * Called from the AppState 'active' listener registered in
995
+ * startProactiveRefreshCron. Idempotent + cheap — three field
996
+ * writes; no I/O.
997
+ */
998
+ resetRefreshState() {
999
+ this.refreshFailureCount = 0;
1000
+ this.refreshCooldownUntil = 0;
1001
+ }
872
1002
  startProactiveRefreshCron() {
873
1003
  if (this.proactiveRefreshTimer) return;
874
1004
  const PROACTIVE_TARGET_PCT = 0.8;
@@ -890,7 +1020,10 @@ var Auth = class {
890
1020
  }, TICK_MS);
891
1021
  try {
892
1022
  const subscription = import_react_native.AppState?.addEventListener?.("change", (state) => {
893
- if (state === "active") void tick();
1023
+ if (state === "active") {
1024
+ this.resetRefreshState();
1025
+ void tick();
1026
+ }
894
1027
  });
895
1028
  if (subscription) {
896
1029
  this.proactiveRefreshAppStateUnsub = () => subscription.remove();
@@ -1010,11 +1143,15 @@ var Auth = class {
1010
1143
  this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
1011
1144
  this.hooks.onIdentityChange(data.user.id);
1012
1145
  this.startProactiveRefreshCron();
1146
+ if (data.retiredAnonUserId) {
1147
+ this.fireDeviceTakeover(data.retiredAnonUserId, data.user.id);
1148
+ }
1013
1149
  }
1014
1150
  async wipeLocalIdentity() {
1015
1151
  this.user = null;
1016
1152
  this.accessToken = null;
1017
1153
  this.accessExpiresAt = 0;
1154
+ this.lastTakeover = null;
1018
1155
  this.hooks.storage.remove(USER_KEY);
1019
1156
  this.hooks.storage.remove(TOKEN_KEY);
1020
1157
  this.hooks.storage.remove(TOKEN_EXPIRES_KEY);
@@ -1321,11 +1458,13 @@ async function getPlayInstallReferrer() {
1321
1458
  }
1322
1459
  var PREWARM_TTL_MS = 5 * 6e4;
1323
1460
  var PREWARM_MAX = 50;
1461
+ var PREWARM_MAX_INFLIGHT = 5;
1324
1462
  var Links = class {
1325
1463
  constructor(deps) {
1326
1464
  this.deps = deps;
1327
1465
  this.listeners = [];
1328
1466
  this.prewarmCache = /* @__PURE__ */ new Map();
1467
+ this.prewarmInflight = 0;
1329
1468
  this.linkingSub = null;
1330
1469
  }
1331
1470
  /** Register a callback for warm + deferred deep-link opens. Returns
@@ -1373,14 +1512,25 @@ var Links = class {
1373
1512
  *
1374
1513
  * Fire-and-forget. Errors are swallowed; the matching `create()` call
1375
1514
  * will surface them on demand instead.
1515
+ *
1516
+ * Wave 28 — silently drops the call when more than
1517
+ * `PREWARM_MAX_INFLIGHT` mints are already in flight. Prewarm is
1518
+ * fire-and-forget by contract; an overflow `prewarm()` is fine to
1519
+ * skip because the next matching `create()` will simply do the mint
1520
+ * inline. Caps unbounded fan-out from runaway loops (eg `prewarm()`
1521
+ * in a FlatList renderItem).
1376
1522
  */
1377
1523
  prewarm(input, opts) {
1378
1524
  this.evictExpired();
1379
1525
  const key = this.cacheKey(input, opts?.key);
1380
1526
  if (this.prewarmCache.has(key)) return;
1527
+ if (this.prewarmInflight >= PREWARM_MAX_INFLIGHT) return;
1528
+ this.prewarmInflight++;
1381
1529
  const promise = this.doCreate(input).catch((err) => {
1382
1530
  this.prewarmCache.delete(key);
1383
1531
  throw err;
1532
+ }).finally(() => {
1533
+ this.prewarmInflight--;
1384
1534
  });
1385
1535
  this.prewarmCache.set(key, { promise, ts: Date.now() });
1386
1536
  }
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,21 @@ declare class Auth {
106
119
  private accessExpiresAt;
107
120
  private inflight;
108
121
  private refreshInflight;
122
+ /**
123
+ * Wave 62 — refresh failure back-pressure.
124
+ *
125
+ * Consecutive network-timeout failures don't wipe identity (refresh
126
+ * may still be valid; iOS NSURLSession was just stalled) but do
127
+ * back off so the next caller doesn't immediately fire another
128
+ * fetch that will also time out. After `REFRESH_FAILURE_COOLDOWN_MS`
129
+ * the counter resets + retries are allowed. AppState foreground
130
+ * also clears it so a user returning to the app gets an immediate
131
+ * fresh attempt.
132
+ */
133
+ private refreshFailureCount;
134
+ private refreshCooldownUntil;
135
+ private takeoverListeners;
136
+ private lastTakeover;
109
137
  /**
110
138
  * Resolves when the parent SDK has finished its async init() —
111
139
  * AsyncStorage hydrated + auth.hydrate() restored the cached
@@ -143,9 +171,50 @@ declare class Auth {
143
171
  hydrate(): void;
144
172
  getCurrentUser(): AuthUser | null;
145
173
  /**
146
- * Returns a non-expired access token or null. If the cached token
147
- * is expired but a refresh token exists, transparently refreshes
148
- * (single-flight concurrent callers share one network call).
174
+ * Subscribe to device-takeover events (s58.111+). Backend retires
175
+ * the anonymous user_id whenever a previously-anon device signs in
176
+ * to an identified account. Use this to clean up any local mirror
177
+ * keyed by the anonymous id — your own users table, an analytics
178
+ * cache, an audience filter — without needing a webhook receiver.
179
+ *
180
+ * Listener is invoked from `signIn`, `loginSocial`, `verifyMagicLink`,
181
+ * `verifyEmailOtp`, `passkey authentication`, and SSO completion
182
+ * (via `consumeSsoFragment`). Returns an unsubscribe function.
183
+ *
184
+ * Listeners are best-effort: errors thrown inside them are swallowed
185
+ * + logged in dev mode. Auth state never depends on listener success.
186
+ */
187
+ onDeviceTakeover(listener: (evt: DeviceTakeoverEvent) => void): () => void;
188
+ /**
189
+ * Inspect the most recent device-takeover event observed by the
190
+ * SDK. Cleared on signOut. Returns null when no takeover has
191
+ * happened during this session — useful for late subscribers or
192
+ * post-sign-in side-effects (e.g. app-launch hydrate).
193
+ */
194
+ getLastDeviceTakeover(): DeviceTakeoverEvent | null;
195
+ /** Internal — fan out to subscribers + cache the latest. */
196
+ private fireDeviceTakeover;
197
+ /**
198
+ * Returns an access token or null. Cold-launch hardening (Wave 62):
199
+ *
200
+ * 1. Non-expired cached → return it. (Fast path.)
201
+ * 2. Expired AND refresh inflight → return stale cached anyway.
202
+ * Backend grace window (60s — see auth-service refresh-rotation
203
+ * middleware) accepts slightly-stale access tokens, so the
204
+ * caller's downstream RPC will succeed against grace while
205
+ * the in-flight refresh resolves out of band. Distinct from
206
+ * pre-Wave-62 behaviour where 5 concurrent callers all shared
207
+ * the same in-flight promise — when that promise hung (iOS
208
+ * NSURLSession cold-launch contention) every caller hung too.
209
+ * 3. Expired + no inflight + refresh available + not in cooldown →
210
+ * fire refresh, await it.
211
+ * 4. Expired + in cooldown → return stale cached. Caller's RPC
212
+ * either succeeds against grace or fails fast on 401, which is
213
+ * a better UX than blocking the whole bridge waiting on a
214
+ * network that we already know is stalled.
215
+ *
216
+ * Returns null only when there's NO usable token at all (no cached
217
+ * + no refresh available).
149
218
  */
150
219
  getAccessToken(): Promise<string | null>;
151
220
  /** Sync read — returns whatever's cached even if expired. Prefer the async variant. */
@@ -320,6 +389,17 @@ declare class Auth {
320
389
  * surface NOT_SIGNED_IN to the host app instead of looping.
321
390
  */
322
391
  private refreshAccessToken;
392
+ /**
393
+ * Wave 62 — AppState resume hook. Clears stuck backoff so a user
394
+ * who backgrounded the app during a network blip + returns now
395
+ * gets an immediate fresh refresh attempt instead of waiting on
396
+ * the cooldown timer.
397
+ *
398
+ * Called from the AppState 'active' listener registered in
399
+ * startProactiveRefreshCron. Idempotent + cheap — three field
400
+ * writes; no I/O.
401
+ */
402
+ private resetRefreshState;
323
403
  /**
324
404
  * Proactive refresh (s58.72). Fires when access-token age crosses
325
405
  * ~80% of TTL. Pairs with the AppState foreground listener so a
@@ -535,6 +615,7 @@ declare class Links {
535
615
  private deps;
536
616
  private listeners;
537
617
  private prewarmCache;
618
+ private prewarmInflight;
538
619
  private linkingSub;
539
620
  constructor(deps: LinksDeps);
540
621
  /** Register a callback for warm + deferred deep-link opens. Returns
@@ -550,6 +631,13 @@ declare class Links {
550
631
  *
551
632
  * Fire-and-forget. Errors are swallowed; the matching `create()` call
552
633
  * will surface them on demand instead.
634
+ *
635
+ * Wave 28 — silently drops the call when more than
636
+ * `PREWARM_MAX_INFLIGHT` mints are already in flight. Prewarm is
637
+ * fire-and-forget by contract; an overflow `prewarm()` is fine to
638
+ * skip because the next matching `create()` will simply do the mint
639
+ * inline. Caps unbounded fan-out from runaway loops (eg `prewarm()`
640
+ * in a FlatList renderItem).
553
641
  */
554
642
  prewarm<T extends LinkData = LinkData>(input: LinkCreateInput<T>, opts?: {
555
643
  key?: string;
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,21 @@ declare class Auth {
106
119
  private accessExpiresAt;
107
120
  private inflight;
108
121
  private refreshInflight;
122
+ /**
123
+ * Wave 62 — refresh failure back-pressure.
124
+ *
125
+ * Consecutive network-timeout failures don't wipe identity (refresh
126
+ * may still be valid; iOS NSURLSession was just stalled) but do
127
+ * back off so the next caller doesn't immediately fire another
128
+ * fetch that will also time out. After `REFRESH_FAILURE_COOLDOWN_MS`
129
+ * the counter resets + retries are allowed. AppState foreground
130
+ * also clears it so a user returning to the app gets an immediate
131
+ * fresh attempt.
132
+ */
133
+ private refreshFailureCount;
134
+ private refreshCooldownUntil;
135
+ private takeoverListeners;
136
+ private lastTakeover;
109
137
  /**
110
138
  * Resolves when the parent SDK has finished its async init() —
111
139
  * AsyncStorage hydrated + auth.hydrate() restored the cached
@@ -143,9 +171,50 @@ declare class Auth {
143
171
  hydrate(): void;
144
172
  getCurrentUser(): AuthUser | null;
145
173
  /**
146
- * Returns a non-expired access token or null. If the cached token
147
- * is expired but a refresh token exists, transparently refreshes
148
- * (single-flight concurrent callers share one network call).
174
+ * Subscribe to device-takeover events (s58.111+). Backend retires
175
+ * the anonymous user_id whenever a previously-anon device signs in
176
+ * to an identified account. Use this to clean up any local mirror
177
+ * keyed by the anonymous id — your own users table, an analytics
178
+ * cache, an audience filter — without needing a webhook receiver.
179
+ *
180
+ * Listener is invoked from `signIn`, `loginSocial`, `verifyMagicLink`,
181
+ * `verifyEmailOtp`, `passkey authentication`, and SSO completion
182
+ * (via `consumeSsoFragment`). Returns an unsubscribe function.
183
+ *
184
+ * Listeners are best-effort: errors thrown inside them are swallowed
185
+ * + logged in dev mode. Auth state never depends on listener success.
186
+ */
187
+ onDeviceTakeover(listener: (evt: DeviceTakeoverEvent) => void): () => void;
188
+ /**
189
+ * Inspect the most recent device-takeover event observed by the
190
+ * SDK. Cleared on signOut. Returns null when no takeover has
191
+ * happened during this session — useful for late subscribers or
192
+ * post-sign-in side-effects (e.g. app-launch hydrate).
193
+ */
194
+ getLastDeviceTakeover(): DeviceTakeoverEvent | null;
195
+ /** Internal — fan out to subscribers + cache the latest. */
196
+ private fireDeviceTakeover;
197
+ /**
198
+ * Returns an access token or null. Cold-launch hardening (Wave 62):
199
+ *
200
+ * 1. Non-expired cached → return it. (Fast path.)
201
+ * 2. Expired AND refresh inflight → return stale cached anyway.
202
+ * Backend grace window (60s — see auth-service refresh-rotation
203
+ * middleware) accepts slightly-stale access tokens, so the
204
+ * caller's downstream RPC will succeed against grace while
205
+ * the in-flight refresh resolves out of band. Distinct from
206
+ * pre-Wave-62 behaviour where 5 concurrent callers all shared
207
+ * the same in-flight promise — when that promise hung (iOS
208
+ * NSURLSession cold-launch contention) every caller hung too.
209
+ * 3. Expired + no inflight + refresh available + not in cooldown →
210
+ * fire refresh, await it.
211
+ * 4. Expired + in cooldown → return stale cached. Caller's RPC
212
+ * either succeeds against grace or fails fast on 401, which is
213
+ * a better UX than blocking the whole bridge waiting on a
214
+ * network that we already know is stalled.
215
+ *
216
+ * Returns null only when there's NO usable token at all (no cached
217
+ * + no refresh available).
149
218
  */
150
219
  getAccessToken(): Promise<string | null>;
151
220
  /** Sync read — returns whatever's cached even if expired. Prefer the async variant. */
@@ -320,6 +389,17 @@ declare class Auth {
320
389
  * surface NOT_SIGNED_IN to the host app instead of looping.
321
390
  */
322
391
  private refreshAccessToken;
392
+ /**
393
+ * Wave 62 — AppState resume hook. Clears stuck backoff so a user
394
+ * who backgrounded the app during a network blip + returns now
395
+ * gets an immediate fresh refresh attempt instead of waiting on
396
+ * the cooldown timer.
397
+ *
398
+ * Called from the AppState 'active' listener registered in
399
+ * startProactiveRefreshCron. Idempotent + cheap — three field
400
+ * writes; no I/O.
401
+ */
402
+ private resetRefreshState;
323
403
  /**
324
404
  * Proactive refresh (s58.72). Fires when access-token age crosses
325
405
  * ~80% of TTL. Pairs with the AppState foreground listener so a
@@ -535,6 +615,7 @@ declare class Links {
535
615
  private deps;
536
616
  private listeners;
537
617
  private prewarmCache;
618
+ private prewarmInflight;
538
619
  private linkingSub;
539
620
  constructor(deps: LinksDeps);
540
621
  /** Register a callback for warm + deferred deep-link opens. Returns
@@ -550,6 +631,13 @@ declare class Links {
550
631
  *
551
632
  * Fire-and-forget. Errors are swallowed; the matching `create()` call
552
633
  * will surface them on demand instead.
634
+ *
635
+ * Wave 28 — silently drops the call when more than
636
+ * `PREWARM_MAX_INFLIGHT` mints are already in flight. Prewarm is
637
+ * fire-and-forget by contract; an overflow `prewarm()` is fine to
638
+ * skip because the next matching `create()` will simply do the mint
639
+ * inline. Caps unbounded fan-out from runaway loops (eg `prewarm()`
640
+ * in a FlatList renderItem).
553
641
  */
554
642
  prewarm<T extends LinkData = LinkData>(input: LinkCreateInput<T>, opts?: {
555
643
  key?: string;
package/dist/index.js CHANGED
@@ -93,16 +93,36 @@ var Storage = class {
93
93
  };
94
94
 
95
95
  // src/http.ts
96
- async function post(opts, path, body, extraHeaders) {
97
- return request(opts, "POST", path, body, extraHeaders);
96
+ var NetworkTimeoutError = class extends Error {
97
+ constructor(method, path, timeoutMs) {
98
+ super(`Request timed out: ${method} ${path} (${timeoutMs}ms)`);
99
+ this.name = "NetworkTimeoutError";
100
+ this.method = method;
101
+ this.path = path;
102
+ this.timeoutMs = timeoutMs;
103
+ }
104
+ };
105
+ var DEFAULT_AUTH_TIMEOUT_MS = 1e4;
106
+ var DEFAULT_INGEST_TIMEOUT_MS = 3e4;
107
+ function defaultTimeoutFor(path) {
108
+ if (path.startsWith("/api/v1/auth-service/")) return DEFAULT_AUTH_TIMEOUT_MS;
109
+ return DEFAULT_INGEST_TIMEOUT_MS;
110
+ }
111
+ async function post(opts, path, body, extraHeaders, reqOpts) {
112
+ return request(opts, "POST", path, body, extraHeaders, reqOpts);
98
113
  }
99
- async function getJson(opts, path, extraHeaders) {
100
- return request(opts, "GET", path, void 0, extraHeaders);
114
+ async function getJson(opts, path, extraHeaders, reqOpts) {
115
+ return request(opts, "GET", path, void 0, extraHeaders, reqOpts);
101
116
  }
102
- async function del(opts, path, extraHeaders) {
103
- return request(opts, "DELETE", path, void 0, extraHeaders);
117
+ async function del(opts, path, extraHeaders, reqOpts) {
118
+ return request(opts, "DELETE", path, void 0, extraHeaders, reqOpts);
104
119
  }
105
- async function request(opts, method, path, body, extraHeaders) {
120
+ async function request(opts, method, path, body, extraHeaders, reqOpts) {
121
+ const timeoutMs = reqOpts?.timeoutMs ?? defaultTimeoutFor(path);
122
+ const controller = new AbortController();
123
+ const timer = setTimeout(() => {
124
+ controller.abort();
125
+ }, timeoutMs);
106
126
  let res;
107
127
  try {
108
128
  res = await fetch(`${opts.apiUrl}${path}`, {
@@ -117,15 +137,20 @@ async function request(opts, method, path, body, extraHeaders) {
117
137
  "x-api-key": opts.publicKey,
118
138
  ...extraHeaders ?? {}
119
139
  },
120
- body: body !== void 0 ? JSON.stringify(body) : void 0
140
+ body: body !== void 0 ? JSON.stringify(body) : void 0,
141
+ signal: controller.signal
121
142
  });
122
143
  } catch (err) {
144
+ clearTimeout(timer);
145
+ const aborted = err?.name === "AbortError";
146
+ if (aborted) throw new NetworkTimeoutError(method, path, timeoutMs);
123
147
  console.warn(
124
148
  `[sendoracloud] ${method} ${path} failed at the network layer:`,
125
149
  err instanceof Error ? err.message : String(err)
126
150
  );
127
151
  return null;
128
152
  }
153
+ clearTimeout(timer);
129
154
  if (!res.ok) {
130
155
  const isAuthPath = path.startsWith("/api/v1/auth-service/");
131
156
  if (isAuthPath && !opts.debug) {
@@ -194,6 +219,21 @@ var Auth = class {
194
219
  this.accessExpiresAt = 0;
195
220
  this.inflight = Promise.resolve();
196
221
  this.refreshInflight = null;
222
+ /**
223
+ * Wave 62 — refresh failure back-pressure.
224
+ *
225
+ * Consecutive network-timeout failures don't wipe identity (refresh
226
+ * may still be valid; iOS NSURLSession was just stalled) but do
227
+ * back off so the next caller doesn't immediately fire another
228
+ * fetch that will also time out. After `REFRESH_FAILURE_COOLDOWN_MS`
229
+ * the counter resets + retries are allowed. AppState foreground
230
+ * also clears it so a user returning to the app gets an immediate
231
+ * fresh attempt.
232
+ */
233
+ this.refreshFailureCount = 0;
234
+ this.refreshCooldownUntil = 0;
235
+ this.takeoverListeners = /* @__PURE__ */ new Set();
236
+ this.lastTakeover = null;
197
237
  /**
198
238
  * Proactive refresh (s58.72). Fires when access-token age crosses
199
239
  * ~80% of TTL. Pairs with the AppState foreground listener so a
@@ -257,15 +297,79 @@ var Auth = class {
257
297
  return this.user;
258
298
  }
259
299
  /**
260
- * Returns a non-expired access token or null. If the cached token
261
- * is expired but a refresh token exists, transparently refreshes
262
- * (single-flight concurrent callers share one network call).
300
+ * Subscribe to device-takeover events (s58.111+). Backend retires
301
+ * the anonymous user_id whenever a previously-anon device signs in
302
+ * to an identified account. Use this to clean up any local mirror
303
+ * keyed by the anonymous id — your own users table, an analytics
304
+ * cache, an audience filter — without needing a webhook receiver.
305
+ *
306
+ * Listener is invoked from `signIn`, `loginSocial`, `verifyMagicLink`,
307
+ * `verifyEmailOtp`, `passkey authentication`, and SSO completion
308
+ * (via `consumeSsoFragment`). Returns an unsubscribe function.
309
+ *
310
+ * Listeners are best-effort: errors thrown inside them are swallowed
311
+ * + logged in dev mode. Auth state never depends on listener success.
312
+ */
313
+ onDeviceTakeover(listener) {
314
+ this.takeoverListeners.add(listener);
315
+ return () => {
316
+ this.takeoverListeners.delete(listener);
317
+ };
318
+ }
319
+ /**
320
+ * Inspect the most recent device-takeover event observed by the
321
+ * SDK. Cleared on signOut. Returns null when no takeover has
322
+ * happened during this session — useful for late subscribers or
323
+ * post-sign-in side-effects (e.g. app-launch hydrate).
324
+ */
325
+ getLastDeviceTakeover() {
326
+ return this.lastTakeover;
327
+ }
328
+ /** Internal — fan out to subscribers + cache the latest. */
329
+ fireDeviceTakeover(retiredAnonUserId, identifiedUserId) {
330
+ const evt = { retiredAnonUserId, identifiedUserId, at: /* @__PURE__ */ new Date() };
331
+ this.lastTakeover = evt;
332
+ for (const fn of this.takeoverListeners) {
333
+ try {
334
+ fn(evt);
335
+ } catch (err) {
336
+ if (this.hooks.debug) console.warn("[sendoracloud] onDeviceTakeover listener threw", err);
337
+ }
338
+ }
339
+ }
340
+ /**
341
+ * Returns an access token or null. Cold-launch hardening (Wave 62):
342
+ *
343
+ * 1. Non-expired cached → return it. (Fast path.)
344
+ * 2. Expired AND refresh inflight → return stale cached anyway.
345
+ * Backend grace window (60s — see auth-service refresh-rotation
346
+ * middleware) accepts slightly-stale access tokens, so the
347
+ * caller's downstream RPC will succeed against grace while
348
+ * the in-flight refresh resolves out of band. Distinct from
349
+ * pre-Wave-62 behaviour where 5 concurrent callers all shared
350
+ * the same in-flight promise — when that promise hung (iOS
351
+ * NSURLSession cold-launch contention) every caller hung too.
352
+ * 3. Expired + no inflight + refresh available + not in cooldown →
353
+ * fire refresh, await it.
354
+ * 4. Expired + in cooldown → return stale cached. Caller's RPC
355
+ * either succeeds against grace or fails fast on 401, which is
356
+ * a better UX than blocking the whole bridge waiting on a
357
+ * network that we already know is stalled.
358
+ *
359
+ * Returns null only when there's NO usable token at all (no cached
360
+ * + no refresh available).
263
361
  */
264
362
  async getAccessToken() {
265
363
  if (!this.accessToken) return null;
266
364
  if (this.accessExpiresAt && Date.now() < this.accessExpiresAt - REFRESH_SAFETY_MS) {
267
365
  return this.accessToken;
268
366
  }
367
+ if (this.refreshInflight) {
368
+ return this.accessToken;
369
+ }
370
+ if (Date.now() < this.refreshCooldownUntil) {
371
+ return this.accessToken;
372
+ }
269
373
  return this.refreshAccessToken();
270
374
  }
271
375
  /** Sync read — returns whatever's cached even if expired. Prefer the async variant. */
@@ -698,6 +802,7 @@ var Auth = class {
698
802
  if (err) throw new AuthError("SSO_FAILED", decodeURIComponent(err));
699
803
  const refresh = params.get("sendora_oidc_token") ?? params.get("sendora_saml_token");
700
804
  if (!refresh) throw new AuthError("SSO_NO_TOKEN", "Callback URL has no SSO token");
805
+ const retiredAnonUserId = params.get("sendora_retired_anon");
701
806
  const res = await post(
702
807
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
703
808
  "/api/v1/auth-service/token/refresh",
@@ -726,7 +831,8 @@ var Auth = class {
726
831
  refreshToken: newRefresh,
727
832
  expiresIn,
728
833
  tokenType: "Bearer"
729
- }
834
+ },
835
+ ...retiredAnonUserId ? { retiredAnonUserId } : {}
730
836
  });
731
837
  return user;
732
838
  });
@@ -802,6 +908,8 @@ var Auth = class {
802
908
  if (Number.isFinite(freshExpires) && freshExpires > Date.now()) {
803
909
  this.accessToken = freshAccess;
804
910
  this.accessExpiresAt = freshExpires;
911
+ this.refreshFailureCount = 0;
912
+ this.refreshCooldownUntil = 0;
805
913
  return freshAccess;
806
914
  }
807
915
  }
@@ -814,11 +922,19 @@ var Auth = class {
814
922
  this.hooks.storage.set(TOKEN_KEY, data.tokens.accessToken);
815
923
  this.hooks.storage.set(TOKEN_EXPIRES_KEY, String(this.accessExpiresAt));
816
924
  this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
925
+ this.refreshFailureCount = 0;
926
+ this.refreshCooldownUntil = 0;
817
927
  return data.tokens.accessToken;
818
928
  } catch (err) {
819
929
  if (err instanceof AuthError && isDeadRefreshError(err.code)) {
820
930
  await this.wipeLocalIdentity().catch(() => void 0);
931
+ this.refreshFailureCount = 0;
932
+ this.refreshCooldownUntil = 0;
933
+ return null;
821
934
  }
935
+ this.refreshFailureCount += 1;
936
+ const backoffMs = Math.min(3e4, 1e3 * Math.pow(2, this.refreshFailureCount - 1));
937
+ this.refreshCooldownUntil = Date.now() + backoffMs;
822
938
  return null;
823
939
  } finally {
824
940
  this.refreshInflight = null;
@@ -826,6 +942,20 @@ var Auth = class {
826
942
  })();
827
943
  return this.refreshInflight;
828
944
  }
945
+ /**
946
+ * Wave 62 — AppState resume hook. Clears stuck backoff so a user
947
+ * who backgrounded the app during a network blip + returns now
948
+ * gets an immediate fresh refresh attempt instead of waiting on
949
+ * the cooldown timer.
950
+ *
951
+ * Called from the AppState 'active' listener registered in
952
+ * startProactiveRefreshCron. Idempotent + cheap — three field
953
+ * writes; no I/O.
954
+ */
955
+ resetRefreshState() {
956
+ this.refreshFailureCount = 0;
957
+ this.refreshCooldownUntil = 0;
958
+ }
829
959
  startProactiveRefreshCron() {
830
960
  if (this.proactiveRefreshTimer) return;
831
961
  const PROACTIVE_TARGET_PCT = 0.8;
@@ -847,7 +977,10 @@ var Auth = class {
847
977
  }, TICK_MS);
848
978
  try {
849
979
  const subscription = RnAppState?.addEventListener?.("change", (state) => {
850
- if (state === "active") void tick();
980
+ if (state === "active") {
981
+ this.resetRefreshState();
982
+ void tick();
983
+ }
851
984
  });
852
985
  if (subscription) {
853
986
  this.proactiveRefreshAppStateUnsub = () => subscription.remove();
@@ -967,11 +1100,15 @@ var Auth = class {
967
1100
  this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
968
1101
  this.hooks.onIdentityChange(data.user.id);
969
1102
  this.startProactiveRefreshCron();
1103
+ if (data.retiredAnonUserId) {
1104
+ this.fireDeviceTakeover(data.retiredAnonUserId, data.user.id);
1105
+ }
970
1106
  }
971
1107
  async wipeLocalIdentity() {
972
1108
  this.user = null;
973
1109
  this.accessToken = null;
974
1110
  this.accessExpiresAt = 0;
1111
+ this.lastTakeover = null;
975
1112
  this.hooks.storage.remove(USER_KEY);
976
1113
  this.hooks.storage.remove(TOKEN_KEY);
977
1114
  this.hooks.storage.remove(TOKEN_EXPIRES_KEY);
@@ -1283,11 +1420,13 @@ async function getPlayInstallReferrer() {
1283
1420
  }
1284
1421
  var PREWARM_TTL_MS = 5 * 6e4;
1285
1422
  var PREWARM_MAX = 50;
1423
+ var PREWARM_MAX_INFLIGHT = 5;
1286
1424
  var Links = class {
1287
1425
  constructor(deps) {
1288
1426
  this.deps = deps;
1289
1427
  this.listeners = [];
1290
1428
  this.prewarmCache = /* @__PURE__ */ new Map();
1429
+ this.prewarmInflight = 0;
1291
1430
  this.linkingSub = null;
1292
1431
  }
1293
1432
  /** Register a callback for warm + deferred deep-link opens. Returns
@@ -1335,14 +1474,25 @@ var Links = class {
1335
1474
  *
1336
1475
  * Fire-and-forget. Errors are swallowed; the matching `create()` call
1337
1476
  * will surface them on demand instead.
1477
+ *
1478
+ * Wave 28 — silently drops the call when more than
1479
+ * `PREWARM_MAX_INFLIGHT` mints are already in flight. Prewarm is
1480
+ * fire-and-forget by contract; an overflow `prewarm()` is fine to
1481
+ * skip because the next matching `create()` will simply do the mint
1482
+ * inline. Caps unbounded fan-out from runaway loops (eg `prewarm()`
1483
+ * in a FlatList renderItem).
1338
1484
  */
1339
1485
  prewarm(input, opts) {
1340
1486
  this.evictExpired();
1341
1487
  const key = this.cacheKey(input, opts?.key);
1342
1488
  if (this.prewarmCache.has(key)) return;
1489
+ if (this.prewarmInflight >= PREWARM_MAX_INFLIGHT) return;
1490
+ this.prewarmInflight++;
1343
1491
  const promise = this.doCreate(input).catch((err) => {
1344
1492
  this.prewarmCache.delete(key);
1345
1493
  throw err;
1494
+ }).finally(() => {
1495
+ this.prewarmInflight--;
1346
1496
  });
1347
1497
  this.prewarmCache.set(key, { promise, ts: Date.now() });
1348
1498
  }
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.1.0",
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",