@sendoracloud/sdk-react-native 1.26.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.26.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;
@@ -984,6 +1134,36 @@ var Auth = class {
984
1134
  this.user = user;
985
1135
  this.hooks.storage.set(USER_KEY, JSON.stringify(user));
986
1136
  }
1137
+ /**
1138
+ * List the auth methods/identities linked to the current account (ADR-030
1139
+ * read side, s58.270). Returns every social/gaming identity on this account
1140
+ * plus a `hasPassword` flag — the cross-device / reinstall-durable source of
1141
+ * truth for a "Connected: Game Center · Google" UI (an on-device tracker
1142
+ * misses a link made on another device). Bearer-gated network read (NOT a
1143
+ * cache read — `getUserSync()` only holds the primary `signupMethod`/
1144
+ * `lastLoginMethod`). Resolves a FRESH access token first (mirrors
1145
+ * `deleteAccount`). Firebase `user.providerData` / Supabase `user.identities`.
1146
+ */
1147
+ async listLinkedIdentities() {
1148
+ const token = await this.getAccessToken();
1149
+ if (!token) throw new AuthError("NOT_SIGNED_IN", "Sign in before listing linked identities");
1150
+ const res = await getJson(
1151
+ { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
1152
+ "/api/v1/auth-service/me/identities",
1153
+ { Authorization: `Bearer ${token}` }
1154
+ );
1155
+ if (!res || !res.ok) throw new AuthError("NETWORK_ERROR", "Failed to list linked identities");
1156
+ let parsed;
1157
+ try {
1158
+ parsed = await res.json();
1159
+ } catch {
1160
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
1161
+ }
1162
+ if (!parsed.success || !parsed.data) {
1163
+ throw new AuthError(parsed.error?.code ?? "IDENTITIES_FAILED", parsed.error?.message ?? "Failed to list linked identities");
1164
+ }
1165
+ return { identities: parsed.data.identities ?? [], hasPassword: Boolean(parsed.data.hasPassword) };
1166
+ }
987
1167
  /**
988
1168
  * Shared gaming-identity sign-in. Mirrors `loginSocial`'s anon-takeover +
989
1169
  * ADR-025 link-in-place handling: reads the anon refresh BEFORE wiping local
@@ -994,12 +1174,7 @@ var Auth = class {
994
1174
  gameIdentitySignIn(path, identityBody, link) {
995
1175
  return this.serialize(async () => {
996
1176
  const body = typeof identityBody === "function" ? await identityBody() : identityBody;
997
- let prevAnonRefreshToken;
998
- if (this.user?.isAnonymous) {
999
- const stashed = this.hooks.storage.get(REFRESH_KEY);
1000
- if (stashed) prevAnonRefreshToken = stashed;
1001
- }
1002
- if (this.user !== null) await this.wipeLocalIdentity();
1177
+ const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
1003
1178
  const payload = { ...body };
1004
1179
  if (prevAnonRefreshToken) payload.prevAnonRefreshToken = prevAnonRefreshToken;
1005
1180
  if (link) payload.linkAnonymous = true;
@@ -1013,14 +1188,16 @@ var Auth = class {
1013
1188
  try {
1014
1189
  parsed = await res.json();
1015
1190
  } catch {
1016
- throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
1191
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
1017
1192
  }
1018
1193
  if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
1019
1194
  throw new AuthError(
1020
1195
  parsed.error?.code ?? "GAME_LOGIN_FAILED",
1021
- parsed.error?.message ?? "Game sign-in failed"
1196
+ parsed.error?.message ?? "Game sign-in failed",
1197
+ { status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
1022
1198
  );
1023
1199
  }
1200
+ if (this.user !== null) await this.wipeLocalIdentity("replaced");
1024
1201
  this.persist(parsed.data);
1025
1202
  return parsed.data.user;
1026
1203
  });
@@ -1071,10 +1248,10 @@ var Auth = class {
1071
1248
  verifyMagicLink(token) {
1072
1249
  return this.serialize(async () => {
1073
1250
  const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
1074
- if (this.user !== null) await this.wipeLocalIdentity();
1075
1251
  const body = { token };
1076
1252
  if (prevAnonRefreshToken) body.prevAnonRefreshToken = prevAnonRefreshToken;
1077
1253
  const data = await this.callAuth("/api/v1/auth-service/magic-link/verify", body);
1254
+ if (this.user !== null) await this.wipeLocalIdentity("replaced");
1078
1255
  this.persist(data);
1079
1256
  return data.user;
1080
1257
  });
@@ -1095,10 +1272,10 @@ var Auth = class {
1095
1272
  verifyEmailOtp(email, code) {
1096
1273
  return this.serialize(async () => {
1097
1274
  const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
1098
- if (this.user !== null) await this.wipeLocalIdentity();
1099
1275
  const body = { email, code };
1100
1276
  if (prevAnonRefreshToken) body.prevAnonRefreshToken = prevAnonRefreshToken;
1101
1277
  const data = await this.callAuth("/api/v1/auth-service/email-otp/verify", body);
1278
+ if (this.user !== null) await this.wipeLocalIdentity("replaced");
1102
1279
  this.persist(data);
1103
1280
  return data.user;
1104
1281
  });
@@ -1254,7 +1431,7 @@ var Auth = class {
1254
1431
  }
1255
1432
  } catch {
1256
1433
  }
1257
- await this.wipeLocalIdentity();
1434
+ await this.wipeLocalIdentity("account_deleted");
1258
1435
  return out;
1259
1436
  }
1260
1437
  // ============================================================
@@ -1322,7 +1499,6 @@ var Auth = class {
1322
1499
  */
1323
1500
  consumeSsoFragment(callbackUrl) {
1324
1501
  return this.serialize(async () => {
1325
- if (this.user !== null) await this.wipeLocalIdentity();
1326
1502
  const hashIdx = callbackUrl.indexOf("#");
1327
1503
  const fragment = hashIdx >= 0 ? callbackUrl.slice(hashIdx + 1) : "";
1328
1504
  const params = new URLSearchParams(fragment);
@@ -1336,14 +1512,13 @@ var Auth = class {
1336
1512
  "/api/v1/auth-service/token/refresh",
1337
1513
  { refreshToken: refresh }
1338
1514
  );
1339
- 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 });
1340
1516
  const parsed = await res.json();
1341
- const access = parsed.data?.accessToken;
1342
- const newRefresh = parsed.data?.refreshToken;
1343
- const expiresIn = parsed.data?.expiresIn;
1344
- if (!parsed.success || !access || !newRefresh || !expiresIn || expiresIn <= 0) {
1345
- 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 });
1346
1520
  }
1521
+ const { accessToken: access, refreshToken: newRefresh, expiresIn } = rotated;
1347
1522
  const claims = decodeJwtPayload(access);
1348
1523
  const user = {
1349
1524
  id: claims?.sub ?? "",
@@ -1352,6 +1527,7 @@ var Auth = class {
1352
1527
  name: claims?.name ?? null,
1353
1528
  isAnonymous: false
1354
1529
  };
1530
+ if (this.user !== null) await this.wipeLocalIdentity("replaced");
1355
1531
  this.persist({
1356
1532
  user,
1357
1533
  tokens: {
@@ -1387,7 +1563,7 @@ var Auth = class {
1387
1563
  signOut() {
1388
1564
  return this.serialize(async () => {
1389
1565
  const refresh = this.hooks.storage.get(REFRESH_KEY);
1390
- await this.wipeLocalIdentity();
1566
+ await this.wipeLocalIdentity("user");
1391
1567
  if (refresh) {
1392
1568
  void post(
1393
1569
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
@@ -1401,7 +1577,11 @@ var Auth = class {
1401
1577
  serialize(op) {
1402
1578
  const guarded = async () => {
1403
1579
  await this.readyPromise;
1404
- return op();
1580
+ try {
1581
+ return await op();
1582
+ } catch (err) {
1583
+ throw asAuthError(err);
1584
+ }
1405
1585
  };
1406
1586
  const next = this.inflight.then(guarded, guarded);
1407
1587
  this.inflight = next.catch(() => void 0);
@@ -1426,6 +1606,7 @@ var Auth = class {
1426
1606
  const refreshSnapshot = this.hooks.storage.get(REFRESH_KEY);
1427
1607
  if (!refreshSnapshot) return null;
1428
1608
  this.refreshInflight = (async () => {
1609
+ let sent = refreshSnapshot;
1429
1610
  try {
1430
1611
  const current = this.hooks.storage.get(REFRESH_KEY);
1431
1612
  if (current && current !== refreshSnapshot) {
@@ -1442,20 +1623,28 @@ var Auth = class {
1442
1623
  }
1443
1624
  }
1444
1625
  }
1626
+ sent = current ?? refreshSnapshot;
1445
1627
  const data = await this.callAuth("/api/v1/auth-service/token/refresh", {
1446
- refreshToken: current ?? refreshSnapshot
1628
+ refreshToken: sent
1447
1629
  });
1630
+ if (!this.tokenStillCurrent(sent)) return null;
1448
1631
  this.accessToken = data.tokens.accessToken;
1449
1632
  this.accessExpiresAt = Date.now() + data.tokens.expiresIn * 1e3;
1450
1633
  this.hooks.storage.set(TOKEN_KEY, data.tokens.accessToken);
1451
1634
  this.hooks.storage.set(TOKEN_EXPIRES_KEY, String(this.accessExpiresAt));
1452
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
+ }
1453
1642
  this.refreshFailureCount = 0;
1454
1643
  this.refreshCooldownUntil = 0;
1455
1644
  return data.tokens.accessToken;
1456
1645
  } catch (err) {
1457
- if (err instanceof AuthError && isDeadRefreshError(err.code)) {
1458
- 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);
1459
1648
  this.refreshFailureCount = 0;
1460
1649
  this.refreshCooldownUntil = 0;
1461
1650
  return null;
@@ -1569,12 +1758,13 @@ var Auth = class {
1569
1758
  try {
1570
1759
  parsed = await res.json();
1571
1760
  } catch {
1572
- throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
1761
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
1573
1762
  }
1574
1763
  if (!res.ok || !parsed.success || !parsed.data) {
1575
1764
  throw new AuthError(
1576
1765
  parsed.error?.code ?? `HTTP_${res.status}`,
1577
- parsed.error?.message ?? `HTTP ${res.status}`
1766
+ parsed.error?.message ?? `HTTP ${res.status}`,
1767
+ { status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
1578
1768
  );
1579
1769
  }
1580
1770
  const t = parsed.data.tokens;
@@ -1608,12 +1798,13 @@ var Auth = class {
1608
1798
  try {
1609
1799
  parsed = await res.json();
1610
1800
  } catch {
1611
- throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
1801
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
1612
1802
  }
1613
1803
  if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
1614
1804
  throw new AuthError(
1615
1805
  parsed.error?.code ?? `HTTP_${res.status}`,
1616
- parsed.error?.message ?? `HTTP ${res.status}`
1806
+ parsed.error?.message ?? `HTTP ${res.status}`,
1807
+ { status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
1617
1808
  );
1618
1809
  }
1619
1810
  return parsed.data;
@@ -1628,14 +1819,46 @@ var Auth = class {
1628
1819
  this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
1629
1820
  this.hooks.onIdentityChange(data.user.id);
1630
1821
  this.startProactiveRefreshCron();
1822
+ this.emitAuthState({ event: "signed_in", user: data.user });
1631
1823
  if (data.retiredAnonUserId) {
1632
1824
  this.fireDeviceTakeover(data.retiredAnonUserId, data.user.id);
1825
+ this.emitAuthState({
1826
+ event: "device_takeover",
1827
+ user: data.user,
1828
+ retiredAnonUserId: data.retiredAnonUserId
1829
+ });
1633
1830
  }
1634
1831
  if (data.reactivatedFromDeletion) {
1635
1832
  this.fireDeletionCancelled(data.user.id);
1833
+ this.emitAuthState({ event: "deletion_cancelled", user: data.user });
1636
1834
  }
1637
1835
  }
1638
- 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") {
1639
1862
  this.user = null;
1640
1863
  this.accessToken = null;
1641
1864
  this.accessExpiresAt = 0;
@@ -1648,11 +1871,23 @@ var Auth = class {
1648
1871
  this.hooks.storage.remove(REFRESH_KEY);
1649
1872
  this.stopProactiveRefreshCron();
1650
1873
  await this.hooks.onAnonymousWipe();
1874
+ if (reason !== "replaced") {
1875
+ this.emitAuthState({ event: "signed_out", user: null, reason });
1876
+ }
1651
1877
  }
1652
1878
  };
1653
1879
  function isDeadRefreshError(code) {
1654
1880
  if (!code) return false;
1655
- 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 };
1656
1891
  }
1657
1892
  function decodeJwtPayload(token) {
1658
1893
  try {
@@ -3296,10 +3531,16 @@ var SendoraSDK = class {
3296
3531
  * Clear identity + rotate the anonymous id. Call on logout. Awaits
3297
3532
  * the AsyncStorage writes so a caller who kills the app immediately
3298
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.
3299
3540
  */
3300
3541
  async reset() {
3542
+ await this.auth.signOut().catch(() => void 0);
3301
3543
  this.userId = null;
3302
- this.anonId = uuid();
3303
3544
  this.sessionId = "";
3304
3545
  await this.storage.clearAll();
3305
3546
  this.storage.set(ANON_KEY, this.anonId);