@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.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.26.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;
@@ -939,6 +1089,36 @@ var Auth = class {
939
1089
  this.user = user;
940
1090
  this.hooks.storage.set(USER_KEY, JSON.stringify(user));
941
1091
  }
1092
+ /**
1093
+ * List the auth methods/identities linked to the current account (ADR-030
1094
+ * read side, s58.270). Returns every social/gaming identity on this account
1095
+ * plus a `hasPassword` flag — the cross-device / reinstall-durable source of
1096
+ * truth for a "Connected: Game Center · Google" UI (an on-device tracker
1097
+ * misses a link made on another device). Bearer-gated network read (NOT a
1098
+ * cache read — `getUserSync()` only holds the primary `signupMethod`/
1099
+ * `lastLoginMethod`). Resolves a FRESH access token first (mirrors
1100
+ * `deleteAccount`). Firebase `user.providerData` / Supabase `user.identities`.
1101
+ */
1102
+ async listLinkedIdentities() {
1103
+ const token = await this.getAccessToken();
1104
+ if (!token) throw new AuthError("NOT_SIGNED_IN", "Sign in before listing linked identities");
1105
+ const res = await getJson(
1106
+ { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
1107
+ "/api/v1/auth-service/me/identities",
1108
+ { Authorization: `Bearer ${token}` }
1109
+ );
1110
+ if (!res || !res.ok) throw new AuthError("NETWORK_ERROR", "Failed to list linked identities");
1111
+ let parsed;
1112
+ try {
1113
+ parsed = await res.json();
1114
+ } catch {
1115
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
1116
+ }
1117
+ if (!parsed.success || !parsed.data) {
1118
+ throw new AuthError(parsed.error?.code ?? "IDENTITIES_FAILED", parsed.error?.message ?? "Failed to list linked identities");
1119
+ }
1120
+ return { identities: parsed.data.identities ?? [], hasPassword: Boolean(parsed.data.hasPassword) };
1121
+ }
942
1122
  /**
943
1123
  * Shared gaming-identity sign-in. Mirrors `loginSocial`'s anon-takeover +
944
1124
  * ADR-025 link-in-place handling: reads the anon refresh BEFORE wiping local
@@ -949,12 +1129,7 @@ var Auth = class {
949
1129
  gameIdentitySignIn(path, identityBody, link) {
950
1130
  return this.serialize(async () => {
951
1131
  const body = typeof identityBody === "function" ? await identityBody() : identityBody;
952
- let prevAnonRefreshToken;
953
- if (this.user?.isAnonymous) {
954
- const stashed = this.hooks.storage.get(REFRESH_KEY);
955
- if (stashed) prevAnonRefreshToken = stashed;
956
- }
957
- if (this.user !== null) await this.wipeLocalIdentity();
1132
+ const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
958
1133
  const payload = { ...body };
959
1134
  if (prevAnonRefreshToken) payload.prevAnonRefreshToken = prevAnonRefreshToken;
960
1135
  if (link) payload.linkAnonymous = true;
@@ -968,14 +1143,16 @@ var Auth = class {
968
1143
  try {
969
1144
  parsed = await res.json();
970
1145
  } catch {
971
- throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
1146
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
972
1147
  }
973
1148
  if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
974
1149
  throw new AuthError(
975
1150
  parsed.error?.code ?? "GAME_LOGIN_FAILED",
976
- parsed.error?.message ?? "Game sign-in failed"
1151
+ parsed.error?.message ?? "Game sign-in failed",
1152
+ { status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
977
1153
  );
978
1154
  }
1155
+ if (this.user !== null) await this.wipeLocalIdentity("replaced");
979
1156
  this.persist(parsed.data);
980
1157
  return parsed.data.user;
981
1158
  });
@@ -1026,10 +1203,10 @@ var Auth = class {
1026
1203
  verifyMagicLink(token) {
1027
1204
  return this.serialize(async () => {
1028
1205
  const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
1029
- if (this.user !== null) await this.wipeLocalIdentity();
1030
1206
  const body = { token };
1031
1207
  if (prevAnonRefreshToken) body.prevAnonRefreshToken = prevAnonRefreshToken;
1032
1208
  const data = await this.callAuth("/api/v1/auth-service/magic-link/verify", body);
1209
+ if (this.user !== null) await this.wipeLocalIdentity("replaced");
1033
1210
  this.persist(data);
1034
1211
  return data.user;
1035
1212
  });
@@ -1050,10 +1227,10 @@ var Auth = class {
1050
1227
  verifyEmailOtp(email, code) {
1051
1228
  return this.serialize(async () => {
1052
1229
  const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
1053
- if (this.user !== null) await this.wipeLocalIdentity();
1054
1230
  const body = { email, code };
1055
1231
  if (prevAnonRefreshToken) body.prevAnonRefreshToken = prevAnonRefreshToken;
1056
1232
  const data = await this.callAuth("/api/v1/auth-service/email-otp/verify", body);
1233
+ if (this.user !== null) await this.wipeLocalIdentity("replaced");
1057
1234
  this.persist(data);
1058
1235
  return data.user;
1059
1236
  });
@@ -1209,7 +1386,7 @@ var Auth = class {
1209
1386
  }
1210
1387
  } catch {
1211
1388
  }
1212
- await this.wipeLocalIdentity();
1389
+ await this.wipeLocalIdentity("account_deleted");
1213
1390
  return out;
1214
1391
  }
1215
1392
  // ============================================================
@@ -1277,7 +1454,6 @@ var Auth = class {
1277
1454
  */
1278
1455
  consumeSsoFragment(callbackUrl) {
1279
1456
  return this.serialize(async () => {
1280
- if (this.user !== null) await this.wipeLocalIdentity();
1281
1457
  const hashIdx = callbackUrl.indexOf("#");
1282
1458
  const fragment = hashIdx >= 0 ? callbackUrl.slice(hashIdx + 1) : "";
1283
1459
  const params = new URLSearchParams(fragment);
@@ -1291,14 +1467,13 @@ var Auth = class {
1291
1467
  "/api/v1/auth-service/token/refresh",
1292
1468
  { refreshToken: refresh }
1293
1469
  );
1294
- 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 });
1295
1471
  const parsed = await res.json();
1296
- const access = parsed.data?.accessToken;
1297
- const newRefresh = parsed.data?.refreshToken;
1298
- const expiresIn = parsed.data?.expiresIn;
1299
- if (!parsed.success || !access || !newRefresh || !expiresIn || expiresIn <= 0) {
1300
- 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 });
1301
1475
  }
1476
+ const { accessToken: access, refreshToken: newRefresh, expiresIn } = rotated;
1302
1477
  const claims = decodeJwtPayload(access);
1303
1478
  const user = {
1304
1479
  id: claims?.sub ?? "",
@@ -1307,6 +1482,7 @@ var Auth = class {
1307
1482
  name: claims?.name ?? null,
1308
1483
  isAnonymous: false
1309
1484
  };
1485
+ if (this.user !== null) await this.wipeLocalIdentity("replaced");
1310
1486
  this.persist({
1311
1487
  user,
1312
1488
  tokens: {
@@ -1342,7 +1518,7 @@ var Auth = class {
1342
1518
  signOut() {
1343
1519
  return this.serialize(async () => {
1344
1520
  const refresh = this.hooks.storage.get(REFRESH_KEY);
1345
- await this.wipeLocalIdentity();
1521
+ await this.wipeLocalIdentity("user");
1346
1522
  if (refresh) {
1347
1523
  void post(
1348
1524
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
@@ -1356,7 +1532,11 @@ var Auth = class {
1356
1532
  serialize(op) {
1357
1533
  const guarded = async () => {
1358
1534
  await this.readyPromise;
1359
- return op();
1535
+ try {
1536
+ return await op();
1537
+ } catch (err) {
1538
+ throw asAuthError(err);
1539
+ }
1360
1540
  };
1361
1541
  const next = this.inflight.then(guarded, guarded);
1362
1542
  this.inflight = next.catch(() => void 0);
@@ -1381,6 +1561,7 @@ var Auth = class {
1381
1561
  const refreshSnapshot = this.hooks.storage.get(REFRESH_KEY);
1382
1562
  if (!refreshSnapshot) return null;
1383
1563
  this.refreshInflight = (async () => {
1564
+ let sent = refreshSnapshot;
1384
1565
  try {
1385
1566
  const current = this.hooks.storage.get(REFRESH_KEY);
1386
1567
  if (current && current !== refreshSnapshot) {
@@ -1397,20 +1578,28 @@ var Auth = class {
1397
1578
  }
1398
1579
  }
1399
1580
  }
1581
+ sent = current ?? refreshSnapshot;
1400
1582
  const data = await this.callAuth("/api/v1/auth-service/token/refresh", {
1401
- refreshToken: current ?? refreshSnapshot
1583
+ refreshToken: sent
1402
1584
  });
1585
+ if (!this.tokenStillCurrent(sent)) return null;
1403
1586
  this.accessToken = data.tokens.accessToken;
1404
1587
  this.accessExpiresAt = Date.now() + data.tokens.expiresIn * 1e3;
1405
1588
  this.hooks.storage.set(TOKEN_KEY, data.tokens.accessToken);
1406
1589
  this.hooks.storage.set(TOKEN_EXPIRES_KEY, String(this.accessExpiresAt));
1407
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
+ }
1408
1597
  this.refreshFailureCount = 0;
1409
1598
  this.refreshCooldownUntil = 0;
1410
1599
  return data.tokens.accessToken;
1411
1600
  } catch (err) {
1412
- if (err instanceof AuthError && isDeadRefreshError(err.code)) {
1413
- 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);
1414
1603
  this.refreshFailureCount = 0;
1415
1604
  this.refreshCooldownUntil = 0;
1416
1605
  return null;
@@ -1524,12 +1713,13 @@ var Auth = class {
1524
1713
  try {
1525
1714
  parsed = await res.json();
1526
1715
  } catch {
1527
- throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
1716
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
1528
1717
  }
1529
1718
  if (!res.ok || !parsed.success || !parsed.data) {
1530
1719
  throw new AuthError(
1531
1720
  parsed.error?.code ?? `HTTP_${res.status}`,
1532
- parsed.error?.message ?? `HTTP ${res.status}`
1721
+ parsed.error?.message ?? `HTTP ${res.status}`,
1722
+ { status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
1533
1723
  );
1534
1724
  }
1535
1725
  const t = parsed.data.tokens;
@@ -1563,12 +1753,13 @@ var Auth = class {
1563
1753
  try {
1564
1754
  parsed = await res.json();
1565
1755
  } catch {
1566
- throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
1756
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
1567
1757
  }
1568
1758
  if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
1569
1759
  throw new AuthError(
1570
1760
  parsed.error?.code ?? `HTTP_${res.status}`,
1571
- parsed.error?.message ?? `HTTP ${res.status}`
1761
+ parsed.error?.message ?? `HTTP ${res.status}`,
1762
+ { status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
1572
1763
  );
1573
1764
  }
1574
1765
  return parsed.data;
@@ -1583,14 +1774,46 @@ var Auth = class {
1583
1774
  this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
1584
1775
  this.hooks.onIdentityChange(data.user.id);
1585
1776
  this.startProactiveRefreshCron();
1777
+ this.emitAuthState({ event: "signed_in", user: data.user });
1586
1778
  if (data.retiredAnonUserId) {
1587
1779
  this.fireDeviceTakeover(data.retiredAnonUserId, data.user.id);
1780
+ this.emitAuthState({
1781
+ event: "device_takeover",
1782
+ user: data.user,
1783
+ retiredAnonUserId: data.retiredAnonUserId
1784
+ });
1588
1785
  }
1589
1786
  if (data.reactivatedFromDeletion) {
1590
1787
  this.fireDeletionCancelled(data.user.id);
1788
+ this.emitAuthState({ event: "deletion_cancelled", user: data.user });
1591
1789
  }
1592
1790
  }
1593
- 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") {
1594
1817
  this.user = null;
1595
1818
  this.accessToken = null;
1596
1819
  this.accessExpiresAt = 0;
@@ -1603,11 +1826,23 @@ var Auth = class {
1603
1826
  this.hooks.storage.remove(REFRESH_KEY);
1604
1827
  this.stopProactiveRefreshCron();
1605
1828
  await this.hooks.onAnonymousWipe();
1829
+ if (reason !== "replaced") {
1830
+ this.emitAuthState({ event: "signed_out", user: null, reason });
1831
+ }
1606
1832
  }
1607
1833
  };
1608
1834
  function isDeadRefreshError(code) {
1609
1835
  if (!code) return false;
1610
- 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 };
1611
1846
  }
1612
1847
  function decodeJwtPayload(token) {
1613
1848
  try {
@@ -3256,10 +3491,16 @@ var SendoraSDK = class {
3256
3491
  * Clear identity + rotate the anonymous id. Call on logout. Awaits
3257
3492
  * the AsyncStorage writes so a caller who kills the app immediately
3258
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.
3259
3500
  */
3260
3501
  async reset() {
3502
+ await this.auth.signOut().catch(() => void 0);
3261
3503
  this.userId = null;
3262
- this.anonId = uuid();
3263
3504
  this.sessionId = "";
3264
3505
  await this.storage.clearAll();
3265
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.26.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",