@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.cjs CHANGED
@@ -202,7 +202,7 @@ var Storage = class {
202
202
  // package.json
203
203
  var package_default = {
204
204
  name: "@sendoracloud/sdk-react-native",
205
- version: "1.27.0",
205
+ version: "1.29.0",
206
206
  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`).",
207
207
  type: "module",
208
208
  main: "./dist/index.cjs",
@@ -258,7 +258,7 @@ var package_default = {
258
258
  scripts: {
259
259
  build: "tsup src/index.ts src/contact-widget.tsx --format esm,cjs --dts --clean --external react --external react-native --external react-native-webview",
260
260
  typecheck: "tsc --noEmit",
261
- 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"
261
+ 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"
262
262
  },
263
263
  devDependencies: {
264
264
  "@types/react": "^19.2.16",
@@ -440,13 +440,88 @@ var CredentialInUseError = class extends Error {
440
440
  this.name = "CredentialInUseError";
441
441
  }
442
442
  };
443
+ var RETRYABLE_KINDS = /* @__PURE__ */ new Set([
444
+ "network",
445
+ "server",
446
+ "rate_limited",
447
+ "account_locked"
448
+ ]);
449
+ function classifyAuthErrorKind(code, status) {
450
+ switch (code) {
451
+ case "NETWORK_ERROR":
452
+ case "NETWORK_TIMEOUT":
453
+ return "network";
454
+ case "PARSE_ERROR":
455
+ return "server";
456
+ case "RATE_LIMITED":
457
+ case "RATE_LIMIT":
458
+ case "RATE_LIMIT_EXCEEDED":
459
+ case "QUOTA_EXCEEDED":
460
+ return "rate_limited";
461
+ case "ACCOUNT_LOCKED":
462
+ return "account_locked";
463
+ case "CREDENTIAL_IN_USE":
464
+ return "credential_in_use";
465
+ case "NOT_ANONYMOUS":
466
+ case "FORBIDDEN_NON_ANONYMOUS":
467
+ return "already_identified";
468
+ case "PASSKEY_USER_CANCELLED":
469
+ case "GAME_CENTER_CANCELLED":
470
+ case "PLAY_GAMES_CANCELLED":
471
+ case "SSO_CANCELLED":
472
+ return "cancelled";
473
+ case "GAME_CENTER_UNAVAILABLE":
474
+ case "PLAY_GAMES_UNAVAILABLE":
475
+ case "ENTITLEMENT_ERROR":
476
+ case "tier_required":
477
+ return "config";
478
+ case "UNAUTHORIZED":
479
+ case "INVALID_CREDENTIALS":
480
+ case "INVALID_REFRESH_TOKEN":
481
+ case "EMAIL_OTP_INVALID":
482
+ case "MAGIC_LINK_INVALID":
483
+ case "PASSKEY_RP_MISMATCH":
484
+ case "NOT_SIGNED_IN":
485
+ return "invalid_credential";
486
+ default:
487
+ break;
488
+ }
489
+ if (typeof status === "number") {
490
+ if (status === 429) return "rate_limited";
491
+ if (status >= 500) return "server";
492
+ if (status >= 400) return "invalid_credential";
493
+ }
494
+ return "unknown";
495
+ }
443
496
  var AuthError = class extends Error {
444
- constructor(code, message) {
497
+ constructor(code, message, opts) {
445
498
  super(message);
446
499
  this.name = "AuthError";
447
500
  this.code = code;
501
+ this.kind = opts?.kind ?? classifyAuthErrorKind(code, opts?.status);
502
+ this.retryable = RETRYABLE_KINDS.has(this.kind);
503
+ this.retryAfterSeconds = opts?.retryAfterSeconds;
504
+ this.status = opts?.status;
448
505
  }
449
506
  };
507
+ function asAuthError(err) {
508
+ if (err instanceof AuthError) return err;
509
+ if (err instanceof EmailAlreadyTakenError || err instanceof AlreadyIdentifiedError || err instanceof CredentialInUseError) {
510
+ return err;
511
+ }
512
+ const name = err?.name;
513
+ if (name === "NetworkTimeoutError") {
514
+ return new AuthError("NETWORK_TIMEOUT", err.message, { kind: "network" });
515
+ }
516
+ return new AuthError("UNKNOWN_ERROR", err instanceof Error ? err.message : String(err), {
517
+ kind: "unknown"
518
+ });
519
+ }
520
+ function readRetryAfterSeconds(parsed) {
521
+ const details = parsed?.error?.details;
522
+ const v = details?.retryAfterSeconds;
523
+ return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : void 0;
524
+ }
450
525
  var GAME_AUTH_NATIVE_MODULE = "SendoraGameAuth";
451
526
  async function fetchGameCenterPayloadNative() {
452
527
  const mod = requireOptionalNativeModule(GAME_AUTH_NATIVE_MODULE);
@@ -511,6 +586,7 @@ var Auth = class {
511
586
  this.refreshCooldownUntil = 0;
512
587
  this.takeoverListeners = /* @__PURE__ */ new Set();
513
588
  this.deletionCancelledListeners = /* @__PURE__ */ new Set();
589
+ this.authStateListeners = /* @__PURE__ */ new Set();
514
590
  this.lastDeletionCancelled = null;
515
591
  this.lastTakeover = null;
516
592
  /**
@@ -521,6 +597,11 @@ var Auth = class {
521
597
  * device ended with two user_ids + duplicate pushes (audit s58.203 fix).
522
598
  */
523
599
  this.pendingAnonTakeover = null;
600
+ /**
601
+ * True once `hydrate()` has run. Distinguishes "no session restored YET"
602
+ * from "genuinely signed out" for `onAuthStateChanged`'s replay-on-subscribe.
603
+ */
604
+ this.hydrated = false;
524
605
  /**
525
606
  * Proactive refresh (s58.72). Fires when access-token age crosses
526
607
  * ~80% of TTL. Pairs with the AppState foreground listener so a
@@ -575,9 +656,9 @@ var Auth = class {
575
656
  this.hooks.storage.remove(USER_KEY);
576
657
  this.hooks.storage.remove(TOKEN_KEY);
577
658
  this.hooks.storage.remove(TOKEN_EXPIRES_KEY);
578
- this.hooks.storage.remove(REFRESH_KEY);
579
659
  }
580
660
  }
661
+ this.hydrated = true;
581
662
  this.resolveReady();
582
663
  }
583
664
  getCurrentUser() {
@@ -603,6 +684,52 @@ var Auth = class {
603
684
  this.takeoverListeners.delete(listener);
604
685
  };
605
686
  }
687
+ /**
688
+ * Subscribe to every auth-state transition (1.28.0) — the single stream that
689
+ * answers "what happened to my session?". Firebase `onAuthStateChanged` /
690
+ * Supabase `onAuthStateChange` parity.
691
+ *
692
+ * The reason it exists: a session that dies in the BACKGROUND (the server
693
+ * rejected the stored refresh token) used to emit no signal whatsoever, so
694
+ * an app could not distinguish it from a deliberate sign-out and only
695
+ * noticed when `getAccessToken()` began returning null. That transition is
696
+ * now `{ event: "signed_out", reason: "session_expired" }`.
697
+ *
698
+ * Fires the CURRENT state immediately on subscribe once `init()` has
699
+ * hydrated (a `signed_in` when a session was restored from disk — including
700
+ * offline, since hydrate is disk-only), so a subscriber never misses the
701
+ * state it started in. Nothing is emitted for a signed-out cold start.
702
+ *
703
+ * A failed sign-in emits NOTHING: the session is unchanged, so there is no
704
+ * transition to report — the rejected promise is the whole story.
705
+ *
706
+ * Listeners are best-effort; a throwing listener is swallowed (dev-logged)
707
+ * and never affects auth state. Returns an unsubscribe function.
708
+ */
709
+ onAuthStateChanged(listener) {
710
+ this.authStateListeners.add(listener);
711
+ if (this.hydrated && this.user) {
712
+ const user = this.user;
713
+ try {
714
+ listener({ event: "signed_in", user });
715
+ } catch (err) {
716
+ if (this.hooks.debug) console.warn("[Sendora] onAuthStateChanged listener threw", err);
717
+ }
718
+ }
719
+ return () => {
720
+ this.authStateListeners.delete(listener);
721
+ };
722
+ }
723
+ /** Fan a state change out to subscribers. Never throws. */
724
+ emitAuthState(change) {
725
+ for (const listener of this.authStateListeners) {
726
+ try {
727
+ listener(change);
728
+ } catch (err) {
729
+ if (this.hooks.debug) console.warn("[Sendora] onAuthStateChanged listener threw", err);
730
+ }
731
+ }
732
+ }
606
733
  /**
607
734
  * Inspect the most recent device-takeover event observed by the
608
735
  * SDK. Cleared on signOut. Returns null when no takeover has
@@ -699,8 +826,27 @@ var Auth = class {
699
826
  getAccessTokenSync() {
700
827
  return this.accessToken;
701
828
  }
829
+ /**
830
+ * Mint (or return) an anonymous session.
831
+ *
832
+ * Reuses the existing anonymous user when there already is one — the same
833
+ * short-circuit Firebase's `signInAnonymously` performs. Without it, an app
834
+ * that calls this defensively on every cold launch (the most natural thing
835
+ * to write) mints a fresh `user_id` each time, and `persist()` overwrites
836
+ * the stored refresh token — the previous anonymous account's only durable
837
+ * handle — with no takeover, no webhook and no state event. That is the same
838
+ * silent progress loss as a failed sign-in wiping the session, except it
839
+ * happens on a *healthy* network.
840
+ *
841
+ * Pass `forceNew: true` to deliberately abandon the current anonymous
842
+ * session and mint a separate one.
843
+ */
702
844
  signInAnonymously(opts) {
703
845
  return this.serialize(async () => {
846
+ const existing = this.user;
847
+ if (!opts?.forceNew && existing?.isAnonymous && this.hooks.storage.get(REFRESH_KEY)) {
848
+ return existing;
849
+ }
704
850
  const data = await this.callAuth("/api/v1/auth-service/anonymous", {
705
851
  name: opts?.name,
706
852
  metadata: opts?.metadata
@@ -749,7 +895,7 @@ var Auth = class {
749
895
  throw new EmailAlreadyTakenError(err.message);
750
896
  }
751
897
  if (err instanceof AuthError && (err.code === "INVALID_REFRESH" || err.code === "UNAUTHORIZED" || err.code === "REFRESH_EXPIRED")) {
752
- await this.wipeLocalIdentity();
898
+ await this.wipeLocalIdentity("session_expired");
753
899
  } else {
754
900
  throw err;
755
901
  }
@@ -790,10 +936,14 @@ var Auth = class {
790
936
  try {
791
937
  parsed = await res.json();
792
938
  } catch {
793
- throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
939
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
794
940
  }
795
941
  if (!res.ok || !parsed.success || !parsed.data) {
796
- throw new AuthError(parsed.error?.code ?? `HTTP_${res.status}`, parsed.error?.message ?? `HTTP ${res.status}`);
942
+ throw new AuthError(
943
+ parsed.error?.code ?? `HTTP_${res.status}`,
944
+ parsed.error?.message ?? `HTTP ${res.status}`,
945
+ { status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
946
+ );
797
947
  }
798
948
  const r = parsed.data;
799
949
  if (r.mfaRequired === true && typeof r.mfaChallengeToken === "string") {
@@ -820,12 +970,7 @@ var Auth = class {
820
970
  */
821
971
  loginSocial(input) {
822
972
  return this.serialize(async () => {
823
- let prevAnonRefreshToken;
824
- if (this.user?.isAnonymous) {
825
- const stashed = this.hooks.storage.get(REFRESH_KEY);
826
- if (stashed) prevAnonRefreshToken = stashed;
827
- }
828
- if (this.user !== null) await this.wipeLocalIdentity();
973
+ const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
829
974
  const { link, ...socialInput } = input;
830
975
  const payload = { ...socialInput };
831
976
  if (prevAnonRefreshToken) payload.prevAnonRefreshToken = prevAnonRefreshToken;
@@ -840,14 +985,16 @@ var Auth = class {
840
985
  try {
841
986
  parsed = await res.json();
842
987
  } catch {
843
- throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
988
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
844
989
  }
845
990
  if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
846
991
  throw new AuthError(
847
992
  parsed.error?.code ?? "SOCIAL_LOGIN_FAILED",
848
- parsed.error?.message ?? "Social login failed"
993
+ parsed.error?.message ?? "Social login failed",
994
+ { status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
849
995
  );
850
996
  }
997
+ if (this.user !== null) await this.wipeLocalIdentity("replaced");
851
998
  this.persist(parsed.data);
852
999
  return parsed.data.user;
853
1000
  });
@@ -966,12 +1113,15 @@ var Auth = class {
966
1113
  try {
967
1114
  parsed = await res.json();
968
1115
  } catch {
969
- throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
1116
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
970
1117
  }
971
1118
  if (!res.ok || !parsed.success || !parsed.data) {
972
1119
  const code = parsed.error?.code ?? `HTTP_${res.status}`;
973
1120
  if (code === "CREDENTIAL_IN_USE") throw new CredentialInUseError(parsed.error?.message);
974
- throw new AuthError(code, parsed.error?.message ?? "Failed to link credential");
1121
+ throw new AuthError(code, parsed.error?.message ?? "Failed to link credential", {
1122
+ status: res.status,
1123
+ retryAfterSeconds: readRetryAfterSeconds(parsed)
1124
+ });
975
1125
  }
976
1126
  if (parsed.data.user) this.updateLocalUser(parsed.data.user);
977
1127
  return parsed.data.user ?? this.user;
@@ -1024,12 +1174,7 @@ var Auth = class {
1024
1174
  gameIdentitySignIn(path, identityBody, link) {
1025
1175
  return this.serialize(async () => {
1026
1176
  const body = typeof identityBody === "function" ? await identityBody() : identityBody;
1027
- let prevAnonRefreshToken;
1028
- if (this.user?.isAnonymous) {
1029
- const stashed = this.hooks.storage.get(REFRESH_KEY);
1030
- if (stashed) prevAnonRefreshToken = stashed;
1031
- }
1032
- if (this.user !== null) await this.wipeLocalIdentity();
1177
+ const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
1033
1178
  const payload = { ...body };
1034
1179
  if (prevAnonRefreshToken) payload.prevAnonRefreshToken = prevAnonRefreshToken;
1035
1180
  if (link) payload.linkAnonymous = true;
@@ -1043,14 +1188,16 @@ var Auth = class {
1043
1188
  try {
1044
1189
  parsed = await res.json();
1045
1190
  } catch {
1046
- throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
1191
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
1047
1192
  }
1048
1193
  if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
1049
1194
  throw new AuthError(
1050
1195
  parsed.error?.code ?? "GAME_LOGIN_FAILED",
1051
- parsed.error?.message ?? "Game sign-in failed"
1196
+ parsed.error?.message ?? "Game sign-in failed",
1197
+ { status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
1052
1198
  );
1053
1199
  }
1200
+ if (this.user !== null) await this.wipeLocalIdentity("replaced");
1054
1201
  this.persist(parsed.data);
1055
1202
  return parsed.data.user;
1056
1203
  });
@@ -1101,10 +1248,10 @@ var Auth = class {
1101
1248
  verifyMagicLink(token) {
1102
1249
  return this.serialize(async () => {
1103
1250
  const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
1104
- if (this.user !== null) await this.wipeLocalIdentity();
1105
1251
  const body = { token };
1106
1252
  if (prevAnonRefreshToken) body.prevAnonRefreshToken = prevAnonRefreshToken;
1107
1253
  const data = await this.callAuth("/api/v1/auth-service/magic-link/verify", body);
1254
+ if (this.user !== null) await this.wipeLocalIdentity("replaced");
1108
1255
  this.persist(data);
1109
1256
  return data.user;
1110
1257
  });
@@ -1125,10 +1272,10 @@ var Auth = class {
1125
1272
  verifyEmailOtp(email, code) {
1126
1273
  return this.serialize(async () => {
1127
1274
  const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
1128
- if (this.user !== null) await this.wipeLocalIdentity();
1129
1275
  const body = { email, code };
1130
1276
  if (prevAnonRefreshToken) body.prevAnonRefreshToken = prevAnonRefreshToken;
1131
1277
  const data = await this.callAuth("/api/v1/auth-service/email-otp/verify", body);
1278
+ if (this.user !== null) await this.wipeLocalIdentity("replaced");
1132
1279
  this.persist(data);
1133
1280
  return data.user;
1134
1281
  });
@@ -1284,7 +1431,7 @@ var Auth = class {
1284
1431
  }
1285
1432
  } catch {
1286
1433
  }
1287
- await this.wipeLocalIdentity();
1434
+ await this.wipeLocalIdentity("account_deleted");
1288
1435
  return out;
1289
1436
  }
1290
1437
  // ============================================================
@@ -1352,7 +1499,6 @@ var Auth = class {
1352
1499
  */
1353
1500
  consumeSsoFragment(callbackUrl) {
1354
1501
  return this.serialize(async () => {
1355
- if (this.user !== null) await this.wipeLocalIdentity();
1356
1502
  const hashIdx = callbackUrl.indexOf("#");
1357
1503
  const fragment = hashIdx >= 0 ? callbackUrl.slice(hashIdx + 1) : "";
1358
1504
  const params = new URLSearchParams(fragment);
@@ -1366,14 +1512,13 @@ var Auth = class {
1366
1512
  "/api/v1/auth-service/token/refresh",
1367
1513
  { refreshToken: refresh }
1368
1514
  );
1369
- if (!res || !res.ok) throw new AuthError("SSO_EXCHANGE_FAILED", `HTTP ${res?.status ?? "network"}`);
1515
+ if (!res || !res.ok) throw new AuthError("SSO_EXCHANGE_FAILED", `HTTP ${res?.status ?? "network"}`, { status: res?.status });
1370
1516
  const parsed = await res.json();
1371
- const access = parsed.data?.accessToken;
1372
- const newRefresh = parsed.data?.refreshToken;
1373
- const expiresIn = parsed.data?.expiresIn;
1374
- if (!parsed.success || !access || !newRefresh || !expiresIn || expiresIn <= 0) {
1375
- throw new AuthError("SSO_EXCHANGE_FAILED", "Could not exchange OIDC token");
1517
+ const rotated = parsed.success ? readRotatedTokens(parsed.data) : null;
1518
+ if (!rotated) {
1519
+ throw new AuthError("SSO_EXCHANGE_FAILED", "Could not exchange OIDC token", { status: res.status });
1376
1520
  }
1521
+ const { accessToken: access, refreshToken: newRefresh, expiresIn } = rotated;
1377
1522
  const claims = decodeJwtPayload(access);
1378
1523
  const user = {
1379
1524
  id: claims?.sub ?? "",
@@ -1382,6 +1527,7 @@ var Auth = class {
1382
1527
  name: claims?.name ?? null,
1383
1528
  isAnonymous: false
1384
1529
  };
1530
+ if (this.user !== null) await this.wipeLocalIdentity("replaced");
1385
1531
  this.persist({
1386
1532
  user,
1387
1533
  tokens: {
@@ -1417,7 +1563,7 @@ var Auth = class {
1417
1563
  signOut() {
1418
1564
  return this.serialize(async () => {
1419
1565
  const refresh = this.hooks.storage.get(REFRESH_KEY);
1420
- await this.wipeLocalIdentity();
1566
+ await this.wipeLocalIdentity("user");
1421
1567
  if (refresh) {
1422
1568
  void post(
1423
1569
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
@@ -1431,7 +1577,11 @@ var Auth = class {
1431
1577
  serialize(op) {
1432
1578
  const guarded = async () => {
1433
1579
  await this.readyPromise;
1434
- return op();
1580
+ try {
1581
+ return await op();
1582
+ } catch (err) {
1583
+ throw asAuthError(err);
1584
+ }
1435
1585
  };
1436
1586
  const next = this.inflight.then(guarded, guarded);
1437
1587
  this.inflight = next.catch(() => void 0);
@@ -1456,6 +1606,7 @@ var Auth = class {
1456
1606
  const refreshSnapshot = this.hooks.storage.get(REFRESH_KEY);
1457
1607
  if (!refreshSnapshot) return null;
1458
1608
  this.refreshInflight = (async () => {
1609
+ let sent = refreshSnapshot;
1459
1610
  try {
1460
1611
  const current = this.hooks.storage.get(REFRESH_KEY);
1461
1612
  if (current && current !== refreshSnapshot) {
@@ -1472,20 +1623,28 @@ var Auth = class {
1472
1623
  }
1473
1624
  }
1474
1625
  }
1626
+ sent = current ?? refreshSnapshot;
1475
1627
  const data = await this.callAuth("/api/v1/auth-service/token/refresh", {
1476
- refreshToken: current ?? refreshSnapshot
1628
+ refreshToken: sent
1477
1629
  });
1630
+ if (!this.tokenStillCurrent(sent)) return null;
1478
1631
  this.accessToken = data.tokens.accessToken;
1479
1632
  this.accessExpiresAt = Date.now() + data.tokens.expiresIn * 1e3;
1480
1633
  this.hooks.storage.set(TOKEN_KEY, data.tokens.accessToken);
1481
1634
  this.hooks.storage.set(TOKEN_EXPIRES_KEY, String(this.accessExpiresAt));
1482
1635
  this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
1636
+ if (this.user === null && isAuthApiResponse(data)) {
1637
+ this.user = data.user;
1638
+ this.hooks.storage.set(USER_KEY, JSON.stringify(data.user));
1639
+ this.hooks.onIdentityChange(data.user.id);
1640
+ this.emitAuthState({ event: "signed_in", user: data.user });
1641
+ }
1483
1642
  this.refreshFailureCount = 0;
1484
1643
  this.refreshCooldownUntil = 0;
1485
1644
  return data.tokens.accessToken;
1486
1645
  } catch (err) {
1487
- if (err instanceof AuthError && isDeadRefreshError(err.code)) {
1488
- await this.wipeLocalIdentity().catch(() => void 0);
1646
+ if (err instanceof AuthError && isDeadRefreshError(err.code) && this.tokenStillCurrent(sent)) {
1647
+ await this.wipeLocalIdentity("session_expired").catch(() => void 0);
1489
1648
  this.refreshFailureCount = 0;
1490
1649
  this.refreshCooldownUntil = 0;
1491
1650
  return null;
@@ -1599,12 +1758,13 @@ var Auth = class {
1599
1758
  try {
1600
1759
  parsed = await res.json();
1601
1760
  } catch {
1602
- throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
1761
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
1603
1762
  }
1604
1763
  if (!res.ok || !parsed.success || !parsed.data) {
1605
1764
  throw new AuthError(
1606
1765
  parsed.error?.code ?? `HTTP_${res.status}`,
1607
- parsed.error?.message ?? `HTTP ${res.status}`
1766
+ parsed.error?.message ?? `HTTP ${res.status}`,
1767
+ { status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
1608
1768
  );
1609
1769
  }
1610
1770
  const t = parsed.data.tokens;
@@ -1638,12 +1798,13 @@ var Auth = class {
1638
1798
  try {
1639
1799
  parsed = await res.json();
1640
1800
  } catch {
1641
- throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
1801
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
1642
1802
  }
1643
1803
  if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
1644
1804
  throw new AuthError(
1645
1805
  parsed.error?.code ?? `HTTP_${res.status}`,
1646
- parsed.error?.message ?? `HTTP ${res.status}`
1806
+ parsed.error?.message ?? `HTTP ${res.status}`,
1807
+ { status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
1647
1808
  );
1648
1809
  }
1649
1810
  return parsed.data;
@@ -1658,14 +1819,46 @@ var Auth = class {
1658
1819
  this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
1659
1820
  this.hooks.onIdentityChange(data.user.id);
1660
1821
  this.startProactiveRefreshCron();
1822
+ this.emitAuthState({ event: "signed_in", user: data.user });
1661
1823
  if (data.retiredAnonUserId) {
1662
1824
  this.fireDeviceTakeover(data.retiredAnonUserId, data.user.id);
1825
+ this.emitAuthState({
1826
+ event: "device_takeover",
1827
+ user: data.user,
1828
+ retiredAnonUserId: data.retiredAnonUserId
1829
+ });
1663
1830
  }
1664
1831
  if (data.reactivatedFromDeletion) {
1665
1832
  this.fireDeletionCancelled(data.user.id);
1833
+ this.emitAuthState({ event: "deletion_cancelled", user: data.user });
1666
1834
  }
1667
1835
  }
1668
- async wipeLocalIdentity() {
1836
+ /**
1837
+ * Drop the local session.
1838
+ *
1839
+ * `reason` drives the `onAuthStateChanged` emission: a real sign-out reason
1840
+ * emits `signed_out`, while `"replaced"` (the internal pre-`persist()` clear
1841
+ * on a SUCCESSFUL sign-in) emits nothing — subscribers see one `signed_in`,
1842
+ * not a spurious logout/login pair.
1843
+ *
1844
+ * ⚠ Never call this before a fallible network request. A failed sign-in must
1845
+ * leave the caller exactly as it found them; for an anonymous session the
1846
+ * refresh token dropped here is the ONLY durable handle on the account, so a
1847
+ * pre-call wipe that is not followed by a successful `persist()` orphans the
1848
+ * account permanently (offline: deterministically). Capture the anon refresh
1849
+ * into a local, make the call, and wipe only after the response validates.
1850
+ */
1851
+ /**
1852
+ * True when `sent` is still the refresh token on disk — i.e. nothing
1853
+ * replaced or cleared the session while a request carrying it was in
1854
+ * flight. Guards both directions of the refresh race: installing a rotated
1855
+ * token over a session that has since been signed out or replaced, and
1856
+ * wiping a fresh session because an OLD token was rejected.
1857
+ */
1858
+ tokenStillCurrent(sent) {
1859
+ return this.hooks.storage.get(REFRESH_KEY) === sent;
1860
+ }
1861
+ async wipeLocalIdentity(reason = "replaced") {
1669
1862
  this.user = null;
1670
1863
  this.accessToken = null;
1671
1864
  this.accessExpiresAt = 0;
@@ -1678,11 +1871,23 @@ var Auth = class {
1678
1871
  this.hooks.storage.remove(REFRESH_KEY);
1679
1872
  this.stopProactiveRefreshCron();
1680
1873
  await this.hooks.onAnonymousWipe();
1874
+ if (reason !== "replaced") {
1875
+ this.emitAuthState({ event: "signed_out", user: null, reason });
1876
+ }
1681
1877
  }
1682
1878
  };
1683
1879
  function isDeadRefreshError(code) {
1684
1880
  if (!code) return false;
1685
- return code === "INVALID_REFRESH_TOKEN" || code === "HTTP_401" || code === "UNAUTHORIZED" || code === "RATE_LIMIT_EXCEEDED" || code === "RATE_LIMIT";
1881
+ return code === "INVALID_REFRESH_TOKEN";
1882
+ }
1883
+ function readRotatedTokens(data) {
1884
+ if (!data || typeof data !== "object") return null;
1885
+ const container = data.tokens;
1886
+ const t = container && typeof container === "object" ? container : data;
1887
+ if (typeof t.accessToken !== "string" || !t.accessToken) return null;
1888
+ if (typeof t.refreshToken !== "string" || !t.refreshToken) return null;
1889
+ if (typeof t.expiresIn !== "number" || t.expiresIn <= 0) return null;
1890
+ return { accessToken: t.accessToken, refreshToken: t.refreshToken, expiresIn: t.expiresIn };
1686
1891
  }
1687
1892
  function decodeJwtPayload(token) {
1688
1893
  try {
@@ -3326,10 +3531,16 @@ var SendoraSDK = class {
3326
3531
  * Clear identity + rotate the anonymous id. Call on logout. Awaits
3327
3532
  * the AsyncStorage writes so a caller who kills the app immediately
3328
3533
  * after doesn't leave stale identity on disk.
3534
+ *
3535
+ * Signs out through `auth` rather than just clearing storage: `clearAll()`
3536
+ * removes the auth keys too, so doing it directly left `Auth` holding an
3537
+ * in-memory user and reporting itself signed in while its refresh token was
3538
+ * already gone — with the server-side session never revoked and no
3539
+ * `onAuthStateChanged` emission telling the app the session had ended.
3329
3540
  */
3330
3541
  async reset() {
3542
+ await this.auth.signOut().catch(() => void 0);
3331
3543
  this.userId = null;
3332
- this.anonId = uuid();
3333
3544
  this.sessionId = "";
3334
3545
  await this.storage.clearAll();
3335
3546
  this.storage.set(ANON_KEY, this.anonId);