@sendoracloud/sdk-react-native 1.27.0 → 1.30.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.30.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 && tsx test/credential-collision.test.ts"
217
217
  },
218
218
  devDependencies: {
219
219
  "@types/react": "^19.2.16",
@@ -390,18 +390,119 @@ var AlreadyIdentifiedError = class extends Error {
390
390
  }
391
391
  };
392
392
  var CredentialInUseError = class extends Error {
393
- constructor(message = "This credential is already linked to a different account") {
393
+ constructor(message = "This credential is already linked to a different account", opts) {
394
394
  super(message);
395
+ /** Always `"CREDENTIAL_IN_USE"`. */
396
+ this.code = "CREDENTIAL_IN_USE";
397
+ /**
398
+ * Always `"credential_in_use"`. Carried so this can be handled in the same
399
+ * `switch (err.kind)` as every other auth failure.
400
+ *
401
+ * NOTE this class deliberately does NOT extend `AuthError` — widening
402
+ * `instanceof AuthError` to include it would silently re-route an upgrading
403
+ * app that checks `AuthError` before this class. The taxonomy fields are
404
+ * duplicated instead.
405
+ */
406
+ this.kind = "credential_in_use";
407
+ /** Always false — a different account owns it; retrying changes nothing. */
408
+ this.retryable = false;
395
409
  this.name = "CredentialInUseError";
410
+ this.provider = opts?.provider;
411
+ this.collision = opts?.collision;
412
+ this.status = opts?.status;
396
413
  }
397
414
  };
415
+ function readCollisionDetails(parsed) {
416
+ const details = parsed?.error?.details;
417
+ if (!details || typeof details !== "object") return {};
418
+ const d = details;
419
+ return {
420
+ provider: typeof d.provider === "string" ? d.provider : void 0,
421
+ collision: d.collision === "identity" || d.collision === "email" ? d.collision : void 0
422
+ };
423
+ }
424
+ var RETRYABLE_KINDS = /* @__PURE__ */ new Set([
425
+ "network",
426
+ "server",
427
+ "rate_limited",
428
+ "account_locked"
429
+ ]);
430
+ function classifyAuthErrorKind(code, status) {
431
+ switch (code) {
432
+ case "NETWORK_ERROR":
433
+ case "NETWORK_TIMEOUT":
434
+ return "network";
435
+ case "PARSE_ERROR":
436
+ return "server";
437
+ case "RATE_LIMITED":
438
+ case "RATE_LIMIT":
439
+ case "RATE_LIMIT_EXCEEDED":
440
+ case "QUOTA_EXCEEDED":
441
+ return "rate_limited";
442
+ case "ACCOUNT_LOCKED":
443
+ return "account_locked";
444
+ case "CREDENTIAL_IN_USE":
445
+ return "credential_in_use";
446
+ case "NOT_ANONYMOUS":
447
+ case "FORBIDDEN_NON_ANONYMOUS":
448
+ return "already_identified";
449
+ case "PASSKEY_USER_CANCELLED":
450
+ case "GAME_CENTER_CANCELLED":
451
+ case "PLAY_GAMES_CANCELLED":
452
+ case "SSO_CANCELLED":
453
+ return "cancelled";
454
+ case "GAME_CENTER_UNAVAILABLE":
455
+ case "PLAY_GAMES_UNAVAILABLE":
456
+ case "ENTITLEMENT_ERROR":
457
+ case "tier_required":
458
+ return "config";
459
+ case "UNAUTHORIZED":
460
+ case "INVALID_CREDENTIALS":
461
+ case "INVALID_REFRESH_TOKEN":
462
+ case "EMAIL_OTP_INVALID":
463
+ case "MAGIC_LINK_INVALID":
464
+ case "PASSKEY_RP_MISMATCH":
465
+ case "NOT_SIGNED_IN":
466
+ return "invalid_credential";
467
+ default:
468
+ break;
469
+ }
470
+ if (typeof status === "number") {
471
+ if (status === 429) return "rate_limited";
472
+ if (status >= 500) return "server";
473
+ if (status >= 400) return "invalid_credential";
474
+ }
475
+ return "unknown";
476
+ }
398
477
  var AuthError = class extends Error {
399
- constructor(code, message) {
478
+ constructor(code, message, opts) {
400
479
  super(message);
401
480
  this.name = "AuthError";
402
481
  this.code = code;
482
+ this.kind = opts?.kind ?? classifyAuthErrorKind(code, opts?.status);
483
+ this.retryable = RETRYABLE_KINDS.has(this.kind);
484
+ this.retryAfterSeconds = opts?.retryAfterSeconds;
485
+ this.status = opts?.status;
403
486
  }
404
487
  };
488
+ function asAuthError(err) {
489
+ if (err instanceof AuthError) return err;
490
+ if (err instanceof EmailAlreadyTakenError || err instanceof AlreadyIdentifiedError || err instanceof CredentialInUseError) {
491
+ return err;
492
+ }
493
+ const name = err?.name;
494
+ if (name === "NetworkTimeoutError") {
495
+ return new AuthError("NETWORK_TIMEOUT", err.message, { kind: "network" });
496
+ }
497
+ return new AuthError("UNKNOWN_ERROR", err instanceof Error ? err.message : String(err), {
498
+ kind: "unknown"
499
+ });
500
+ }
501
+ function readRetryAfterSeconds(parsed) {
502
+ const details = parsed?.error?.details;
503
+ const v = details?.retryAfterSeconds;
504
+ return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : void 0;
505
+ }
405
506
  var GAME_AUTH_NATIVE_MODULE = "SendoraGameAuth";
406
507
  async function fetchGameCenterPayloadNative() {
407
508
  const mod = requireOptionalNativeModule(GAME_AUTH_NATIVE_MODULE);
@@ -466,6 +567,7 @@ var Auth = class {
466
567
  this.refreshCooldownUntil = 0;
467
568
  this.takeoverListeners = /* @__PURE__ */ new Set();
468
569
  this.deletionCancelledListeners = /* @__PURE__ */ new Set();
570
+ this.authStateListeners = /* @__PURE__ */ new Set();
469
571
  this.lastDeletionCancelled = null;
470
572
  this.lastTakeover = null;
471
573
  /**
@@ -476,6 +578,11 @@ var Auth = class {
476
578
  * device ended with two user_ids + duplicate pushes (audit s58.203 fix).
477
579
  */
478
580
  this.pendingAnonTakeover = null;
581
+ /**
582
+ * True once `hydrate()` has run. Distinguishes "no session restored YET"
583
+ * from "genuinely signed out" for `onAuthStateChanged`'s replay-on-subscribe.
584
+ */
585
+ this.hydrated = false;
479
586
  /**
480
587
  * Proactive refresh (s58.72). Fires when access-token age crosses
481
588
  * ~80% of TTL. Pairs with the AppState foreground listener so a
@@ -530,9 +637,9 @@ var Auth = class {
530
637
  this.hooks.storage.remove(USER_KEY);
531
638
  this.hooks.storage.remove(TOKEN_KEY);
532
639
  this.hooks.storage.remove(TOKEN_EXPIRES_KEY);
533
- this.hooks.storage.remove(REFRESH_KEY);
534
640
  }
535
641
  }
642
+ this.hydrated = true;
536
643
  this.resolveReady();
537
644
  }
538
645
  getCurrentUser() {
@@ -558,6 +665,52 @@ var Auth = class {
558
665
  this.takeoverListeners.delete(listener);
559
666
  };
560
667
  }
668
+ /**
669
+ * Subscribe to every auth-state transition (1.28.0) — the single stream that
670
+ * answers "what happened to my session?". Firebase `onAuthStateChanged` /
671
+ * Supabase `onAuthStateChange` parity.
672
+ *
673
+ * The reason it exists: a session that dies in the BACKGROUND (the server
674
+ * rejected the stored refresh token) used to emit no signal whatsoever, so
675
+ * an app could not distinguish it from a deliberate sign-out and only
676
+ * noticed when `getAccessToken()` began returning null. That transition is
677
+ * now `{ event: "signed_out", reason: "session_expired" }`.
678
+ *
679
+ * Fires the CURRENT state immediately on subscribe once `init()` has
680
+ * hydrated (a `signed_in` when a session was restored from disk — including
681
+ * offline, since hydrate is disk-only), so a subscriber never misses the
682
+ * state it started in. Nothing is emitted for a signed-out cold start.
683
+ *
684
+ * A failed sign-in emits NOTHING: the session is unchanged, so there is no
685
+ * transition to report — the rejected promise is the whole story.
686
+ *
687
+ * Listeners are best-effort; a throwing listener is swallowed (dev-logged)
688
+ * and never affects auth state. Returns an unsubscribe function.
689
+ */
690
+ onAuthStateChanged(listener) {
691
+ this.authStateListeners.add(listener);
692
+ if (this.hydrated && this.user) {
693
+ const user = this.user;
694
+ try {
695
+ listener({ event: "signed_in", user });
696
+ } catch (err) {
697
+ if (this.hooks.debug) console.warn("[Sendora] onAuthStateChanged listener threw", err);
698
+ }
699
+ }
700
+ return () => {
701
+ this.authStateListeners.delete(listener);
702
+ };
703
+ }
704
+ /** Fan a state change out to subscribers. Never throws. */
705
+ emitAuthState(change) {
706
+ for (const listener of this.authStateListeners) {
707
+ try {
708
+ listener(change);
709
+ } catch (err) {
710
+ if (this.hooks.debug) console.warn("[Sendora] onAuthStateChanged listener threw", err);
711
+ }
712
+ }
713
+ }
561
714
  /**
562
715
  * Inspect the most recent device-takeover event observed by the
563
716
  * SDK. Cleared on signOut. Returns null when no takeover has
@@ -654,8 +807,27 @@ var Auth = class {
654
807
  getAccessTokenSync() {
655
808
  return this.accessToken;
656
809
  }
810
+ /**
811
+ * Mint (or return) an anonymous session.
812
+ *
813
+ * Reuses the existing anonymous user when there already is one — the same
814
+ * short-circuit Firebase's `signInAnonymously` performs. Without it, an app
815
+ * that calls this defensively on every cold launch (the most natural thing
816
+ * to write) mints a fresh `user_id` each time, and `persist()` overwrites
817
+ * the stored refresh token — the previous anonymous account's only durable
818
+ * handle — with no takeover, no webhook and no state event. That is the same
819
+ * silent progress loss as a failed sign-in wiping the session, except it
820
+ * happens on a *healthy* network.
821
+ *
822
+ * Pass `forceNew: true` to deliberately abandon the current anonymous
823
+ * session and mint a separate one.
824
+ */
657
825
  signInAnonymously(opts) {
658
826
  return this.serialize(async () => {
827
+ const existing = this.user;
828
+ if (!opts?.forceNew && existing?.isAnonymous && this.hooks.storage.get(REFRESH_KEY)) {
829
+ return existing;
830
+ }
659
831
  const data = await this.callAuth("/api/v1/auth-service/anonymous", {
660
832
  name: opts?.name,
661
833
  metadata: opts?.metadata
@@ -704,7 +876,7 @@ var Auth = class {
704
876
  throw new EmailAlreadyTakenError(err.message);
705
877
  }
706
878
  if (err instanceof AuthError && (err.code === "INVALID_REFRESH" || err.code === "UNAUTHORIZED" || err.code === "REFRESH_EXPIRED")) {
707
- await this.wipeLocalIdentity();
879
+ await this.wipeLocalIdentity("session_expired");
708
880
  } else {
709
881
  throw err;
710
882
  }
@@ -745,10 +917,14 @@ var Auth = class {
745
917
  try {
746
918
  parsed = await res.json();
747
919
  } catch {
748
- throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
920
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
749
921
  }
750
922
  if (!res.ok || !parsed.success || !parsed.data) {
751
- throw new AuthError(parsed.error?.code ?? `HTTP_${res.status}`, parsed.error?.message ?? `HTTP ${res.status}`);
923
+ throw new AuthError(
924
+ parsed.error?.code ?? `HTTP_${res.status}`,
925
+ parsed.error?.message ?? `HTTP ${res.status}`,
926
+ { status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
927
+ );
752
928
  }
753
929
  const r = parsed.data;
754
930
  if (r.mfaRequired === true && typeof r.mfaChallengeToken === "string") {
@@ -775,16 +951,12 @@ var Auth = class {
775
951
  */
776
952
  loginSocial(input) {
777
953
  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();
784
- const { link, ...socialInput } = input;
954
+ const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
955
+ const { link, onCredentialInUse, ...socialInput } = input;
785
956
  const payload = { ...socialInput };
786
957
  if (prevAnonRefreshToken) payload.prevAnonRefreshToken = prevAnonRefreshToken;
787
958
  if (link) payload.linkAnonymous = true;
959
+ if (onCredentialInUse) payload.onCredentialInUse = onCredentialInUse;
788
960
  const res = await post(
789
961
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
790
962
  "/api/v1/auth-service/login/social",
@@ -795,14 +967,22 @@ var Auth = class {
795
967
  try {
796
968
  parsed = await res.json();
797
969
  } catch {
798
- throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
970
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
799
971
  }
800
972
  if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
973
+ if (parsed.error?.code === "CREDENTIAL_IN_USE") {
974
+ throw new CredentialInUseError(parsed.error.message, {
975
+ ...readCollisionDetails(parsed),
976
+ status: res.status
977
+ });
978
+ }
801
979
  throw new AuthError(
802
980
  parsed.error?.code ?? "SOCIAL_LOGIN_FAILED",
803
- parsed.error?.message ?? "Social login failed"
981
+ parsed.error?.message ?? "Social login failed",
982
+ { status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
804
983
  );
805
984
  }
985
+ if (this.user !== null) await this.wipeLocalIdentity("replaced");
806
986
  this.persist(parsed.data);
807
987
  return parsed.data.user;
808
988
  });
@@ -831,26 +1011,24 @@ var Auth = class {
831
1011
  signInWithGameCenter(payloadOrOpts, maybeOpts) {
832
1012
  const path = "/api/v1/auth-service/login/game-center";
833
1013
  if (payloadOrOpts && "publicKeyUrl" in payloadOrOpts) {
834
- return this.gameIdentitySignIn(path, { ...payloadOrOpts }, maybeOpts?.link);
1014
+ return this.gameIdentitySignIn(path, { ...payloadOrOpts }, maybeOpts);
835
1015
  }
836
- const link = payloadOrOpts?.link;
837
1016
  return this.gameIdentitySignIn(
838
1017
  path,
839
1018
  async () => await fetchGameCenterPayloadNative(),
840
- link
1019
+ payloadOrOpts
841
1020
  );
842
1021
  }
843
1022
  signInWithPlayGames(payloadOrOpts, maybeOpts) {
844
1023
  const path = "/api/v1/auth-service/login/play-games";
845
1024
  if (payloadOrOpts && "serverAuthCode" in payloadOrOpts) {
846
1025
  const { serverAuthCode } = payloadOrOpts;
847
- return this.gameIdentitySignIn(path, { serverAuthCode }, maybeOpts?.link);
1026
+ return this.gameIdentitySignIn(path, { serverAuthCode }, maybeOpts);
848
1027
  }
849
- const link = payloadOrOpts?.link;
850
1028
  return this.gameIdentitySignIn(
851
1029
  path,
852
1030
  async () => await fetchPlayGamesAuthCodeNative(),
853
- link
1031
+ payloadOrOpts
854
1032
  );
855
1033
  }
856
1034
  // ============================================================
@@ -921,12 +1099,24 @@ var Auth = class {
921
1099
  try {
922
1100
  parsed = await res.json();
923
1101
  } catch {
924
- throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
1102
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
925
1103
  }
926
1104
  if (!res.ok || !parsed.success || !parsed.data) {
927
1105
  const code = parsed.error?.code ?? `HTTP_${res.status}`;
928
- if (code === "CREDENTIAL_IN_USE") throw new CredentialInUseError(parsed.error?.message);
929
- throw new AuthError(code, parsed.error?.message ?? "Failed to link credential");
1106
+ if (code === "CREDENTIAL_IN_USE") {
1107
+ throw new CredentialInUseError(parsed.error?.message, {
1108
+ ...readCollisionDetails(parsed),
1109
+ status: res.status
1110
+ });
1111
+ }
1112
+ throw new AuthError(code, parsed.error?.message ?? "Failed to link credential", {
1113
+ status: res.status,
1114
+ retryAfterSeconds: readRetryAfterSeconds(parsed)
1115
+ });
1116
+ }
1117
+ if (parsed.data.upgraded && isAuthApiResponse(parsed.data)) {
1118
+ this.persist(parsed.data);
1119
+ return parsed.data.user;
930
1120
  }
931
1121
  if (parsed.data.user) this.updateLocalUser(parsed.data.user);
932
1122
  return parsed.data.user ?? this.user;
@@ -976,18 +1166,16 @@ var Auth = class {
976
1166
  * `linkAnonymous` when `link` is set. `persist()` fires `onDeviceTakeover`
977
1167
  * centrally from `retiredAnonUserId`.
978
1168
  */
979
- gameIdentitySignIn(path, identityBody, link) {
1169
+ gameIdentitySignIn(path, identityBody, opts) {
1170
+ const link = opts?.link;
1171
+ const onCredentialInUse = opts?.onCredentialInUse;
980
1172
  return this.serialize(async () => {
981
1173
  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();
1174
+ const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
988
1175
  const payload = { ...body };
989
1176
  if (prevAnonRefreshToken) payload.prevAnonRefreshToken = prevAnonRefreshToken;
990
1177
  if (link) payload.linkAnonymous = true;
1178
+ if (onCredentialInUse) payload.onCredentialInUse = onCredentialInUse;
991
1179
  const res = await post(
992
1180
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
993
1181
  path,
@@ -998,14 +1186,22 @@ var Auth = class {
998
1186
  try {
999
1187
  parsed = await res.json();
1000
1188
  } catch {
1001
- throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
1189
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
1002
1190
  }
1003
1191
  if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
1192
+ if (parsed.error?.code === "CREDENTIAL_IN_USE") {
1193
+ throw new CredentialInUseError(parsed.error.message, {
1194
+ ...readCollisionDetails(parsed),
1195
+ status: res.status
1196
+ });
1197
+ }
1004
1198
  throw new AuthError(
1005
1199
  parsed.error?.code ?? "GAME_LOGIN_FAILED",
1006
- parsed.error?.message ?? "Game sign-in failed"
1200
+ parsed.error?.message ?? "Game sign-in failed",
1201
+ { status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
1007
1202
  );
1008
1203
  }
1204
+ if (this.user !== null) await this.wipeLocalIdentity("replaced");
1009
1205
  this.persist(parsed.data);
1010
1206
  return parsed.data.user;
1011
1207
  });
@@ -1056,10 +1252,10 @@ var Auth = class {
1056
1252
  verifyMagicLink(token) {
1057
1253
  return this.serialize(async () => {
1058
1254
  const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
1059
- if (this.user !== null) await this.wipeLocalIdentity();
1060
1255
  const body = { token };
1061
1256
  if (prevAnonRefreshToken) body.prevAnonRefreshToken = prevAnonRefreshToken;
1062
1257
  const data = await this.callAuth("/api/v1/auth-service/magic-link/verify", body);
1258
+ if (this.user !== null) await this.wipeLocalIdentity("replaced");
1063
1259
  this.persist(data);
1064
1260
  return data.user;
1065
1261
  });
@@ -1080,10 +1276,10 @@ var Auth = class {
1080
1276
  verifyEmailOtp(email, code) {
1081
1277
  return this.serialize(async () => {
1082
1278
  const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
1083
- if (this.user !== null) await this.wipeLocalIdentity();
1084
1279
  const body = { email, code };
1085
1280
  if (prevAnonRefreshToken) body.prevAnonRefreshToken = prevAnonRefreshToken;
1086
1281
  const data = await this.callAuth("/api/v1/auth-service/email-otp/verify", body);
1282
+ if (this.user !== null) await this.wipeLocalIdentity("replaced");
1087
1283
  this.persist(data);
1088
1284
  return data.user;
1089
1285
  });
@@ -1239,7 +1435,7 @@ var Auth = class {
1239
1435
  }
1240
1436
  } catch {
1241
1437
  }
1242
- await this.wipeLocalIdentity();
1438
+ await this.wipeLocalIdentity("account_deleted");
1243
1439
  return out;
1244
1440
  }
1245
1441
  // ============================================================
@@ -1307,7 +1503,6 @@ var Auth = class {
1307
1503
  */
1308
1504
  consumeSsoFragment(callbackUrl) {
1309
1505
  return this.serialize(async () => {
1310
- if (this.user !== null) await this.wipeLocalIdentity();
1311
1506
  const hashIdx = callbackUrl.indexOf("#");
1312
1507
  const fragment = hashIdx >= 0 ? callbackUrl.slice(hashIdx + 1) : "";
1313
1508
  const params = new URLSearchParams(fragment);
@@ -1321,14 +1516,13 @@ var Auth = class {
1321
1516
  "/api/v1/auth-service/token/refresh",
1322
1517
  { refreshToken: refresh }
1323
1518
  );
1324
- if (!res || !res.ok) throw new AuthError("SSO_EXCHANGE_FAILED", `HTTP ${res?.status ?? "network"}`);
1519
+ if (!res || !res.ok) throw new AuthError("SSO_EXCHANGE_FAILED", `HTTP ${res?.status ?? "network"}`, { status: res?.status });
1325
1520
  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");
1521
+ const rotated = parsed.success ? readRotatedTokens(parsed.data) : null;
1522
+ if (!rotated) {
1523
+ throw new AuthError("SSO_EXCHANGE_FAILED", "Could not exchange OIDC token", { status: res.status });
1331
1524
  }
1525
+ const { accessToken: access, refreshToken: newRefresh, expiresIn } = rotated;
1332
1526
  const claims = decodeJwtPayload(access);
1333
1527
  const user = {
1334
1528
  id: claims?.sub ?? "",
@@ -1337,6 +1531,7 @@ var Auth = class {
1337
1531
  name: claims?.name ?? null,
1338
1532
  isAnonymous: false
1339
1533
  };
1534
+ if (this.user !== null) await this.wipeLocalIdentity("replaced");
1340
1535
  this.persist({
1341
1536
  user,
1342
1537
  tokens: {
@@ -1372,7 +1567,7 @@ var Auth = class {
1372
1567
  signOut() {
1373
1568
  return this.serialize(async () => {
1374
1569
  const refresh = this.hooks.storage.get(REFRESH_KEY);
1375
- await this.wipeLocalIdentity();
1570
+ await this.wipeLocalIdentity("user");
1376
1571
  if (refresh) {
1377
1572
  void post(
1378
1573
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
@@ -1386,7 +1581,11 @@ var Auth = class {
1386
1581
  serialize(op) {
1387
1582
  const guarded = async () => {
1388
1583
  await this.readyPromise;
1389
- return op();
1584
+ try {
1585
+ return await op();
1586
+ } catch (err) {
1587
+ throw asAuthError(err);
1588
+ }
1390
1589
  };
1391
1590
  const next = this.inflight.then(guarded, guarded);
1392
1591
  this.inflight = next.catch(() => void 0);
@@ -1411,6 +1610,7 @@ var Auth = class {
1411
1610
  const refreshSnapshot = this.hooks.storage.get(REFRESH_KEY);
1412
1611
  if (!refreshSnapshot) return null;
1413
1612
  this.refreshInflight = (async () => {
1613
+ let sent = refreshSnapshot;
1414
1614
  try {
1415
1615
  const current = this.hooks.storage.get(REFRESH_KEY);
1416
1616
  if (current && current !== refreshSnapshot) {
@@ -1427,20 +1627,28 @@ var Auth = class {
1427
1627
  }
1428
1628
  }
1429
1629
  }
1630
+ sent = current ?? refreshSnapshot;
1430
1631
  const data = await this.callAuth("/api/v1/auth-service/token/refresh", {
1431
- refreshToken: current ?? refreshSnapshot
1632
+ refreshToken: sent
1432
1633
  });
1634
+ if (!this.tokenStillCurrent(sent)) return null;
1433
1635
  this.accessToken = data.tokens.accessToken;
1434
1636
  this.accessExpiresAt = Date.now() + data.tokens.expiresIn * 1e3;
1435
1637
  this.hooks.storage.set(TOKEN_KEY, data.tokens.accessToken);
1436
1638
  this.hooks.storage.set(TOKEN_EXPIRES_KEY, String(this.accessExpiresAt));
1437
1639
  this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
1640
+ if (this.user === null && isAuthApiResponse(data)) {
1641
+ this.user = data.user;
1642
+ this.hooks.storage.set(USER_KEY, JSON.stringify(data.user));
1643
+ this.hooks.onIdentityChange(data.user.id);
1644
+ this.emitAuthState({ event: "signed_in", user: data.user });
1645
+ }
1438
1646
  this.refreshFailureCount = 0;
1439
1647
  this.refreshCooldownUntil = 0;
1440
1648
  return data.tokens.accessToken;
1441
1649
  } catch (err) {
1442
- if (err instanceof AuthError && isDeadRefreshError(err.code)) {
1443
- await this.wipeLocalIdentity().catch(() => void 0);
1650
+ if (err instanceof AuthError && isDeadRefreshError(err.code) && this.tokenStillCurrent(sent)) {
1651
+ await this.wipeLocalIdentity("session_expired").catch(() => void 0);
1444
1652
  this.refreshFailureCount = 0;
1445
1653
  this.refreshCooldownUntil = 0;
1446
1654
  return null;
@@ -1554,12 +1762,13 @@ var Auth = class {
1554
1762
  try {
1555
1763
  parsed = await res.json();
1556
1764
  } catch {
1557
- throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
1765
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
1558
1766
  }
1559
1767
  if (!res.ok || !parsed.success || !parsed.data) {
1560
1768
  throw new AuthError(
1561
1769
  parsed.error?.code ?? `HTTP_${res.status}`,
1562
- parsed.error?.message ?? `HTTP ${res.status}`
1770
+ parsed.error?.message ?? `HTTP ${res.status}`,
1771
+ { status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
1563
1772
  );
1564
1773
  }
1565
1774
  const t = parsed.data.tokens;
@@ -1593,12 +1802,13 @@ var Auth = class {
1593
1802
  try {
1594
1803
  parsed = await res.json();
1595
1804
  } catch {
1596
- throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
1805
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
1597
1806
  }
1598
1807
  if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
1599
1808
  throw new AuthError(
1600
1809
  parsed.error?.code ?? `HTTP_${res.status}`,
1601
- parsed.error?.message ?? `HTTP ${res.status}`
1810
+ parsed.error?.message ?? `HTTP ${res.status}`,
1811
+ { status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
1602
1812
  );
1603
1813
  }
1604
1814
  return parsed.data;
@@ -1613,14 +1823,46 @@ var Auth = class {
1613
1823
  this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
1614
1824
  this.hooks.onIdentityChange(data.user.id);
1615
1825
  this.startProactiveRefreshCron();
1826
+ this.emitAuthState({ event: "signed_in", user: data.user });
1616
1827
  if (data.retiredAnonUserId) {
1617
1828
  this.fireDeviceTakeover(data.retiredAnonUserId, data.user.id);
1829
+ this.emitAuthState({
1830
+ event: "device_takeover",
1831
+ user: data.user,
1832
+ retiredAnonUserId: data.retiredAnonUserId
1833
+ });
1618
1834
  }
1619
1835
  if (data.reactivatedFromDeletion) {
1620
1836
  this.fireDeletionCancelled(data.user.id);
1837
+ this.emitAuthState({ event: "deletion_cancelled", user: data.user });
1621
1838
  }
1622
1839
  }
1623
- async wipeLocalIdentity() {
1840
+ /**
1841
+ * Drop the local session.
1842
+ *
1843
+ * `reason` drives the `onAuthStateChanged` emission: a real sign-out reason
1844
+ * emits `signed_out`, while `"replaced"` (the internal pre-`persist()` clear
1845
+ * on a SUCCESSFUL sign-in) emits nothing — subscribers see one `signed_in`,
1846
+ * not a spurious logout/login pair.
1847
+ *
1848
+ * ⚠ Never call this before a fallible network request. A failed sign-in must
1849
+ * leave the caller exactly as it found them; for an anonymous session the
1850
+ * refresh token dropped here is the ONLY durable handle on the account, so a
1851
+ * pre-call wipe that is not followed by a successful `persist()` orphans the
1852
+ * account permanently (offline: deterministically). Capture the anon refresh
1853
+ * into a local, make the call, and wipe only after the response validates.
1854
+ */
1855
+ /**
1856
+ * True when `sent` is still the refresh token on disk — i.e. nothing
1857
+ * replaced or cleared the session while a request carrying it was in
1858
+ * flight. Guards both directions of the refresh race: installing a rotated
1859
+ * token over a session that has since been signed out or replaced, and
1860
+ * wiping a fresh session because an OLD token was rejected.
1861
+ */
1862
+ tokenStillCurrent(sent) {
1863
+ return this.hooks.storage.get(REFRESH_KEY) === sent;
1864
+ }
1865
+ async wipeLocalIdentity(reason = "replaced") {
1624
1866
  this.user = null;
1625
1867
  this.accessToken = null;
1626
1868
  this.accessExpiresAt = 0;
@@ -1633,11 +1875,23 @@ var Auth = class {
1633
1875
  this.hooks.storage.remove(REFRESH_KEY);
1634
1876
  this.stopProactiveRefreshCron();
1635
1877
  await this.hooks.onAnonymousWipe();
1878
+ if (reason !== "replaced") {
1879
+ this.emitAuthState({ event: "signed_out", user: null, reason });
1880
+ }
1636
1881
  }
1637
1882
  };
1638
1883
  function isDeadRefreshError(code) {
1639
1884
  if (!code) return false;
1640
- return code === "INVALID_REFRESH_TOKEN" || code === "HTTP_401" || code === "UNAUTHORIZED" || code === "RATE_LIMIT_EXCEEDED" || code === "RATE_LIMIT";
1885
+ return code === "INVALID_REFRESH_TOKEN";
1886
+ }
1887
+ function readRotatedTokens(data) {
1888
+ if (!data || typeof data !== "object") return null;
1889
+ const container = data.tokens;
1890
+ const t = container && typeof container === "object" ? container : data;
1891
+ if (typeof t.accessToken !== "string" || !t.accessToken) return null;
1892
+ if (typeof t.refreshToken !== "string" || !t.refreshToken) return null;
1893
+ if (typeof t.expiresIn !== "number" || t.expiresIn <= 0) return null;
1894
+ return { accessToken: t.accessToken, refreshToken: t.refreshToken, expiresIn: t.expiresIn };
1641
1895
  }
1642
1896
  function decodeJwtPayload(token) {
1643
1897
  try {
@@ -3286,10 +3540,16 @@ var SendoraSDK = class {
3286
3540
  * Clear identity + rotate the anonymous id. Call on logout. Awaits
3287
3541
  * the AsyncStorage writes so a caller who kills the app immediately
3288
3542
  * after doesn't leave stale identity on disk.
3543
+ *
3544
+ * Signs out through `auth` rather than just clearing storage: `clearAll()`
3545
+ * removes the auth keys too, so doing it directly left `Auth` holding an
3546
+ * in-memory user and reporting itself signed in while its refresh token was
3547
+ * already gone — with the server-side session never revoked and no
3548
+ * `onAuthStateChanged` emission telling the app the session had ended.
3289
3549
  */
3290
3550
  async reset() {
3551
+ await this.auth.signOut().catch(() => void 0);
3291
3552
  this.userId = null;
3292
- this.anonId = uuid();
3293
3553
  this.sessionId = "";
3294
3554
  await this.storage.clearAll();
3295
3555
  this.storage.set(ANON_KEY, this.anonId);