@sendoracloud/sdk-react-native 1.27.0 → 1.29.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.js CHANGED
@@ -157,7 +157,7 @@ var Storage = class {
157
157
  // package.json
158
158
  var package_default = {
159
159
  name: "@sendoracloud/sdk-react-native",
160
- version: "1.27.0",
160
+ version: "1.29.0",
161
161
  description: "Sendora Cloud React Native + Expo SDK \u2014 analytics, identity, push-token registration, auth (incl. Game Center / Play Games), deep links (Branch / Firebase Dynamic Links parity). Auth + analytics + deep links work in Expo Go; push-token registration and the no-arg Game Center / Play Games sign-in require a Dev Client (EAS Build or `npx expo prebuild`).",
162
162
  type: "module",
163
163
  main: "./dist/index.cjs",
@@ -213,7 +213,7 @@ var package_default = {
213
213
  scripts: {
214
214
  build: "tsup src/index.ts src/contact-widget.tsx --format esm,cjs --dts --clean --external react --external react-native --external react-native-webview",
215
215
  typecheck: "tsc --noEmit",
216
- test: "tsx test/secure-storage.test.ts && tsx test/integration-storage.test.ts && tsx test/plugin.test.ts && tsx test/game-auth.test.ts && tsx test/link.test.ts"
216
+ test: "tsx test/secure-storage.test.ts && tsx test/integration-storage.test.ts && tsx test/plugin.test.ts && tsx test/game-auth.test.ts && tsx test/link.test.ts && tsx test/session-preservation.test.ts"
217
217
  },
218
218
  devDependencies: {
219
219
  "@types/react": "^19.2.16",
@@ -395,13 +395,88 @@ var CredentialInUseError = class extends Error {
395
395
  this.name = "CredentialInUseError";
396
396
  }
397
397
  };
398
+ var RETRYABLE_KINDS = /* @__PURE__ */ new Set([
399
+ "network",
400
+ "server",
401
+ "rate_limited",
402
+ "account_locked"
403
+ ]);
404
+ function classifyAuthErrorKind(code, status) {
405
+ switch (code) {
406
+ case "NETWORK_ERROR":
407
+ case "NETWORK_TIMEOUT":
408
+ return "network";
409
+ case "PARSE_ERROR":
410
+ return "server";
411
+ case "RATE_LIMITED":
412
+ case "RATE_LIMIT":
413
+ case "RATE_LIMIT_EXCEEDED":
414
+ case "QUOTA_EXCEEDED":
415
+ return "rate_limited";
416
+ case "ACCOUNT_LOCKED":
417
+ return "account_locked";
418
+ case "CREDENTIAL_IN_USE":
419
+ return "credential_in_use";
420
+ case "NOT_ANONYMOUS":
421
+ case "FORBIDDEN_NON_ANONYMOUS":
422
+ return "already_identified";
423
+ case "PASSKEY_USER_CANCELLED":
424
+ case "GAME_CENTER_CANCELLED":
425
+ case "PLAY_GAMES_CANCELLED":
426
+ case "SSO_CANCELLED":
427
+ return "cancelled";
428
+ case "GAME_CENTER_UNAVAILABLE":
429
+ case "PLAY_GAMES_UNAVAILABLE":
430
+ case "ENTITLEMENT_ERROR":
431
+ case "tier_required":
432
+ return "config";
433
+ case "UNAUTHORIZED":
434
+ case "INVALID_CREDENTIALS":
435
+ case "INVALID_REFRESH_TOKEN":
436
+ case "EMAIL_OTP_INVALID":
437
+ case "MAGIC_LINK_INVALID":
438
+ case "PASSKEY_RP_MISMATCH":
439
+ case "NOT_SIGNED_IN":
440
+ return "invalid_credential";
441
+ default:
442
+ break;
443
+ }
444
+ if (typeof status === "number") {
445
+ if (status === 429) return "rate_limited";
446
+ if (status >= 500) return "server";
447
+ if (status >= 400) return "invalid_credential";
448
+ }
449
+ return "unknown";
450
+ }
398
451
  var AuthError = class extends Error {
399
- constructor(code, message) {
452
+ constructor(code, message, opts) {
400
453
  super(message);
401
454
  this.name = "AuthError";
402
455
  this.code = code;
456
+ this.kind = opts?.kind ?? classifyAuthErrorKind(code, opts?.status);
457
+ this.retryable = RETRYABLE_KINDS.has(this.kind);
458
+ this.retryAfterSeconds = opts?.retryAfterSeconds;
459
+ this.status = opts?.status;
403
460
  }
404
461
  };
462
+ function asAuthError(err) {
463
+ if (err instanceof AuthError) return err;
464
+ if (err instanceof EmailAlreadyTakenError || err instanceof AlreadyIdentifiedError || err instanceof CredentialInUseError) {
465
+ return err;
466
+ }
467
+ const name = err?.name;
468
+ if (name === "NetworkTimeoutError") {
469
+ return new AuthError("NETWORK_TIMEOUT", err.message, { kind: "network" });
470
+ }
471
+ return new AuthError("UNKNOWN_ERROR", err instanceof Error ? err.message : String(err), {
472
+ kind: "unknown"
473
+ });
474
+ }
475
+ function readRetryAfterSeconds(parsed) {
476
+ const details = parsed?.error?.details;
477
+ const v = details?.retryAfterSeconds;
478
+ return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : void 0;
479
+ }
405
480
  var GAME_AUTH_NATIVE_MODULE = "SendoraGameAuth";
406
481
  async function fetchGameCenterPayloadNative() {
407
482
  const mod = requireOptionalNativeModule(GAME_AUTH_NATIVE_MODULE);
@@ -466,6 +541,7 @@ var Auth = class {
466
541
  this.refreshCooldownUntil = 0;
467
542
  this.takeoverListeners = /* @__PURE__ */ new Set();
468
543
  this.deletionCancelledListeners = /* @__PURE__ */ new Set();
544
+ this.authStateListeners = /* @__PURE__ */ new Set();
469
545
  this.lastDeletionCancelled = null;
470
546
  this.lastTakeover = null;
471
547
  /**
@@ -476,6 +552,11 @@ var Auth = class {
476
552
  * device ended with two user_ids + duplicate pushes (audit s58.203 fix).
477
553
  */
478
554
  this.pendingAnonTakeover = null;
555
+ /**
556
+ * True once `hydrate()` has run. Distinguishes "no session restored YET"
557
+ * from "genuinely signed out" for `onAuthStateChanged`'s replay-on-subscribe.
558
+ */
559
+ this.hydrated = false;
479
560
  /**
480
561
  * Proactive refresh (s58.72). Fires when access-token age crosses
481
562
  * ~80% of TTL. Pairs with the AppState foreground listener so a
@@ -530,9 +611,9 @@ var Auth = class {
530
611
  this.hooks.storage.remove(USER_KEY);
531
612
  this.hooks.storage.remove(TOKEN_KEY);
532
613
  this.hooks.storage.remove(TOKEN_EXPIRES_KEY);
533
- this.hooks.storage.remove(REFRESH_KEY);
534
614
  }
535
615
  }
616
+ this.hydrated = true;
536
617
  this.resolveReady();
537
618
  }
538
619
  getCurrentUser() {
@@ -558,6 +639,52 @@ var Auth = class {
558
639
  this.takeoverListeners.delete(listener);
559
640
  };
560
641
  }
642
+ /**
643
+ * Subscribe to every auth-state transition (1.28.0) — the single stream that
644
+ * answers "what happened to my session?". Firebase `onAuthStateChanged` /
645
+ * Supabase `onAuthStateChange` parity.
646
+ *
647
+ * The reason it exists: a session that dies in the BACKGROUND (the server
648
+ * rejected the stored refresh token) used to emit no signal whatsoever, so
649
+ * an app could not distinguish it from a deliberate sign-out and only
650
+ * noticed when `getAccessToken()` began returning null. That transition is
651
+ * now `{ event: "signed_out", reason: "session_expired" }`.
652
+ *
653
+ * Fires the CURRENT state immediately on subscribe once `init()` has
654
+ * hydrated (a `signed_in` when a session was restored from disk — including
655
+ * offline, since hydrate is disk-only), so a subscriber never misses the
656
+ * state it started in. Nothing is emitted for a signed-out cold start.
657
+ *
658
+ * A failed sign-in emits NOTHING: the session is unchanged, so there is no
659
+ * transition to report — the rejected promise is the whole story.
660
+ *
661
+ * Listeners are best-effort; a throwing listener is swallowed (dev-logged)
662
+ * and never affects auth state. Returns an unsubscribe function.
663
+ */
664
+ onAuthStateChanged(listener) {
665
+ this.authStateListeners.add(listener);
666
+ if (this.hydrated && this.user) {
667
+ const user = this.user;
668
+ try {
669
+ listener({ event: "signed_in", user });
670
+ } catch (err) {
671
+ if (this.hooks.debug) console.warn("[Sendora] onAuthStateChanged listener threw", err);
672
+ }
673
+ }
674
+ return () => {
675
+ this.authStateListeners.delete(listener);
676
+ };
677
+ }
678
+ /** Fan a state change out to subscribers. Never throws. */
679
+ emitAuthState(change) {
680
+ for (const listener of this.authStateListeners) {
681
+ try {
682
+ listener(change);
683
+ } catch (err) {
684
+ if (this.hooks.debug) console.warn("[Sendora] onAuthStateChanged listener threw", err);
685
+ }
686
+ }
687
+ }
561
688
  /**
562
689
  * Inspect the most recent device-takeover event observed by the
563
690
  * SDK. Cleared on signOut. Returns null when no takeover has
@@ -654,8 +781,27 @@ var Auth = class {
654
781
  getAccessTokenSync() {
655
782
  return this.accessToken;
656
783
  }
784
+ /**
785
+ * Mint (or return) an anonymous session.
786
+ *
787
+ * Reuses the existing anonymous user when there already is one — the same
788
+ * short-circuit Firebase's `signInAnonymously` performs. Without it, an app
789
+ * that calls this defensively on every cold launch (the most natural thing
790
+ * to write) mints a fresh `user_id` each time, and `persist()` overwrites
791
+ * the stored refresh token — the previous anonymous account's only durable
792
+ * handle — with no takeover, no webhook and no state event. That is the same
793
+ * silent progress loss as a failed sign-in wiping the session, except it
794
+ * happens on a *healthy* network.
795
+ *
796
+ * Pass `forceNew: true` to deliberately abandon the current anonymous
797
+ * session and mint a separate one.
798
+ */
657
799
  signInAnonymously(opts) {
658
800
  return this.serialize(async () => {
801
+ const existing = this.user;
802
+ if (!opts?.forceNew && existing?.isAnonymous && this.hooks.storage.get(REFRESH_KEY)) {
803
+ return existing;
804
+ }
659
805
  const data = await this.callAuth("/api/v1/auth-service/anonymous", {
660
806
  name: opts?.name,
661
807
  metadata: opts?.metadata
@@ -704,7 +850,7 @@ var Auth = class {
704
850
  throw new EmailAlreadyTakenError(err.message);
705
851
  }
706
852
  if (err instanceof AuthError && (err.code === "INVALID_REFRESH" || err.code === "UNAUTHORIZED" || err.code === "REFRESH_EXPIRED")) {
707
- await this.wipeLocalIdentity();
853
+ await this.wipeLocalIdentity("session_expired");
708
854
  } else {
709
855
  throw err;
710
856
  }
@@ -745,10 +891,14 @@ var Auth = class {
745
891
  try {
746
892
  parsed = await res.json();
747
893
  } catch {
748
- throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
894
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
749
895
  }
750
896
  if (!res.ok || !parsed.success || !parsed.data) {
751
- throw new AuthError(parsed.error?.code ?? `HTTP_${res.status}`, parsed.error?.message ?? `HTTP ${res.status}`);
897
+ throw new AuthError(
898
+ parsed.error?.code ?? `HTTP_${res.status}`,
899
+ parsed.error?.message ?? `HTTP ${res.status}`,
900
+ { status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
901
+ );
752
902
  }
753
903
  const r = parsed.data;
754
904
  if (r.mfaRequired === true && typeof r.mfaChallengeToken === "string") {
@@ -775,12 +925,7 @@ var Auth = class {
775
925
  */
776
926
  loginSocial(input) {
777
927
  return this.serialize(async () => {
778
- let prevAnonRefreshToken;
779
- if (this.user?.isAnonymous) {
780
- const stashed = this.hooks.storage.get(REFRESH_KEY);
781
- if (stashed) prevAnonRefreshToken = stashed;
782
- }
783
- if (this.user !== null) await this.wipeLocalIdentity();
928
+ const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
784
929
  const { link, ...socialInput } = input;
785
930
  const payload = { ...socialInput };
786
931
  if (prevAnonRefreshToken) payload.prevAnonRefreshToken = prevAnonRefreshToken;
@@ -795,14 +940,16 @@ var Auth = class {
795
940
  try {
796
941
  parsed = await res.json();
797
942
  } catch {
798
- throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
943
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
799
944
  }
800
945
  if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
801
946
  throw new AuthError(
802
947
  parsed.error?.code ?? "SOCIAL_LOGIN_FAILED",
803
- parsed.error?.message ?? "Social login failed"
948
+ parsed.error?.message ?? "Social login failed",
949
+ { status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
804
950
  );
805
951
  }
952
+ if (this.user !== null) await this.wipeLocalIdentity("replaced");
806
953
  this.persist(parsed.data);
807
954
  return parsed.data.user;
808
955
  });
@@ -921,12 +1068,15 @@ var Auth = class {
921
1068
  try {
922
1069
  parsed = await res.json();
923
1070
  } catch {
924
- throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
1071
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
925
1072
  }
926
1073
  if (!res.ok || !parsed.success || !parsed.data) {
927
1074
  const code = parsed.error?.code ?? `HTTP_${res.status}`;
928
1075
  if (code === "CREDENTIAL_IN_USE") throw new CredentialInUseError(parsed.error?.message);
929
- throw new AuthError(code, parsed.error?.message ?? "Failed to link credential");
1076
+ throw new AuthError(code, parsed.error?.message ?? "Failed to link credential", {
1077
+ status: res.status,
1078
+ retryAfterSeconds: readRetryAfterSeconds(parsed)
1079
+ });
930
1080
  }
931
1081
  if (parsed.data.user) this.updateLocalUser(parsed.data.user);
932
1082
  return parsed.data.user ?? this.user;
@@ -979,12 +1129,7 @@ var Auth = class {
979
1129
  gameIdentitySignIn(path, identityBody, link) {
980
1130
  return this.serialize(async () => {
981
1131
  const body = typeof identityBody === "function" ? await identityBody() : identityBody;
982
- let prevAnonRefreshToken;
983
- if (this.user?.isAnonymous) {
984
- const stashed = this.hooks.storage.get(REFRESH_KEY);
985
- if (stashed) prevAnonRefreshToken = stashed;
986
- }
987
- if (this.user !== null) await this.wipeLocalIdentity();
1132
+ const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
988
1133
  const payload = { ...body };
989
1134
  if (prevAnonRefreshToken) payload.prevAnonRefreshToken = prevAnonRefreshToken;
990
1135
  if (link) payload.linkAnonymous = true;
@@ -998,14 +1143,16 @@ var Auth = class {
998
1143
  try {
999
1144
  parsed = await res.json();
1000
1145
  } catch {
1001
- throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
1146
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
1002
1147
  }
1003
1148
  if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
1004
1149
  throw new AuthError(
1005
1150
  parsed.error?.code ?? "GAME_LOGIN_FAILED",
1006
- parsed.error?.message ?? "Game sign-in failed"
1151
+ parsed.error?.message ?? "Game sign-in failed",
1152
+ { status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
1007
1153
  );
1008
1154
  }
1155
+ if (this.user !== null) await this.wipeLocalIdentity("replaced");
1009
1156
  this.persist(parsed.data);
1010
1157
  return parsed.data.user;
1011
1158
  });
@@ -1056,10 +1203,10 @@ var Auth = class {
1056
1203
  verifyMagicLink(token) {
1057
1204
  return this.serialize(async () => {
1058
1205
  const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
1059
- if (this.user !== null) await this.wipeLocalIdentity();
1060
1206
  const body = { token };
1061
1207
  if (prevAnonRefreshToken) body.prevAnonRefreshToken = prevAnonRefreshToken;
1062
1208
  const data = await this.callAuth("/api/v1/auth-service/magic-link/verify", body);
1209
+ if (this.user !== null) await this.wipeLocalIdentity("replaced");
1063
1210
  this.persist(data);
1064
1211
  return data.user;
1065
1212
  });
@@ -1080,10 +1227,10 @@ var Auth = class {
1080
1227
  verifyEmailOtp(email, code) {
1081
1228
  return this.serialize(async () => {
1082
1229
  const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
1083
- if (this.user !== null) await this.wipeLocalIdentity();
1084
1230
  const body = { email, code };
1085
1231
  if (prevAnonRefreshToken) body.prevAnonRefreshToken = prevAnonRefreshToken;
1086
1232
  const data = await this.callAuth("/api/v1/auth-service/email-otp/verify", body);
1233
+ if (this.user !== null) await this.wipeLocalIdentity("replaced");
1087
1234
  this.persist(data);
1088
1235
  return data.user;
1089
1236
  });
@@ -1239,7 +1386,7 @@ var Auth = class {
1239
1386
  }
1240
1387
  } catch {
1241
1388
  }
1242
- await this.wipeLocalIdentity();
1389
+ await this.wipeLocalIdentity("account_deleted");
1243
1390
  return out;
1244
1391
  }
1245
1392
  // ============================================================
@@ -1307,7 +1454,6 @@ var Auth = class {
1307
1454
  */
1308
1455
  consumeSsoFragment(callbackUrl) {
1309
1456
  return this.serialize(async () => {
1310
- if (this.user !== null) await this.wipeLocalIdentity();
1311
1457
  const hashIdx = callbackUrl.indexOf("#");
1312
1458
  const fragment = hashIdx >= 0 ? callbackUrl.slice(hashIdx + 1) : "";
1313
1459
  const params = new URLSearchParams(fragment);
@@ -1321,14 +1467,13 @@ var Auth = class {
1321
1467
  "/api/v1/auth-service/token/refresh",
1322
1468
  { refreshToken: refresh }
1323
1469
  );
1324
- if (!res || !res.ok) throw new AuthError("SSO_EXCHANGE_FAILED", `HTTP ${res?.status ?? "network"}`);
1470
+ if (!res || !res.ok) throw new AuthError("SSO_EXCHANGE_FAILED", `HTTP ${res?.status ?? "network"}`, { status: res?.status });
1325
1471
  const parsed = await res.json();
1326
- const access = parsed.data?.accessToken;
1327
- const newRefresh = parsed.data?.refreshToken;
1328
- const expiresIn = parsed.data?.expiresIn;
1329
- if (!parsed.success || !access || !newRefresh || !expiresIn || expiresIn <= 0) {
1330
- throw new AuthError("SSO_EXCHANGE_FAILED", "Could not exchange OIDC token");
1472
+ const rotated = parsed.success ? readRotatedTokens(parsed.data) : null;
1473
+ if (!rotated) {
1474
+ throw new AuthError("SSO_EXCHANGE_FAILED", "Could not exchange OIDC token", { status: res.status });
1331
1475
  }
1476
+ const { accessToken: access, refreshToken: newRefresh, expiresIn } = rotated;
1332
1477
  const claims = decodeJwtPayload(access);
1333
1478
  const user = {
1334
1479
  id: claims?.sub ?? "",
@@ -1337,6 +1482,7 @@ var Auth = class {
1337
1482
  name: claims?.name ?? null,
1338
1483
  isAnonymous: false
1339
1484
  };
1485
+ if (this.user !== null) await this.wipeLocalIdentity("replaced");
1340
1486
  this.persist({
1341
1487
  user,
1342
1488
  tokens: {
@@ -1372,7 +1518,7 @@ var Auth = class {
1372
1518
  signOut() {
1373
1519
  return this.serialize(async () => {
1374
1520
  const refresh = this.hooks.storage.get(REFRESH_KEY);
1375
- await this.wipeLocalIdentity();
1521
+ await this.wipeLocalIdentity("user");
1376
1522
  if (refresh) {
1377
1523
  void post(
1378
1524
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
@@ -1386,7 +1532,11 @@ var Auth = class {
1386
1532
  serialize(op) {
1387
1533
  const guarded = async () => {
1388
1534
  await this.readyPromise;
1389
- return op();
1535
+ try {
1536
+ return await op();
1537
+ } catch (err) {
1538
+ throw asAuthError(err);
1539
+ }
1390
1540
  };
1391
1541
  const next = this.inflight.then(guarded, guarded);
1392
1542
  this.inflight = next.catch(() => void 0);
@@ -1411,6 +1561,7 @@ var Auth = class {
1411
1561
  const refreshSnapshot = this.hooks.storage.get(REFRESH_KEY);
1412
1562
  if (!refreshSnapshot) return null;
1413
1563
  this.refreshInflight = (async () => {
1564
+ let sent = refreshSnapshot;
1414
1565
  try {
1415
1566
  const current = this.hooks.storage.get(REFRESH_KEY);
1416
1567
  if (current && current !== refreshSnapshot) {
@@ -1427,20 +1578,28 @@ var Auth = class {
1427
1578
  }
1428
1579
  }
1429
1580
  }
1581
+ sent = current ?? refreshSnapshot;
1430
1582
  const data = await this.callAuth("/api/v1/auth-service/token/refresh", {
1431
- refreshToken: current ?? refreshSnapshot
1583
+ refreshToken: sent
1432
1584
  });
1585
+ if (!this.tokenStillCurrent(sent)) return null;
1433
1586
  this.accessToken = data.tokens.accessToken;
1434
1587
  this.accessExpiresAt = Date.now() + data.tokens.expiresIn * 1e3;
1435
1588
  this.hooks.storage.set(TOKEN_KEY, data.tokens.accessToken);
1436
1589
  this.hooks.storage.set(TOKEN_EXPIRES_KEY, String(this.accessExpiresAt));
1437
1590
  this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
1591
+ if (this.user === null && isAuthApiResponse(data)) {
1592
+ this.user = data.user;
1593
+ this.hooks.storage.set(USER_KEY, JSON.stringify(data.user));
1594
+ this.hooks.onIdentityChange(data.user.id);
1595
+ this.emitAuthState({ event: "signed_in", user: data.user });
1596
+ }
1438
1597
  this.refreshFailureCount = 0;
1439
1598
  this.refreshCooldownUntil = 0;
1440
1599
  return data.tokens.accessToken;
1441
1600
  } catch (err) {
1442
- if (err instanceof AuthError && isDeadRefreshError(err.code)) {
1443
- await this.wipeLocalIdentity().catch(() => void 0);
1601
+ if (err instanceof AuthError && isDeadRefreshError(err.code) && this.tokenStillCurrent(sent)) {
1602
+ await this.wipeLocalIdentity("session_expired").catch(() => void 0);
1444
1603
  this.refreshFailureCount = 0;
1445
1604
  this.refreshCooldownUntil = 0;
1446
1605
  return null;
@@ -1554,12 +1713,13 @@ var Auth = class {
1554
1713
  try {
1555
1714
  parsed = await res.json();
1556
1715
  } catch {
1557
- throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
1716
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
1558
1717
  }
1559
1718
  if (!res.ok || !parsed.success || !parsed.data) {
1560
1719
  throw new AuthError(
1561
1720
  parsed.error?.code ?? `HTTP_${res.status}`,
1562
- parsed.error?.message ?? `HTTP ${res.status}`
1721
+ parsed.error?.message ?? `HTTP ${res.status}`,
1722
+ { status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
1563
1723
  );
1564
1724
  }
1565
1725
  const t = parsed.data.tokens;
@@ -1593,12 +1753,13 @@ var Auth = class {
1593
1753
  try {
1594
1754
  parsed = await res.json();
1595
1755
  } catch {
1596
- throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
1756
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
1597
1757
  }
1598
1758
  if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
1599
1759
  throw new AuthError(
1600
1760
  parsed.error?.code ?? `HTTP_${res.status}`,
1601
- parsed.error?.message ?? `HTTP ${res.status}`
1761
+ parsed.error?.message ?? `HTTP ${res.status}`,
1762
+ { status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
1602
1763
  );
1603
1764
  }
1604
1765
  return parsed.data;
@@ -1613,14 +1774,46 @@ var Auth = class {
1613
1774
  this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
1614
1775
  this.hooks.onIdentityChange(data.user.id);
1615
1776
  this.startProactiveRefreshCron();
1777
+ this.emitAuthState({ event: "signed_in", user: data.user });
1616
1778
  if (data.retiredAnonUserId) {
1617
1779
  this.fireDeviceTakeover(data.retiredAnonUserId, data.user.id);
1780
+ this.emitAuthState({
1781
+ event: "device_takeover",
1782
+ user: data.user,
1783
+ retiredAnonUserId: data.retiredAnonUserId
1784
+ });
1618
1785
  }
1619
1786
  if (data.reactivatedFromDeletion) {
1620
1787
  this.fireDeletionCancelled(data.user.id);
1788
+ this.emitAuthState({ event: "deletion_cancelled", user: data.user });
1621
1789
  }
1622
1790
  }
1623
- async wipeLocalIdentity() {
1791
+ /**
1792
+ * Drop the local session.
1793
+ *
1794
+ * `reason` drives the `onAuthStateChanged` emission: a real sign-out reason
1795
+ * emits `signed_out`, while `"replaced"` (the internal pre-`persist()` clear
1796
+ * on a SUCCESSFUL sign-in) emits nothing — subscribers see one `signed_in`,
1797
+ * not a spurious logout/login pair.
1798
+ *
1799
+ * ⚠ Never call this before a fallible network request. A failed sign-in must
1800
+ * leave the caller exactly as it found them; for an anonymous session the
1801
+ * refresh token dropped here is the ONLY durable handle on the account, so a
1802
+ * pre-call wipe that is not followed by a successful `persist()` orphans the
1803
+ * account permanently (offline: deterministically). Capture the anon refresh
1804
+ * into a local, make the call, and wipe only after the response validates.
1805
+ */
1806
+ /**
1807
+ * True when `sent` is still the refresh token on disk — i.e. nothing
1808
+ * replaced or cleared the session while a request carrying it was in
1809
+ * flight. Guards both directions of the refresh race: installing a rotated
1810
+ * token over a session that has since been signed out or replaced, and
1811
+ * wiping a fresh session because an OLD token was rejected.
1812
+ */
1813
+ tokenStillCurrent(sent) {
1814
+ return this.hooks.storage.get(REFRESH_KEY) === sent;
1815
+ }
1816
+ async wipeLocalIdentity(reason = "replaced") {
1624
1817
  this.user = null;
1625
1818
  this.accessToken = null;
1626
1819
  this.accessExpiresAt = 0;
@@ -1633,11 +1826,23 @@ var Auth = class {
1633
1826
  this.hooks.storage.remove(REFRESH_KEY);
1634
1827
  this.stopProactiveRefreshCron();
1635
1828
  await this.hooks.onAnonymousWipe();
1829
+ if (reason !== "replaced") {
1830
+ this.emitAuthState({ event: "signed_out", user: null, reason });
1831
+ }
1636
1832
  }
1637
1833
  };
1638
1834
  function isDeadRefreshError(code) {
1639
1835
  if (!code) return false;
1640
- return code === "INVALID_REFRESH_TOKEN" || code === "HTTP_401" || code === "UNAUTHORIZED" || code === "RATE_LIMIT_EXCEEDED" || code === "RATE_LIMIT";
1836
+ return code === "INVALID_REFRESH_TOKEN";
1837
+ }
1838
+ function readRotatedTokens(data) {
1839
+ if (!data || typeof data !== "object") return null;
1840
+ const container = data.tokens;
1841
+ const t = container && typeof container === "object" ? container : data;
1842
+ if (typeof t.accessToken !== "string" || !t.accessToken) return null;
1843
+ if (typeof t.refreshToken !== "string" || !t.refreshToken) return null;
1844
+ if (typeof t.expiresIn !== "number" || t.expiresIn <= 0) return null;
1845
+ return { accessToken: t.accessToken, refreshToken: t.refreshToken, expiresIn: t.expiresIn };
1641
1846
  }
1642
1847
  function decodeJwtPayload(token) {
1643
1848
  try {
@@ -3286,10 +3491,16 @@ var SendoraSDK = class {
3286
3491
  * Clear identity + rotate the anonymous id. Call on logout. Awaits
3287
3492
  * the AsyncStorage writes so a caller who kills the app immediately
3288
3493
  * after doesn't leave stale identity on disk.
3494
+ *
3495
+ * Signs out through `auth` rather than just clearing storage: `clearAll()`
3496
+ * removes the auth keys too, so doing it directly left `Auth` holding an
3497
+ * in-memory user and reporting itself signed in while its refresh token was
3498
+ * already gone — with the server-side session never revoked and no
3499
+ * `onAuthStateChanged` emission telling the app the session had ended.
3289
3500
  */
3290
3501
  async reset() {
3502
+ await this.auth.signOut().catch(() => void 0);
3291
3503
  this.userId = null;
3292
- this.anonId = uuid();
3293
3504
  this.sessionId = "";
3294
3505
  await this.storage.clearAll();
3295
3506
  this.storage.set(ANON_KEY, this.anonId);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sendoracloud/sdk-react-native",
3
- "version": "1.27.0",
4
- "description": "Sendora Cloud React Native + Expo SDK — analytics, identity, push-token registration, auth (incl. Game Center / Play Games), deep links (Branch / Firebase Dynamic Links parity). Auth + analytics + deep links work in Expo Go; push-token registration and the no-arg Game Center / Play Games sign-in require a Dev Client (EAS Build or `npx expo prebuild`).",
3
+ "version": "1.29.0",
4
+ "description": "Sendora Cloud React Native + Expo SDK \u2014 analytics, identity, push-token registration, auth (incl. Game Center / Play Games), deep links (Branch / Firebase Dynamic Links parity). Auth + analytics + deep links work in Expo Go; push-token registration and the no-arg Game Center / Play Games sign-in require a Dev Client (EAS Build or `npx expo prebuild`).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
7
7
  "module": "./dist/index.js",
@@ -56,7 +56,7 @@
56
56
  "scripts": {
57
57
  "build": "tsup src/index.ts src/contact-widget.tsx --format esm,cjs --dts --clean --external react --external react-native --external react-native-webview",
58
58
  "typecheck": "tsc --noEmit",
59
- "test": "tsx test/secure-storage.test.ts && tsx test/integration-storage.test.ts && tsx test/plugin.test.ts && tsx test/game-auth.test.ts && tsx test/link.test.ts"
59
+ "test": "tsx test/secure-storage.test.ts && tsx test/integration-storage.test.ts && tsx test/plugin.test.ts && tsx test/game-auth.test.ts && tsx test/link.test.ts && tsx test/session-preservation.test.ts"
60
60
  },
61
61
  "devDependencies": {
62
62
  "@types/react": "^19.2.16",