@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.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.30.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 && tsx test/credential-collision.test.ts"
262
262
  },
263
263
  devDependencies: {
264
264
  "@types/react": "^19.2.16",
@@ -435,18 +435,119 @@ var AlreadyIdentifiedError = class extends Error {
435
435
  }
436
436
  };
437
437
  var CredentialInUseError = class extends Error {
438
- constructor(message = "This credential is already linked to a different account") {
438
+ constructor(message = "This credential is already linked to a different account", opts) {
439
439
  super(message);
440
+ /** Always `"CREDENTIAL_IN_USE"`. */
441
+ this.code = "CREDENTIAL_IN_USE";
442
+ /**
443
+ * Always `"credential_in_use"`. Carried so this can be handled in the same
444
+ * `switch (err.kind)` as every other auth failure.
445
+ *
446
+ * NOTE this class deliberately does NOT extend `AuthError` — widening
447
+ * `instanceof AuthError` to include it would silently re-route an upgrading
448
+ * app that checks `AuthError` before this class. The taxonomy fields are
449
+ * duplicated instead.
450
+ */
451
+ this.kind = "credential_in_use";
452
+ /** Always false — a different account owns it; retrying changes nothing. */
453
+ this.retryable = false;
440
454
  this.name = "CredentialInUseError";
455
+ this.provider = opts?.provider;
456
+ this.collision = opts?.collision;
457
+ this.status = opts?.status;
441
458
  }
442
459
  };
460
+ function readCollisionDetails(parsed) {
461
+ const details = parsed?.error?.details;
462
+ if (!details || typeof details !== "object") return {};
463
+ const d = details;
464
+ return {
465
+ provider: typeof d.provider === "string" ? d.provider : void 0,
466
+ collision: d.collision === "identity" || d.collision === "email" ? d.collision : void 0
467
+ };
468
+ }
469
+ var RETRYABLE_KINDS = /* @__PURE__ */ new Set([
470
+ "network",
471
+ "server",
472
+ "rate_limited",
473
+ "account_locked"
474
+ ]);
475
+ function classifyAuthErrorKind(code, status) {
476
+ switch (code) {
477
+ case "NETWORK_ERROR":
478
+ case "NETWORK_TIMEOUT":
479
+ return "network";
480
+ case "PARSE_ERROR":
481
+ return "server";
482
+ case "RATE_LIMITED":
483
+ case "RATE_LIMIT":
484
+ case "RATE_LIMIT_EXCEEDED":
485
+ case "QUOTA_EXCEEDED":
486
+ return "rate_limited";
487
+ case "ACCOUNT_LOCKED":
488
+ return "account_locked";
489
+ case "CREDENTIAL_IN_USE":
490
+ return "credential_in_use";
491
+ case "NOT_ANONYMOUS":
492
+ case "FORBIDDEN_NON_ANONYMOUS":
493
+ return "already_identified";
494
+ case "PASSKEY_USER_CANCELLED":
495
+ case "GAME_CENTER_CANCELLED":
496
+ case "PLAY_GAMES_CANCELLED":
497
+ case "SSO_CANCELLED":
498
+ return "cancelled";
499
+ case "GAME_CENTER_UNAVAILABLE":
500
+ case "PLAY_GAMES_UNAVAILABLE":
501
+ case "ENTITLEMENT_ERROR":
502
+ case "tier_required":
503
+ return "config";
504
+ case "UNAUTHORIZED":
505
+ case "INVALID_CREDENTIALS":
506
+ case "INVALID_REFRESH_TOKEN":
507
+ case "EMAIL_OTP_INVALID":
508
+ case "MAGIC_LINK_INVALID":
509
+ case "PASSKEY_RP_MISMATCH":
510
+ case "NOT_SIGNED_IN":
511
+ return "invalid_credential";
512
+ default:
513
+ break;
514
+ }
515
+ if (typeof status === "number") {
516
+ if (status === 429) return "rate_limited";
517
+ if (status >= 500) return "server";
518
+ if (status >= 400) return "invalid_credential";
519
+ }
520
+ return "unknown";
521
+ }
443
522
  var AuthError = class extends Error {
444
- constructor(code, message) {
523
+ constructor(code, message, opts) {
445
524
  super(message);
446
525
  this.name = "AuthError";
447
526
  this.code = code;
527
+ this.kind = opts?.kind ?? classifyAuthErrorKind(code, opts?.status);
528
+ this.retryable = RETRYABLE_KINDS.has(this.kind);
529
+ this.retryAfterSeconds = opts?.retryAfterSeconds;
530
+ this.status = opts?.status;
448
531
  }
449
532
  };
533
+ function asAuthError(err) {
534
+ if (err instanceof AuthError) return err;
535
+ if (err instanceof EmailAlreadyTakenError || err instanceof AlreadyIdentifiedError || err instanceof CredentialInUseError) {
536
+ return err;
537
+ }
538
+ const name = err?.name;
539
+ if (name === "NetworkTimeoutError") {
540
+ return new AuthError("NETWORK_TIMEOUT", err.message, { kind: "network" });
541
+ }
542
+ return new AuthError("UNKNOWN_ERROR", err instanceof Error ? err.message : String(err), {
543
+ kind: "unknown"
544
+ });
545
+ }
546
+ function readRetryAfterSeconds(parsed) {
547
+ const details = parsed?.error?.details;
548
+ const v = details?.retryAfterSeconds;
549
+ return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : void 0;
550
+ }
450
551
  var GAME_AUTH_NATIVE_MODULE = "SendoraGameAuth";
451
552
  async function fetchGameCenterPayloadNative() {
452
553
  const mod = requireOptionalNativeModule(GAME_AUTH_NATIVE_MODULE);
@@ -511,6 +612,7 @@ var Auth = class {
511
612
  this.refreshCooldownUntil = 0;
512
613
  this.takeoverListeners = /* @__PURE__ */ new Set();
513
614
  this.deletionCancelledListeners = /* @__PURE__ */ new Set();
615
+ this.authStateListeners = /* @__PURE__ */ new Set();
514
616
  this.lastDeletionCancelled = null;
515
617
  this.lastTakeover = null;
516
618
  /**
@@ -521,6 +623,11 @@ var Auth = class {
521
623
  * device ended with two user_ids + duplicate pushes (audit s58.203 fix).
522
624
  */
523
625
  this.pendingAnonTakeover = null;
626
+ /**
627
+ * True once `hydrate()` has run. Distinguishes "no session restored YET"
628
+ * from "genuinely signed out" for `onAuthStateChanged`'s replay-on-subscribe.
629
+ */
630
+ this.hydrated = false;
524
631
  /**
525
632
  * Proactive refresh (s58.72). Fires when access-token age crosses
526
633
  * ~80% of TTL. Pairs with the AppState foreground listener so a
@@ -575,9 +682,9 @@ var Auth = class {
575
682
  this.hooks.storage.remove(USER_KEY);
576
683
  this.hooks.storage.remove(TOKEN_KEY);
577
684
  this.hooks.storage.remove(TOKEN_EXPIRES_KEY);
578
- this.hooks.storage.remove(REFRESH_KEY);
579
685
  }
580
686
  }
687
+ this.hydrated = true;
581
688
  this.resolveReady();
582
689
  }
583
690
  getCurrentUser() {
@@ -603,6 +710,52 @@ var Auth = class {
603
710
  this.takeoverListeners.delete(listener);
604
711
  };
605
712
  }
713
+ /**
714
+ * Subscribe to every auth-state transition (1.28.0) — the single stream that
715
+ * answers "what happened to my session?". Firebase `onAuthStateChanged` /
716
+ * Supabase `onAuthStateChange` parity.
717
+ *
718
+ * The reason it exists: a session that dies in the BACKGROUND (the server
719
+ * rejected the stored refresh token) used to emit no signal whatsoever, so
720
+ * an app could not distinguish it from a deliberate sign-out and only
721
+ * noticed when `getAccessToken()` began returning null. That transition is
722
+ * now `{ event: "signed_out", reason: "session_expired" }`.
723
+ *
724
+ * Fires the CURRENT state immediately on subscribe once `init()` has
725
+ * hydrated (a `signed_in` when a session was restored from disk — including
726
+ * offline, since hydrate is disk-only), so a subscriber never misses the
727
+ * state it started in. Nothing is emitted for a signed-out cold start.
728
+ *
729
+ * A failed sign-in emits NOTHING: the session is unchanged, so there is no
730
+ * transition to report — the rejected promise is the whole story.
731
+ *
732
+ * Listeners are best-effort; a throwing listener is swallowed (dev-logged)
733
+ * and never affects auth state. Returns an unsubscribe function.
734
+ */
735
+ onAuthStateChanged(listener) {
736
+ this.authStateListeners.add(listener);
737
+ if (this.hydrated && this.user) {
738
+ const user = this.user;
739
+ try {
740
+ listener({ event: "signed_in", user });
741
+ } catch (err) {
742
+ if (this.hooks.debug) console.warn("[Sendora] onAuthStateChanged listener threw", err);
743
+ }
744
+ }
745
+ return () => {
746
+ this.authStateListeners.delete(listener);
747
+ };
748
+ }
749
+ /** Fan a state change out to subscribers. Never throws. */
750
+ emitAuthState(change) {
751
+ for (const listener of this.authStateListeners) {
752
+ try {
753
+ listener(change);
754
+ } catch (err) {
755
+ if (this.hooks.debug) console.warn("[Sendora] onAuthStateChanged listener threw", err);
756
+ }
757
+ }
758
+ }
606
759
  /**
607
760
  * Inspect the most recent device-takeover event observed by the
608
761
  * SDK. Cleared on signOut. Returns null when no takeover has
@@ -699,8 +852,27 @@ var Auth = class {
699
852
  getAccessTokenSync() {
700
853
  return this.accessToken;
701
854
  }
855
+ /**
856
+ * Mint (or return) an anonymous session.
857
+ *
858
+ * Reuses the existing anonymous user when there already is one — the same
859
+ * short-circuit Firebase's `signInAnonymously` performs. Without it, an app
860
+ * that calls this defensively on every cold launch (the most natural thing
861
+ * to write) mints a fresh `user_id` each time, and `persist()` overwrites
862
+ * the stored refresh token — the previous anonymous account's only durable
863
+ * handle — with no takeover, no webhook and no state event. That is the same
864
+ * silent progress loss as a failed sign-in wiping the session, except it
865
+ * happens on a *healthy* network.
866
+ *
867
+ * Pass `forceNew: true` to deliberately abandon the current anonymous
868
+ * session and mint a separate one.
869
+ */
702
870
  signInAnonymously(opts) {
703
871
  return this.serialize(async () => {
872
+ const existing = this.user;
873
+ if (!opts?.forceNew && existing?.isAnonymous && this.hooks.storage.get(REFRESH_KEY)) {
874
+ return existing;
875
+ }
704
876
  const data = await this.callAuth("/api/v1/auth-service/anonymous", {
705
877
  name: opts?.name,
706
878
  metadata: opts?.metadata
@@ -749,7 +921,7 @@ var Auth = class {
749
921
  throw new EmailAlreadyTakenError(err.message);
750
922
  }
751
923
  if (err instanceof AuthError && (err.code === "INVALID_REFRESH" || err.code === "UNAUTHORIZED" || err.code === "REFRESH_EXPIRED")) {
752
- await this.wipeLocalIdentity();
924
+ await this.wipeLocalIdentity("session_expired");
753
925
  } else {
754
926
  throw err;
755
927
  }
@@ -790,10 +962,14 @@ var Auth = class {
790
962
  try {
791
963
  parsed = await res.json();
792
964
  } catch {
793
- throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
965
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
794
966
  }
795
967
  if (!res.ok || !parsed.success || !parsed.data) {
796
- throw new AuthError(parsed.error?.code ?? `HTTP_${res.status}`, parsed.error?.message ?? `HTTP ${res.status}`);
968
+ throw new AuthError(
969
+ parsed.error?.code ?? `HTTP_${res.status}`,
970
+ parsed.error?.message ?? `HTTP ${res.status}`,
971
+ { status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
972
+ );
797
973
  }
798
974
  const r = parsed.data;
799
975
  if (r.mfaRequired === true && typeof r.mfaChallengeToken === "string") {
@@ -820,16 +996,12 @@ var Auth = class {
820
996
  */
821
997
  loginSocial(input) {
822
998
  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();
829
- const { link, ...socialInput } = input;
999
+ const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
1000
+ const { link, onCredentialInUse, ...socialInput } = input;
830
1001
  const payload = { ...socialInput };
831
1002
  if (prevAnonRefreshToken) payload.prevAnonRefreshToken = prevAnonRefreshToken;
832
1003
  if (link) payload.linkAnonymous = true;
1004
+ if (onCredentialInUse) payload.onCredentialInUse = onCredentialInUse;
833
1005
  const res = await post(
834
1006
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
835
1007
  "/api/v1/auth-service/login/social",
@@ -840,14 +1012,22 @@ var Auth = class {
840
1012
  try {
841
1013
  parsed = await res.json();
842
1014
  } catch {
843
- throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
1015
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
844
1016
  }
845
1017
  if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
1018
+ if (parsed.error?.code === "CREDENTIAL_IN_USE") {
1019
+ throw new CredentialInUseError(parsed.error.message, {
1020
+ ...readCollisionDetails(parsed),
1021
+ status: res.status
1022
+ });
1023
+ }
846
1024
  throw new AuthError(
847
1025
  parsed.error?.code ?? "SOCIAL_LOGIN_FAILED",
848
- parsed.error?.message ?? "Social login failed"
1026
+ parsed.error?.message ?? "Social login failed",
1027
+ { status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
849
1028
  );
850
1029
  }
1030
+ if (this.user !== null) await this.wipeLocalIdentity("replaced");
851
1031
  this.persist(parsed.data);
852
1032
  return parsed.data.user;
853
1033
  });
@@ -876,26 +1056,24 @@ var Auth = class {
876
1056
  signInWithGameCenter(payloadOrOpts, maybeOpts) {
877
1057
  const path = "/api/v1/auth-service/login/game-center";
878
1058
  if (payloadOrOpts && "publicKeyUrl" in payloadOrOpts) {
879
- return this.gameIdentitySignIn(path, { ...payloadOrOpts }, maybeOpts?.link);
1059
+ return this.gameIdentitySignIn(path, { ...payloadOrOpts }, maybeOpts);
880
1060
  }
881
- const link = payloadOrOpts?.link;
882
1061
  return this.gameIdentitySignIn(
883
1062
  path,
884
1063
  async () => await fetchGameCenterPayloadNative(),
885
- link
1064
+ payloadOrOpts
886
1065
  );
887
1066
  }
888
1067
  signInWithPlayGames(payloadOrOpts, maybeOpts) {
889
1068
  const path = "/api/v1/auth-service/login/play-games";
890
1069
  if (payloadOrOpts && "serverAuthCode" in payloadOrOpts) {
891
1070
  const { serverAuthCode } = payloadOrOpts;
892
- return this.gameIdentitySignIn(path, { serverAuthCode }, maybeOpts?.link);
1071
+ return this.gameIdentitySignIn(path, { serverAuthCode }, maybeOpts);
893
1072
  }
894
- const link = payloadOrOpts?.link;
895
1073
  return this.gameIdentitySignIn(
896
1074
  path,
897
1075
  async () => await fetchPlayGamesAuthCodeNative(),
898
- link
1076
+ payloadOrOpts
899
1077
  );
900
1078
  }
901
1079
  // ============================================================
@@ -966,12 +1144,24 @@ var Auth = class {
966
1144
  try {
967
1145
  parsed = await res.json();
968
1146
  } catch {
969
- throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
1147
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
970
1148
  }
971
1149
  if (!res.ok || !parsed.success || !parsed.data) {
972
1150
  const code = parsed.error?.code ?? `HTTP_${res.status}`;
973
- if (code === "CREDENTIAL_IN_USE") throw new CredentialInUseError(parsed.error?.message);
974
- throw new AuthError(code, parsed.error?.message ?? "Failed to link credential");
1151
+ if (code === "CREDENTIAL_IN_USE") {
1152
+ throw new CredentialInUseError(parsed.error?.message, {
1153
+ ...readCollisionDetails(parsed),
1154
+ status: res.status
1155
+ });
1156
+ }
1157
+ throw new AuthError(code, parsed.error?.message ?? "Failed to link credential", {
1158
+ status: res.status,
1159
+ retryAfterSeconds: readRetryAfterSeconds(parsed)
1160
+ });
1161
+ }
1162
+ if (parsed.data.upgraded && isAuthApiResponse(parsed.data)) {
1163
+ this.persist(parsed.data);
1164
+ return parsed.data.user;
975
1165
  }
976
1166
  if (parsed.data.user) this.updateLocalUser(parsed.data.user);
977
1167
  return parsed.data.user ?? this.user;
@@ -1021,18 +1211,16 @@ var Auth = class {
1021
1211
  * `linkAnonymous` when `link` is set. `persist()` fires `onDeviceTakeover`
1022
1212
  * centrally from `retiredAnonUserId`.
1023
1213
  */
1024
- gameIdentitySignIn(path, identityBody, link) {
1214
+ gameIdentitySignIn(path, identityBody, opts) {
1215
+ const link = opts?.link;
1216
+ const onCredentialInUse = opts?.onCredentialInUse;
1025
1217
  return this.serialize(async () => {
1026
1218
  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();
1219
+ const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
1033
1220
  const payload = { ...body };
1034
1221
  if (prevAnonRefreshToken) payload.prevAnonRefreshToken = prevAnonRefreshToken;
1035
1222
  if (link) payload.linkAnonymous = true;
1223
+ if (onCredentialInUse) payload.onCredentialInUse = onCredentialInUse;
1036
1224
  const res = await post(
1037
1225
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
1038
1226
  path,
@@ -1043,14 +1231,22 @@ var Auth = class {
1043
1231
  try {
1044
1232
  parsed = await res.json();
1045
1233
  } catch {
1046
- throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
1234
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
1047
1235
  }
1048
1236
  if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
1237
+ if (parsed.error?.code === "CREDENTIAL_IN_USE") {
1238
+ throw new CredentialInUseError(parsed.error.message, {
1239
+ ...readCollisionDetails(parsed),
1240
+ status: res.status
1241
+ });
1242
+ }
1049
1243
  throw new AuthError(
1050
1244
  parsed.error?.code ?? "GAME_LOGIN_FAILED",
1051
- parsed.error?.message ?? "Game sign-in failed"
1245
+ parsed.error?.message ?? "Game sign-in failed",
1246
+ { status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
1052
1247
  );
1053
1248
  }
1249
+ if (this.user !== null) await this.wipeLocalIdentity("replaced");
1054
1250
  this.persist(parsed.data);
1055
1251
  return parsed.data.user;
1056
1252
  });
@@ -1101,10 +1297,10 @@ var Auth = class {
1101
1297
  verifyMagicLink(token) {
1102
1298
  return this.serialize(async () => {
1103
1299
  const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
1104
- if (this.user !== null) await this.wipeLocalIdentity();
1105
1300
  const body = { token };
1106
1301
  if (prevAnonRefreshToken) body.prevAnonRefreshToken = prevAnonRefreshToken;
1107
1302
  const data = await this.callAuth("/api/v1/auth-service/magic-link/verify", body);
1303
+ if (this.user !== null) await this.wipeLocalIdentity("replaced");
1108
1304
  this.persist(data);
1109
1305
  return data.user;
1110
1306
  });
@@ -1125,10 +1321,10 @@ var Auth = class {
1125
1321
  verifyEmailOtp(email, code) {
1126
1322
  return this.serialize(async () => {
1127
1323
  const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
1128
- if (this.user !== null) await this.wipeLocalIdentity();
1129
1324
  const body = { email, code };
1130
1325
  if (prevAnonRefreshToken) body.prevAnonRefreshToken = prevAnonRefreshToken;
1131
1326
  const data = await this.callAuth("/api/v1/auth-service/email-otp/verify", body);
1327
+ if (this.user !== null) await this.wipeLocalIdentity("replaced");
1132
1328
  this.persist(data);
1133
1329
  return data.user;
1134
1330
  });
@@ -1284,7 +1480,7 @@ var Auth = class {
1284
1480
  }
1285
1481
  } catch {
1286
1482
  }
1287
- await this.wipeLocalIdentity();
1483
+ await this.wipeLocalIdentity("account_deleted");
1288
1484
  return out;
1289
1485
  }
1290
1486
  // ============================================================
@@ -1352,7 +1548,6 @@ var Auth = class {
1352
1548
  */
1353
1549
  consumeSsoFragment(callbackUrl) {
1354
1550
  return this.serialize(async () => {
1355
- if (this.user !== null) await this.wipeLocalIdentity();
1356
1551
  const hashIdx = callbackUrl.indexOf("#");
1357
1552
  const fragment = hashIdx >= 0 ? callbackUrl.slice(hashIdx + 1) : "";
1358
1553
  const params = new URLSearchParams(fragment);
@@ -1366,14 +1561,13 @@ var Auth = class {
1366
1561
  "/api/v1/auth-service/token/refresh",
1367
1562
  { refreshToken: refresh }
1368
1563
  );
1369
- if (!res || !res.ok) throw new AuthError("SSO_EXCHANGE_FAILED", `HTTP ${res?.status ?? "network"}`);
1564
+ if (!res || !res.ok) throw new AuthError("SSO_EXCHANGE_FAILED", `HTTP ${res?.status ?? "network"}`, { status: res?.status });
1370
1565
  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");
1566
+ const rotated = parsed.success ? readRotatedTokens(parsed.data) : null;
1567
+ if (!rotated) {
1568
+ throw new AuthError("SSO_EXCHANGE_FAILED", "Could not exchange OIDC token", { status: res.status });
1376
1569
  }
1570
+ const { accessToken: access, refreshToken: newRefresh, expiresIn } = rotated;
1377
1571
  const claims = decodeJwtPayload(access);
1378
1572
  const user = {
1379
1573
  id: claims?.sub ?? "",
@@ -1382,6 +1576,7 @@ var Auth = class {
1382
1576
  name: claims?.name ?? null,
1383
1577
  isAnonymous: false
1384
1578
  };
1579
+ if (this.user !== null) await this.wipeLocalIdentity("replaced");
1385
1580
  this.persist({
1386
1581
  user,
1387
1582
  tokens: {
@@ -1417,7 +1612,7 @@ var Auth = class {
1417
1612
  signOut() {
1418
1613
  return this.serialize(async () => {
1419
1614
  const refresh = this.hooks.storage.get(REFRESH_KEY);
1420
- await this.wipeLocalIdentity();
1615
+ await this.wipeLocalIdentity("user");
1421
1616
  if (refresh) {
1422
1617
  void post(
1423
1618
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
@@ -1431,7 +1626,11 @@ var Auth = class {
1431
1626
  serialize(op) {
1432
1627
  const guarded = async () => {
1433
1628
  await this.readyPromise;
1434
- return op();
1629
+ try {
1630
+ return await op();
1631
+ } catch (err) {
1632
+ throw asAuthError(err);
1633
+ }
1435
1634
  };
1436
1635
  const next = this.inflight.then(guarded, guarded);
1437
1636
  this.inflight = next.catch(() => void 0);
@@ -1456,6 +1655,7 @@ var Auth = class {
1456
1655
  const refreshSnapshot = this.hooks.storage.get(REFRESH_KEY);
1457
1656
  if (!refreshSnapshot) return null;
1458
1657
  this.refreshInflight = (async () => {
1658
+ let sent = refreshSnapshot;
1459
1659
  try {
1460
1660
  const current = this.hooks.storage.get(REFRESH_KEY);
1461
1661
  if (current && current !== refreshSnapshot) {
@@ -1472,20 +1672,28 @@ var Auth = class {
1472
1672
  }
1473
1673
  }
1474
1674
  }
1675
+ sent = current ?? refreshSnapshot;
1475
1676
  const data = await this.callAuth("/api/v1/auth-service/token/refresh", {
1476
- refreshToken: current ?? refreshSnapshot
1677
+ refreshToken: sent
1477
1678
  });
1679
+ if (!this.tokenStillCurrent(sent)) return null;
1478
1680
  this.accessToken = data.tokens.accessToken;
1479
1681
  this.accessExpiresAt = Date.now() + data.tokens.expiresIn * 1e3;
1480
1682
  this.hooks.storage.set(TOKEN_KEY, data.tokens.accessToken);
1481
1683
  this.hooks.storage.set(TOKEN_EXPIRES_KEY, String(this.accessExpiresAt));
1482
1684
  this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
1685
+ if (this.user === null && isAuthApiResponse(data)) {
1686
+ this.user = data.user;
1687
+ this.hooks.storage.set(USER_KEY, JSON.stringify(data.user));
1688
+ this.hooks.onIdentityChange(data.user.id);
1689
+ this.emitAuthState({ event: "signed_in", user: data.user });
1690
+ }
1483
1691
  this.refreshFailureCount = 0;
1484
1692
  this.refreshCooldownUntil = 0;
1485
1693
  return data.tokens.accessToken;
1486
1694
  } catch (err) {
1487
- if (err instanceof AuthError && isDeadRefreshError(err.code)) {
1488
- await this.wipeLocalIdentity().catch(() => void 0);
1695
+ if (err instanceof AuthError && isDeadRefreshError(err.code) && this.tokenStillCurrent(sent)) {
1696
+ await this.wipeLocalIdentity("session_expired").catch(() => void 0);
1489
1697
  this.refreshFailureCount = 0;
1490
1698
  this.refreshCooldownUntil = 0;
1491
1699
  return null;
@@ -1599,12 +1807,13 @@ var Auth = class {
1599
1807
  try {
1600
1808
  parsed = await res.json();
1601
1809
  } catch {
1602
- throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
1810
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
1603
1811
  }
1604
1812
  if (!res.ok || !parsed.success || !parsed.data) {
1605
1813
  throw new AuthError(
1606
1814
  parsed.error?.code ?? `HTTP_${res.status}`,
1607
- parsed.error?.message ?? `HTTP ${res.status}`
1815
+ parsed.error?.message ?? `HTTP ${res.status}`,
1816
+ { status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
1608
1817
  );
1609
1818
  }
1610
1819
  const t = parsed.data.tokens;
@@ -1638,12 +1847,13 @@ var Auth = class {
1638
1847
  try {
1639
1848
  parsed = await res.json();
1640
1849
  } catch {
1641
- throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
1850
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
1642
1851
  }
1643
1852
  if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
1644
1853
  throw new AuthError(
1645
1854
  parsed.error?.code ?? `HTTP_${res.status}`,
1646
- parsed.error?.message ?? `HTTP ${res.status}`
1855
+ parsed.error?.message ?? `HTTP ${res.status}`,
1856
+ { status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
1647
1857
  );
1648
1858
  }
1649
1859
  return parsed.data;
@@ -1658,14 +1868,46 @@ var Auth = class {
1658
1868
  this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
1659
1869
  this.hooks.onIdentityChange(data.user.id);
1660
1870
  this.startProactiveRefreshCron();
1871
+ this.emitAuthState({ event: "signed_in", user: data.user });
1661
1872
  if (data.retiredAnonUserId) {
1662
1873
  this.fireDeviceTakeover(data.retiredAnonUserId, data.user.id);
1874
+ this.emitAuthState({
1875
+ event: "device_takeover",
1876
+ user: data.user,
1877
+ retiredAnonUserId: data.retiredAnonUserId
1878
+ });
1663
1879
  }
1664
1880
  if (data.reactivatedFromDeletion) {
1665
1881
  this.fireDeletionCancelled(data.user.id);
1882
+ this.emitAuthState({ event: "deletion_cancelled", user: data.user });
1666
1883
  }
1667
1884
  }
1668
- async wipeLocalIdentity() {
1885
+ /**
1886
+ * Drop the local session.
1887
+ *
1888
+ * `reason` drives the `onAuthStateChanged` emission: a real sign-out reason
1889
+ * emits `signed_out`, while `"replaced"` (the internal pre-`persist()` clear
1890
+ * on a SUCCESSFUL sign-in) emits nothing — subscribers see one `signed_in`,
1891
+ * not a spurious logout/login pair.
1892
+ *
1893
+ * ⚠ Never call this before a fallible network request. A failed sign-in must
1894
+ * leave the caller exactly as it found them; for an anonymous session the
1895
+ * refresh token dropped here is the ONLY durable handle on the account, so a
1896
+ * pre-call wipe that is not followed by a successful `persist()` orphans the
1897
+ * account permanently (offline: deterministically). Capture the anon refresh
1898
+ * into a local, make the call, and wipe only after the response validates.
1899
+ */
1900
+ /**
1901
+ * True when `sent` is still the refresh token on disk — i.e. nothing
1902
+ * replaced or cleared the session while a request carrying it was in
1903
+ * flight. Guards both directions of the refresh race: installing a rotated
1904
+ * token over a session that has since been signed out or replaced, and
1905
+ * wiping a fresh session because an OLD token was rejected.
1906
+ */
1907
+ tokenStillCurrent(sent) {
1908
+ return this.hooks.storage.get(REFRESH_KEY) === sent;
1909
+ }
1910
+ async wipeLocalIdentity(reason = "replaced") {
1669
1911
  this.user = null;
1670
1912
  this.accessToken = null;
1671
1913
  this.accessExpiresAt = 0;
@@ -1678,11 +1920,23 @@ var Auth = class {
1678
1920
  this.hooks.storage.remove(REFRESH_KEY);
1679
1921
  this.stopProactiveRefreshCron();
1680
1922
  await this.hooks.onAnonymousWipe();
1923
+ if (reason !== "replaced") {
1924
+ this.emitAuthState({ event: "signed_out", user: null, reason });
1925
+ }
1681
1926
  }
1682
1927
  };
1683
1928
  function isDeadRefreshError(code) {
1684
1929
  if (!code) return false;
1685
- return code === "INVALID_REFRESH_TOKEN" || code === "HTTP_401" || code === "UNAUTHORIZED" || code === "RATE_LIMIT_EXCEEDED" || code === "RATE_LIMIT";
1930
+ return code === "INVALID_REFRESH_TOKEN";
1931
+ }
1932
+ function readRotatedTokens(data) {
1933
+ if (!data || typeof data !== "object") return null;
1934
+ const container = data.tokens;
1935
+ const t = container && typeof container === "object" ? container : data;
1936
+ if (typeof t.accessToken !== "string" || !t.accessToken) return null;
1937
+ if (typeof t.refreshToken !== "string" || !t.refreshToken) return null;
1938
+ if (typeof t.expiresIn !== "number" || t.expiresIn <= 0) return null;
1939
+ return { accessToken: t.accessToken, refreshToken: t.refreshToken, expiresIn: t.expiresIn };
1686
1940
  }
1687
1941
  function decodeJwtPayload(token) {
1688
1942
  try {
@@ -3326,10 +3580,16 @@ var SendoraSDK = class {
3326
3580
  * Clear identity + rotate the anonymous id. Call on logout. Awaits
3327
3581
  * the AsyncStorage writes so a caller who kills the app immediately
3328
3582
  * after doesn't leave stale identity on disk.
3583
+ *
3584
+ * Signs out through `auth` rather than just clearing storage: `clearAll()`
3585
+ * removes the auth keys too, so doing it directly left `Auth` holding an
3586
+ * in-memory user and reporting itself signed in while its refresh token was
3587
+ * already gone — with the server-side session never revoked and no
3588
+ * `onAuthStateChanged` emission telling the app the session had ended.
3329
3589
  */
3330
3590
  async reset() {
3591
+ await this.auth.signOut().catch(() => void 0);
3331
3592
  this.userId = null;
3332
- this.anonId = uuid();
3333
3593
  this.sessionId = "";
3334
3594
  await this.storage.clearAll();
3335
3595
  this.storage.set(ANON_KEY, this.anonId);