@layers/amba-react-native 4.2.2 → 4.3.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
@@ -56,6 +56,20 @@ var AsyncStoragePersistence = class {
56
56
  if (v !== null) this.memoryCache.set(fullKey, v);
57
57
  return v;
58
58
  }
59
+ /**
60
+ * Read `key` from storage again, past the in-memory copy, and keep what
61
+ * it holds now. For a record whose first read came back incomplete (see
62
+ * `IdentityStore.recordStillUnreadable`).
63
+ */
64
+ async reread(key) {
65
+ const fullKey = `${this.prefix}:${key}`;
66
+ const storage = await getStorage();
67
+ if (!storage) return this.memoryCache.get(fullKey) ?? null;
68
+ const v = await storage.getItem(fullKey);
69
+ if (v !== null) this.memoryCache.set(fullKey, v);
70
+ else this.memoryCache.delete(fullKey);
71
+ return v;
72
+ }
59
73
  async write(key, value) {
60
74
  const fullKey = `${this.prefix}:${key}`;
61
75
  const storage = await getStorage();
@@ -607,11 +621,42 @@ var IdentityStore = class IdentityStore {
607
621
  installId;
608
622
  /** Whether the stored record carries `installId`. */
609
623
  installIdStored;
610
- constructor(persistence, seed, vault, plaintext, vaultUnreadable, seq) {
624
+ /** See `IdentitySnapshot.signInRequired`. */
625
+ signInRequired;
626
+ /**
627
+ * Advanced by every committed write that installs or ends a session (a
628
+ * sign-in, a sign-out, an ended session). A refresh or a link keeps it.
629
+ */
630
+ generation = 0;
631
+ /** Set by `dispose`: the store commits and persists nothing after it. */
632
+ disposed = false;
633
+ /**
634
+ * Set while the record's credential part could not be read (see
635
+ * `PersistedIdentity.credentials_unreadable`). Writes wait for a read
636
+ * that succeeds, so a session this launch never loaded is not replaced.
637
+ */
638
+ recordUnreadable;
639
+ /**
640
+ * Set while the anonymous id is a stand-in for one the unreadable
641
+ * credential part holds. It is never persisted.
642
+ */
643
+ temporaryAnonymousId;
644
+ /** See `onStoredSessionLoaded`. */
645
+ storedSessionLoaded = null;
646
+ /** See `onAnonymousIdKnown`. */
647
+ anonymousIdKnown = null;
648
+ /** The refresh token the store held once created (see `continuesLaunchSession`). */
649
+ launchRefreshToken = null;
650
+ /** Sign-ins committed since the store was created. */
651
+ signInsSinceLaunch = 0;
652
+ constructor(persistence, seed, vault, plaintext, vaultUnreadable, seq, recordUnreadable, temporaryAnonymousId) {
611
653
  this.persistence = persistence;
612
654
  this.vault = vault;
613
655
  this.plaintext = plaintext;
614
656
  this.vaultUnreadable = vaultUnreadable;
657
+ this.recordUnreadable = recordUnreadable;
658
+ this.temporaryAnonymousId = temporaryAnonymousId;
659
+ this.signInRequired = seed.sign_in_required;
615
660
  this.seq = seq;
616
661
  this.installId = seed.install_id ?? uuidv4();
617
662
  this.installIdStored = seed.install_id !== null;
@@ -647,21 +692,17 @@ var IdentityStore = class IdentityStore {
647
692
  assertSecureRandomAvailable();
648
693
  const raw = await persistence.read(IDENTITY_KEY);
649
694
  let recordPresent = false;
695
+ let recordUnreadable = false;
696
+ let temporaryAnonymousId = false;
650
697
  let seed;
651
698
  if (raw) try {
652
699
  const parsed = JSON.parse(raw);
653
- seed = {
654
- anonymous_id: parsed.anonymous_id ?? uuidv4(),
655
- app_user_id: parsed.app_user_id ?? null,
656
- session_token: parsed.session_token ?? null,
657
- refresh_token: parsed.refresh_token ?? null,
658
- expires_at: parsed.expires_at ?? null,
659
- anonymous_session: typeof parsed.anonymous_session === "boolean" ? parsed.anonymous_session : null,
660
- write_seq: seqOf(parsed.write_seq) ?? 0,
661
- vault_seq: parsed.vault_seq === void 0 ? 0 : seqOf(parsed.vault_seq),
662
- install_id: typeof parsed.install_id === "string" ? parsed.install_id : null
663
- };
700
+ seed = seedFrom(parsed);
664
701
  recordPresent = typeof parsed.anonymous_id === "string";
702
+ if (parsed.credentials_unreadable === true) {
703
+ recordUnreadable = true;
704
+ temporaryAnonymousId = !recordPresent;
705
+ }
665
706
  } catch {
666
707
  seed = freshSeed();
667
708
  }
@@ -676,7 +717,8 @@ var IdentityStore = class IdentityStore {
676
717
  } : null;
677
718
  let vaultReadable = true;
678
719
  let seq = seed.write_seq;
679
- if (vault) {
720
+ if (vault && recordUnreadable) vaultReadable = false;
721
+ else if (vault) {
680
722
  let fromVault = null;
681
723
  try {
682
724
  fromVault = await vault.read();
@@ -695,17 +737,10 @@ var IdentityStore = class IdentityStore {
695
737
  seed.vault_seq = null;
696
738
  }
697
739
  }
698
- if (vaultReadable && seed.session_token === null && seed.refresh_token === null) {
699
- if (seed.app_user_id !== null && seed.anonymous_session === false) seed.anonymous_id = uuidv4();
700
- seed.app_user_id = null;
701
- seed.anonymous_session = null;
702
- }
703
- if (isExpired(seed.expires_at)) {
704
- seed.session_token = null;
705
- seed.expires_at = null;
706
- }
707
- const store = new IdentityStore(persistence, seed, vault, plaintext, !vaultReadable, seq);
740
+ settleSeed(seed, vaultReadable && !recordUnreadable);
741
+ const store = new IdentityStore(persistence, seed, vault, plaintext, !vaultReadable, seq, recordUnreadable, temporaryAnonymousId);
708
742
  await store.flush({ hydrating: true });
743
+ store.launchRefreshToken = store.refreshToken;
709
744
  return store;
710
745
  }
711
746
  snapshot() {
@@ -715,9 +750,184 @@ var IdentityStore = class IdentityStore {
715
750
  sessionToken: this.sessionToken,
716
751
  refreshToken: this.refreshToken,
717
752
  expiresAt: this.expiresAt,
718
- anonymousSession: this.anonymousSession
753
+ anonymousSession: this.anonymousSession,
754
+ signInRequired: this.signInRequired
719
755
  };
720
756
  }
757
+ /** The current generation (see `CommitFence`). */
758
+ getGeneration() {
759
+ return this.generation;
760
+ }
761
+ /**
762
+ * Whether the store still holds the session it was created with, renewed
763
+ * or ended since but never replaced by a sign-in: it was created holding
764
+ * `refreshToken`, and no sign-in has committed since.
765
+ */
766
+ continuesLaunchSession(refreshToken) {
767
+ return this.launchRefreshToken === refreshToken && this.signInsSinceLaunch === 0;
768
+ }
769
+ /** See `IdentitySnapshot.signInRequired`. */
770
+ isSignInRequired() {
771
+ return this.signInRequired;
772
+ }
773
+ /**
774
+ * Detach the store (the SDK was configured again). Nothing commits or
775
+ * persists after this; the promise settles once the writes already under
776
+ * way have landed.
777
+ */
778
+ dispose() {
779
+ this.disposed = true;
780
+ return this.flushChain;
781
+ }
782
+ /** Undo `dispose` (a `configure()` that failed before it replaced this store). */
783
+ reattach() {
784
+ this.disposed = false;
785
+ }
786
+ /** Whether a write under `fence` may commit now. */
787
+ mayCommit(fence) {
788
+ if (this.disposed) return false;
789
+ return fence.ifGeneration === void 0 || fence.ifGeneration === this.generation;
790
+ }
791
+ /** Commit bookkeeping: advance the generation unless the fence keeps it. */
792
+ committed(fence) {
793
+ if (fence.advance !== false) this.generation += 1;
794
+ return this.generation;
795
+ }
796
+ /**
797
+ * Called when a stored session that couldn't be read at launch (a locked
798
+ * vault or credential record) is loaded after it, so the SDK can tell its
799
+ * subscribers. A session loaded while the store is created is not
800
+ * reported.
801
+ */
802
+ onStoredSessionLoaded(listener) {
803
+ this.storedSessionLoaded = listener;
804
+ }
805
+ /**
806
+ * Called when the stand-in anonymous id gives way to the device's own (see
807
+ * `temporaryAnonymousId`): a later read found it, or a sign-in gave one.
808
+ * A sign-out that rotates the id is not reported: it starts over.
809
+ */
810
+ onAnonymousIdKnown(listener) {
811
+ this.anonymousIdKnown = listener;
812
+ }
813
+ /** Whether the anonymous id is a stand-in (see `temporaryAnonymousId`). */
814
+ hasStandInAnonymousId() {
815
+ return this.temporaryAnonymousId;
816
+ }
817
+ /** The stand-in gives way to a known anonymous id (see `onAnonymousIdKnown`). */
818
+ anonymousIdNowKnown() {
819
+ if (!this.temporaryAnonymousId) return;
820
+ this.temporaryAnonymousId = false;
821
+ if (!this.disposed) this.anonymousIdKnown?.();
822
+ }
823
+ /** Report a session a late read loaded (see `onStoredSessionLoaded`). */
824
+ reportStoredSession() {
825
+ if (this.signInsSinceLaunch === 0 && this.launchRefreshToken === null) this.launchRefreshToken = this.refreshToken;
826
+ if (this.disposed) return;
827
+ if (this.sessionToken === null && this.refreshToken === null) return;
828
+ this.storedSessionLoaded?.();
829
+ }
830
+ /**
831
+ * Whether the record's credential part still can't be read (see
832
+ * `PersistedIdentity.credentials_unreadable`). When the launch read
833
+ * failed, the record is read again first. A session it holds is loaded
834
+ * when nothing has replaced the launch state since; the stored anonymous
835
+ * id replaces the stand-in either way.
836
+ */
837
+ async recordStillUnreadable() {
838
+ if (!this.recordUnreadable) return false;
839
+ let parsed = null;
840
+ try {
841
+ const raw = await this.persistence.reread(IDENTITY_KEY);
842
+ parsed = raw ? JSON.parse(raw) : null;
843
+ } catch {
844
+ return true;
845
+ }
846
+ if (!this.recordUnreadable) return false;
847
+ if (parsed?.credentials_unreadable === true) return true;
848
+ this.recordUnreadable = false;
849
+ const storedAnonymousId = parsed && typeof parsed.anonymous_id === "string" ? parsed.anonymous_id : null;
850
+ const untouched = this.generation === 0 && this.sessionToken === null && this.refreshToken === null;
851
+ if (parsed && storedAnonymousId !== null && untouched) {
852
+ if (!this.vault) {
853
+ const stored$1 = seedFrom(parsed);
854
+ settleSeed(stored$1, true);
855
+ this.anonymousId = stored$1.anonymous_id;
856
+ this.appUserId = stored$1.app_user_id;
857
+ this.sessionToken = stored$1.session_token;
858
+ this.refreshToken = stored$1.refresh_token;
859
+ this.expiresAt = stored$1.expires_at;
860
+ this.anonymousSession = stored$1.anonymous_session;
861
+ this.signInRequired = stored$1.sign_in_required;
862
+ this.seq = Math.max(this.seq, stored$1.write_seq);
863
+ this.reportStoredSession();
864
+ this.anonymousIdNowKnown();
865
+ return false;
866
+ }
867
+ const stored = seedFrom(parsed);
868
+ this.anonymousId = stored.anonymous_id;
869
+ const wasStandIn = this.temporaryAnonymousId;
870
+ this.temporaryAnonymousId = false;
871
+ this.appUserId = stored.app_user_id;
872
+ this.anonymousSession = stored.anonymous_session;
873
+ this.signInRequired = stored.sign_in_required;
874
+ this.recordSeq = stored.write_seq;
875
+ this.vaultSeq = stored.vault_seq;
876
+ this.seq = Math.max(this.seq, stored.write_seq);
877
+ await this.vaultStillUnreadable();
878
+ if (this.sessionToken === null && this.refreshToken === null && (stored.session_token !== null || stored.refresh_token !== null)) {
879
+ this.plaintext = {
880
+ appUserId: stored.app_user_id,
881
+ slice: {
882
+ session_token: stored.session_token,
883
+ refresh_token: stored.refresh_token,
884
+ expires_at: stored.expires_at
885
+ }
886
+ };
887
+ const expired = isExpired(stored.expires_at);
888
+ this.sessionToken = expired ? null : stored.session_token;
889
+ this.refreshToken = stored.refresh_token;
890
+ this.expiresAt = expired ? null : stored.expires_at;
891
+ this.reportStoredSession();
892
+ }
893
+ if (!this.vaultUnreadable && this.sessionToken === null && this.refreshToken === null) {
894
+ if (this.appUserId !== null && this.anonymousSession === false) this.anonymousId = uuidv4();
895
+ this.appUserId = null;
896
+ this.anonymousSession = null;
897
+ }
898
+ if (wasStandIn && !this.disposed) this.anonymousIdKnown?.();
899
+ return false;
900
+ }
901
+ if (this.temporaryAnonymousId && storedAnonymousId !== null) this.anonymousId = storedAnonymousId;
902
+ this.anonymousIdNowKnown();
903
+ return false;
904
+ }
905
+ /**
906
+ * Whether the stored session can't be read right now: the record's
907
+ * credential part (see `recordStillUnreadable`) or the vault (see
908
+ * `vaultStillUnreadable`). The record is read first: it binds the entry.
909
+ */
910
+ async credentialsStillUnreadable() {
911
+ if (await this.recordStillUnreadable()) return true;
912
+ return this.vaultStillUnreadable();
913
+ }
914
+ /**
915
+ * Whether the stored credentials were unreadable at the last attempt. A
916
+ * call that needs a session reports `SECURE_STORE_UNAVAILABLE` then.
917
+ */
918
+ credentialsUnknown() {
919
+ return this.recordUnreadable || this.vaultUnreadable;
920
+ }
921
+ /**
922
+ * Whether the anonymous id is still a stand-in after the record is read
923
+ * again (see `temporaryAnonymousId`). Anything sent under it would belong
924
+ * to nobody.
925
+ */
926
+ async anonymousIdStillUnknown() {
927
+ if (!this.temporaryAnonymousId) return false;
928
+ await this.recordStillUnreadable();
929
+ return this.temporaryAnonymousId;
930
+ }
721
931
  /**
722
932
  * Whether the vault still can't be read. When the launch read failed, the
723
933
  * vault is read again first, and a session it holds for this install is
@@ -726,6 +936,7 @@ var IdentityStore = class IdentityStore {
726
936
  */
727
937
  async vaultStillUnreadable() {
728
938
  if (!this.vault || !this.vaultUnreadable) return false;
939
+ if (this.recordUnreadable) return true;
729
940
  let stored;
730
941
  try {
731
942
  stored = await this.vault.read();
@@ -734,7 +945,10 @@ var IdentityStore = class IdentityStore {
734
945
  }
735
946
  this.vaultUnreadable = false;
736
947
  this.noteStoredSeq(stored);
737
- if (this.sessionToken === null && this.refreshToken === null) this.adoptStored(stored);
948
+ if (this.sessionToken === null && this.refreshToken === null) {
949
+ this.adoptStored(stored);
950
+ this.reportStoredSession();
951
+ }
738
952
  return false;
739
953
  }
740
954
  /** Keep the next sequence number above an entry read from the vault. */
@@ -771,30 +985,51 @@ var IdentityStore = class IdentityStore {
771
985
  /**
772
986
  * Store a session. `anonymous` records whether it is a purely anonymous
773
987
  * one (true only for `/v1/client/auth/anonymous`); omit it to keep the
774
- * current value (a token refresh of the same session).
988
+ * current value (a token refresh of the same session). A stored session
989
+ * ends a pending `signInRequired`.
990
+ *
991
+ * Resolves the generation the write committed at, or null when the fence
992
+ * refused it (nothing changed).
775
993
  */
776
- async setSession(args) {
994
+ async setSession(args, fence = {}) {
995
+ if (!this.mayCommit(fence)) return null;
777
996
  this.appUserId = args.appUserId;
778
997
  this.sessionToken = args.sessionToken;
779
998
  this.refreshToken = args.refreshToken;
780
999
  this.expiresAt = args.expiresAt;
781
1000
  if (args.anonymous !== void 0) this.anonymousSession = args.anonymous;
1001
+ this.signInRequired = false;
1002
+ if (fence.advance !== false) this.signInsSinceLaunch += 1;
1003
+ const generation = this.committed(fence);
782
1004
  await this.flush();
1005
+ return generation;
783
1006
  }
784
- /** The current session gained an identity (a link). */
785
- async markIdentified() {
786
- if (this.anonymousSession === false) return;
1007
+ /**
1008
+ * The current session gained an identity (a link). False when the fence
1009
+ * refused the write.
1010
+ */
1011
+ async markIdentified(fence = {}) {
1012
+ if (!this.mayCommit(fence)) return false;
1013
+ if (this.anonymousSession === false) return true;
787
1014
  this.anonymousSession = false;
788
1015
  await this.flush();
1016
+ return true;
789
1017
  }
790
1018
  /**
791
1019
  * Adopt the anonymous id the server holds for this install's user: the id
792
1020
  * an anonymous sign-in answered with, or the fresh id a link gave the user.
1021
+ * False when the fence refused the write.
793
1022
  */
794
- async setAnonymousId(anonymousId) {
795
- if (anonymousId === this.anonymousId) return;
796
- this.anonymousId = anonymousId;
797
- await this.flush();
1023
+ async setAnonymousId(anonymousId, fence = {}) {
1024
+ if (!this.mayCommit(fence)) return false;
1025
+ if (anonymousId !== this.anonymousId) {
1026
+ this.anonymousId = anonymousId;
1027
+ this.anonymousIdNowKnown();
1028
+ await this.flush();
1029
+ return true;
1030
+ }
1031
+ this.anonymousIdNowKnown();
1032
+ return true;
798
1033
  }
799
1034
  /**
800
1035
  * End the session. The anonymous id is rotated when `rotateAnonymousId`
@@ -806,16 +1041,24 @@ var IdentityStore = class IdentityStore {
806
1041
  * anonymous sign-in).
807
1042
  */
808
1043
  async clearSession(rotateAnonymousId, opts = {}) {
1044
+ if (!this.mayCommit({ ifGeneration: opts.ifGeneration })) return false;
809
1045
  const hadSession = this.sessionToken !== null || this.refreshToken !== null;
1046
+ const unreadSession = this.credentialsUnknown() && this.appUserId !== null;
1047
+ if (opts.unlessSignedInSince !== void 0 && opts.unlessSignedInSince !== this.generation && hadSession) return false;
810
1048
  const identified = this.anonymousSession === false || this.anonymousSession === null && !opts.keepIdWhenKindUnknown;
811
- const nextAnonymousId = rotateAnonymousId || hadSession && identified ? uuidv4() : this.anonymousId;
1049
+ const rotate = rotateAnonymousId || (hadSession || unreadSession) && identified;
1050
+ const nextAnonymousId = rotate ? uuidv4() : this.anonymousId;
1051
+ if (rotate) this.temporaryAnonymousId = false;
812
1052
  this.appUserId = null;
813
1053
  this.sessionToken = null;
814
1054
  this.refreshToken = null;
815
1055
  this.expiresAt = null;
816
1056
  this.anonymousSession = null;
1057
+ this.signInRequired = opts.signInRequired === true;
817
1058
  this.anonymousId = nextAnonymousId;
1059
+ this.committed({});
818
1060
  await this.flush({ signingOut: true });
1061
+ return true;
819
1062
  }
820
1063
  /**
821
1064
  * Persist the identity. `hydrating` marks the write that follows `create`:
@@ -825,11 +1068,13 @@ var IdentityStore = class IdentityStore {
825
1068
  * to a new anonymous id so the entry left behind no longer belongs to it.
826
1069
  */
827
1070
  flush(opts) {
1071
+ if (this.disposed) return Promise.resolve();
828
1072
  const run = this.flushChain.then(() => this.flushNow(opts));
829
1073
  this.flushChain = run.catch(() => void 0);
830
1074
  return run;
831
1075
  }
832
1076
  async flushNow(opts) {
1077
+ if (await this.recordStillUnreadable() && this.generation === 0) return;
833
1078
  if (!this.vault) {
834
1079
  const seq$1 = ++this.seq;
835
1080
  const recordSeq$1 = opts?.hydrating ? this.recordSeq : seq$1;
@@ -840,9 +1085,11 @@ var IdentityStore = class IdentityStore {
840
1085
  refresh_token: this.refreshToken,
841
1086
  expires_at: this.expiresAt,
842
1087
  anonymous_session: this.anonymousSession,
1088
+ sign_in_required: this.signInRequired,
843
1089
  write_seq: recordSeq$1,
844
1090
  vault_seq: null,
845
- install_id: this.installId
1091
+ install_id: this.installId,
1092
+ ...this.temporaryAnonymousId ? { anonymous_id_unknown: true } : {}
846
1093
  };
847
1094
  await this.persistence.write(IDENTITY_KEY, JSON.stringify(payload));
848
1095
  this.recordSeq = recordSeq$1;
@@ -921,9 +1168,11 @@ var IdentityStore = class IdentityStore {
921
1168
  refresh_token: keep?.refresh_token ?? null,
922
1169
  expires_at: keep?.expires_at ?? null,
923
1170
  anonymous_session: this.anonymousSession,
1171
+ sign_in_required: this.signInRequired,
924
1172
  write_seq: recordSeq,
925
1173
  vault_seq: this.vaultSeq,
926
- install_id: this.installId
1174
+ install_id: this.installId,
1175
+ ...this.temporaryAnonymousId ? { anonymous_id_unknown: true } : {}
927
1176
  };
928
1177
  }
929
1178
  };
@@ -935,11 +1184,47 @@ function freshSeed() {
935
1184
  refresh_token: null,
936
1185
  expires_at: null,
937
1186
  anonymous_session: null,
1187
+ sign_in_required: false,
938
1188
  write_seq: 0,
939
1189
  vault_seq: null,
940
1190
  install_id: null
941
1191
  };
942
1192
  }
1193
+ /** The identity a parsed record describes, with defaults for missing fields. */
1194
+ function seedFrom(parsed) {
1195
+ return {
1196
+ anonymous_id: parsed.anonymous_id ?? uuidv4(),
1197
+ app_user_id: parsed.app_user_id ?? null,
1198
+ session_token: parsed.session_token ?? null,
1199
+ refresh_token: parsed.refresh_token ?? null,
1200
+ expires_at: parsed.expires_at ?? null,
1201
+ anonymous_session: typeof parsed.anonymous_session === "boolean" ? parsed.anonymous_session : null,
1202
+ sign_in_required: parsed.sign_in_required === true,
1203
+ write_seq: seqOf(parsed.write_seq) ?? 0,
1204
+ vault_seq: parsed.vault_seq === void 0 ? 0 : seqOf(parsed.vault_seq),
1205
+ install_id: typeof parsed.install_id === "string" ? parsed.install_id : null
1206
+ };
1207
+ }
1208
+ /**
1209
+ * Settle a hydrated identity. With the credentials known (`readable`), a
1210
+ * user id with no token to act on is stale (memory storage after a
1211
+ * relaunch, a vault entry that was discarded): nobody is signed in, and the
1212
+ * session kind goes with it. A session that ended this way after it gained
1213
+ * an identity rotates the anonymous id, as `clearSession` would. An expired
1214
+ * session token is dropped; its refresh token stays so the session can be
1215
+ * renewed.
1216
+ */
1217
+ function settleSeed(seed, readable) {
1218
+ if (readable && seed.session_token === null && seed.refresh_token === null) {
1219
+ if (seed.app_user_id !== null && seed.anonymous_session === false) seed.anonymous_id = uuidv4();
1220
+ seed.app_user_id = null;
1221
+ seed.anonymous_session = null;
1222
+ }
1223
+ if (isExpired(seed.expires_at)) {
1224
+ seed.session_token = null;
1225
+ seed.expires_at = null;
1226
+ }
1227
+ }
943
1228
 
944
1229
  //#endregion
945
1230
  //#region src/token-storage.ts
@@ -1632,14 +1917,18 @@ var EventBatchQueue = class {
1632
1917
  if (this.events.length) await this.store.set(this.storageKey, JSON.stringify(this.events));
1633
1918
  else await this.store.delete(this.storageKey);
1634
1919
  }
1635
- enqueue(event, properties, telemetry) {
1920
+ /**
1921
+ * Queue an event. `timestamp` is when it happened, for an event that waited
1922
+ * elsewhere before reaching the queue; it defaults to now.
1923
+ */
1924
+ enqueue(event, properties, telemetry, timestamp) {
1636
1925
  if (this.disposed) return Promise.reject(/* @__PURE__ */ new Error("Amba event queue disposed"));
1637
1926
  const id = this.identity();
1638
1927
  const item = {
1639
1928
  event,
1640
1929
  properties,
1641
1930
  telemetry,
1642
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1931
+ timestamp: timestamp ?? (/* @__PURE__ */ new Date()).toISOString(),
1643
1932
  idempotency_key: newIdempotencyKey(),
1644
1933
  userId: id.sessionToken ? id.userId : null,
1645
1934
  anonymousId: id.anonymousId
@@ -2774,7 +3063,9 @@ function delay(ms) {
2774
3063
  //#endregion
2775
3064
  //#region src/index.ts
2776
3065
  /** SDK semver — burned in at build time. Bump alongside package.json. */
2777
- const SDK_VERSION = "4.2.2";
3066
+ const SDK_VERSION = "4.3.0";
3067
+ /** The most events held at once; the oldest goes first past it. */
3068
+ const MAX_HELD_EVENTS = 500;
2778
3069
  /**
2779
3070
  * Default host that serves deployed functions. `Amba.functions.invoke(name)`
2780
3071
  * targets `https://<projectId>.<DEFAULT_FUNCTIONS_HOST>/<name>` unless a
@@ -2791,6 +3082,23 @@ let state = null;
2791
3082
  * instead of disabling the now-active outbox.
2792
3083
  */
2793
3084
  let _offlineEnableToken = 0;
3085
+ /** The `configure()` running now, if any (see `liveState`). */
3086
+ let configuring = null;
3087
+ /** Serializes `configure()` calls, so each detaches the one before it. */
3088
+ let configureChain = Promise.resolve();
3089
+ /**
3090
+ * The state calls act on. While a `configure()` is running, waits for it,
3091
+ * so a sign-in, a link or a sign-out made meanwhile lands on the state it
3092
+ * installs (or on the earlier one, when it fails).
3093
+ */
3094
+ async function liveState() {
3095
+ while (configuring) await configuring.catch(() => void 0);
3096
+ return getState();
3097
+ }
3098
+ /** Whether two states talk to the same project with the same stored identity. */
3099
+ function sameIdentityTarget(a, b) {
3100
+ return a.projectId === b.projectId && a.clientKey === b.clientKey && a.baseUrl === b.baseUrl && a.persistence.prefix === b.persistence.prefix;
3101
+ }
2794
3102
  function getState() {
2795
3103
  if (!state) throw new AmbaApiError("NOT_INITIALIZED", "@layers/amba-react-native: SDK not configured. Call Amba.configure({ projectId, clientKey }) at app start.");
2796
3104
  return state;
@@ -2883,10 +3191,16 @@ function snapshotSession(user) {
2883
3191
  expiresAt: ""
2884
3192
  };
2885
3193
  }
2886
- function notifyAuthSubscribers(session) {
3194
+ /**
3195
+ * Tell `onAuthStateChange` subscribers about an auth change of `state_`.
3196
+ * A state a later `configure()` replaced tells no one.
3197
+ */
3198
+ function notifyAuthSubscribers(state_, session, reason) {
3199
+ if (state_ !== state || state_.disposed) return;
2887
3200
  if (session === null) experimentReporter.reset();
3201
+ const event = { reason };
2888
3202
  for (const cb of authSubscribers) try {
2889
- cb(session);
3203
+ cb(session, event);
2890
3204
  } catch (err) {
2891
3205
  if (typeof console !== "undefined") console.error("[amba] onAuthStateChange callback threw", err);
2892
3206
  }
@@ -2943,21 +3257,50 @@ function normalizeAuthResult(result) {
2943
3257
  user: normalizeUserAuthProviders(result.user)
2944
3258
  };
2945
3259
  }
2946
- async function notifyFromAuthResult(result) {
2947
- notifyAuthSubscribers(snapshotSession(result.user));
3260
+ function notifyFromAuthResult(state_, result, reason) {
3261
+ notifyAuthSubscribers(state_, snapshotSession(result.user), reason);
3262
+ }
3263
+ /** A call needs a session while the user must sign in again. */
3264
+ function signInRequiredError() {
3265
+ return new AmbaApiError("SIGN_IN_REQUIRED", "the session ended and the user must sign in again; call a signIn* method, or Amba.auth.signInAnonymously() to start a new anonymous user");
3266
+ }
3267
+ function secureStoreUnavailableError() {
3268
+ return new AmbaApiError("SECURE_STORE_UNAVAILABLE", "the stored session can't be read yet (the device may be locked); retry once it is unlocked");
3269
+ }
3270
+ function sessionChangedError(message) {
3271
+ return new AmbaApiError("SESSION_CHANGED", message);
3272
+ }
3273
+ function isSessionChanged(err) {
3274
+ return err instanceof AmbaApiError && err.code === "SESSION_CHANGED";
3275
+ }
3276
+ /**
3277
+ * The stored session token, for a call that needs one. Without a session
3278
+ * the call fails with `SIGN_IN_REQUIRED` while the user must sign in again,
3279
+ * and with `UNAUTHORIZED` otherwise.
3280
+ */
3281
+ function requireSession(state_) {
3282
+ const token = state_.identity.getSessionToken();
3283
+ if (token) return token;
3284
+ if (state_.identity.isSignInRequired()) throw signInRequiredError();
3285
+ if (state_.identity.credentialsUnknown()) throw secureStoreUnavailableError();
3286
+ throw new AmbaApiError("UNAUTHORIZED", "no active session — call signIn* first");
2948
3287
  }
2949
3288
  function expiresAtFromResult(result) {
2950
3289
  if (result.expires_at && typeof result.expires_at === "string") return result.expires_at;
2951
3290
  return new Date(Date.now() + 3600 * 1e3).toISOString();
2952
3291
  }
2953
- async function persistAuthResult(state_, result, anonymous) {
2954
- await state_.identity.setSession({
3292
+ /**
3293
+ * Store an auth result under `fence`. Resolves the generation it committed
3294
+ * at, or null when the fence refused it.
3295
+ */
3296
+ function persistAuthResult(state_, result, anonymous, fence = {}) {
3297
+ return state_.identity.setSession({
2955
3298
  appUserId: result.user.id,
2956
3299
  sessionToken: result.session_token,
2957
3300
  refreshToken: result.refresh_token,
2958
3301
  expiresAt: expiresAtFromResult(result),
2959
3302
  anonymous
2960
- });
3303
+ }, fence);
2961
3304
  }
2962
3305
  function nonEmptyString(value) {
2963
3306
  return typeof value === "string" && value.length > 0 ? value : null;
@@ -2969,14 +3312,31 @@ function nonEmptyString(value) {
2969
3312
  * request carried none). A link adopts the account's rotated anonymous id.
2970
3313
  * A refresh keeps the session's kind; every other sign-in records an
2971
3314
  * identified session.
3315
+ *
3316
+ * A sign-in the app asked for stores its session whatever happened
3317
+ * meanwhile: it is the newest thing the app asked for. The automatic
3318
+ * anonymous sign-in and a link pass a `fence` and fail with
3319
+ * `SESSION_CHANGED` (storing nothing) when the session changed while they
3320
+ * were in flight.
2972
3321
  */
2973
- async function postAuth(state_, path, body) {
3322
+ async function postAuth(state_, path, body, fence = {}) {
2974
3323
  const result = normalizeAuthResult((await state_.http.post(path, body)).data);
2975
- await persistAuthResult(state_, result, path === P.authAnonymous ? true : path === P.authRefresh ? void 0 : false);
3324
+ const anonymous = path === P.authAnonymous ? true : path === P.authRefresh ? void 0 : false;
3325
+ let target = state_;
3326
+ let committedAt = await persistAuthResult(state_, result, anonymous, fence);
3327
+ if (committedAt === null && fence.ifGeneration === void 0 && state_.disposed) {
3328
+ const next = await liveState();
3329
+ if (next === state_ ? !next.disposed : sameIdentityTarget(next, state_)) {
3330
+ target = next;
3331
+ committedAt = await persistAuthResult(next, result, anonymous);
3332
+ }
3333
+ }
3334
+ if (committedAt === null) throw sessionChangedError("the session changed while this sign-in was in flight; the newer session stays");
3335
+ state_ = target;
2976
3336
  const adoptedAnonymousId = path === P.authAnonymous ? nonEmptyString(result.anonymous_id) ?? nonEmptyString(result.user?.anonymous_id) : path === P.authLink ? nonEmptyString(result.user?.anonymous_id) : null;
2977
- if (adoptedAnonymousId) await adoptAnonymousId(state_, adoptedAnonymousId);
3337
+ if (adoptedAnonymousId) await adoptAnonymousId(state_, adoptedAnonymousId, committedAt);
2978
3338
  state_.eventQueue?.flush();
2979
- await notifyFromAuthResult(result);
3339
+ notifyFromAuthResult(state_, result, "signed_in");
2980
3340
  return result;
2981
3341
  }
2982
3342
  /**
@@ -2985,16 +3345,86 @@ async function postAuth(state_, path, body) {
2985
3345
  * session; they are handed to the signed-in user first, so the id change
2986
3346
  * leaves them replayable.
2987
3347
  */
2988
- async function adoptAnonymousId(state_, anonymousId) {
3348
+ async function adoptAnonymousId(state_, anonymousId, generation) {
2989
3349
  const previous = state_.identity.getAnonymousId();
2990
3350
  if (anonymousId === previous) return;
3351
+ if (state_.identity.getGeneration() !== generation) return;
2991
3352
  const appUserId = state_.identity.getAppUserId();
2992
3353
  if (appUserId && state_.offlineEnabled) try {
2993
3354
  await getOfflineQueue(state_).claimAnonymousOps(previous, appUserId);
2994
3355
  } catch (err) {
2995
3356
  if (typeof console !== "undefined") console.warn("[amba] could not reassign queued offline writes", err);
2996
3357
  }
2997
- await state_.identity.setAnonymousId(anonymousId);
3358
+ await state_.identity.setAnonymousId(anonymousId, { ifGeneration: generation });
3359
+ }
3360
+ /**
3361
+ * Hold an event `track()` got while the stored identity couldn't be read.
3362
+ * It keeps its call time and a copy of its properties; past
3363
+ * `MAX_HELD_EVENTS` the oldest is dropped.
3364
+ */
3365
+ function holdEvent(state_, event, properties, options) {
3366
+ const held = state_.heldEvents ??= [];
3367
+ if (held.length >= MAX_HELD_EVENTS) {
3368
+ held.shift();
3369
+ state_.heldEventsDropped = (state_.heldEventsDropped ?? 0) + 1;
3370
+ }
3371
+ held.push({
3372
+ event,
3373
+ properties: snapshotProperties(properties),
3374
+ options,
3375
+ occurredAt: (/* @__PURE__ */ new Date()).toISOString(),
3376
+ generation: state_.identity.getGeneration()
3377
+ });
3378
+ }
3379
+ /** Report the held events dropped for `MAX_HELD_EVENTS`, once. */
3380
+ function reportHeldEventsDropped(state_) {
3381
+ const dropped = state_.heldEventsDropped ?? 0;
3382
+ state_.heldEventsDropped = 0;
3383
+ if (dropped > 0 && typeof console !== "undefined") console.warn(`[amba] dropped ${dropped} event(s) tracked while the secure store was locked: more than ${MAX_HELD_EVENTS} were waiting`);
3384
+ }
3385
+ /** Drop the held events, with one warning that says why. */
3386
+ function dropHeldEvents(state_, why) {
3387
+ const count = (state_.heldEvents ?? []).length + (state_.heldEventsDropped ?? 0);
3388
+ state_.heldEvents = [];
3389
+ state_.heldEventsDropped = 0;
3390
+ if (count > 0 && typeof console !== "undefined") console.warn(`[amba] dropped ${count} event(s) tracked while the secure store was locked: ${why}`);
3391
+ }
3392
+ /**
3393
+ * Send the held events once the stored identity is known, oldest first,
3394
+ * through the path `track()` uses, under the identity that loaded. A session
3395
+ * renewal the load started finishes first. An event that fails is reported
3396
+ * and the rest go on; events tracked meanwhile join the end of the line.
3397
+ * An event goes out only while the session it belongs to is still the
3398
+ * stored one: the session that loaded for events held before, the session
3399
+ * of the moment for events tracked during the release. When the session
3400
+ * changed since (the renewal is refused and the session ends, a sign-in or
3401
+ * sign-out lands), the event is dropped, and one warning counts them.
3402
+ */
3403
+ async function releaseHeldEvents(state_) {
3404
+ if (state_.releasingHeldEvents || !state_.heldEvents?.length) return;
3405
+ state_.releasingHeldEvents = true;
3406
+ let stale = 0;
3407
+ try {
3408
+ const loadedAt = state_.identity.getGeneration();
3409
+ await settleSessionInFlight(state_);
3410
+ reportHeldEventsDropped(state_);
3411
+ while (state_.heldEvents?.length && !state_.disposed) {
3412
+ const next = state_.heldEvents.shift();
3413
+ const belongsTo = Math.max(next.generation, loadedAt);
3414
+ if (state_.identity.getGeneration() !== belongsTo) {
3415
+ stale += 1;
3416
+ continue;
3417
+ }
3418
+ try {
3419
+ await sendEvent(state_, next.event, next.properties, next.options, next.occurredAt);
3420
+ } catch (err) {
3421
+ if (typeof console !== "undefined") console.warn(`[amba] could not send "${next.event}", tracked while the secure store was locked`, err);
3422
+ }
3423
+ }
3424
+ } finally {
3425
+ state_.releasingHeldEvents = false;
3426
+ if (stale > 0 && typeof console !== "undefined") console.warn(`[amba] dropped ${stale} event(s) tracked while the secure store was locked: the session they belong to changed before they could be sent`);
3427
+ }
2998
3428
  }
2999
3429
  /** Paths that must NOT trigger lazy anonymous sign-in. */
3000
3430
  function pathSkipsLazySession(path) {
@@ -3005,21 +3435,41 @@ function pathSkipsLazySession(path) {
3005
3435
  if (path === P.flags || path.startsWith(`${P.flags}/`)) return true;
3006
3436
  return false;
3007
3437
  }
3438
+ /**
3439
+ * Make sure a session is stored before an authenticated call. While the user
3440
+ * must sign in again (`sign_in_required`), the call fails with
3441
+ * `SIGN_IN_REQUIRED` and no anonymous user starts.
3442
+ */
3008
3443
  async function ensureAnonymousSession(state_) {
3009
3444
  if (state_.identity.getSessionToken()) return;
3445
+ if (state_.identity.isSignInRequired()) throw signInRequiredError();
3010
3446
  if (state_.anonSignInInflight) {
3011
3447
  await state_.anonSignInInflight.catch(() => {});
3012
3448
  if (state_.identity.getSessionToken()) return;
3449
+ if (state_.identity.isSignInRequired()) throw signInRequiredError();
3450
+ }
3451
+ for (let attempt = 0; attempt < 2; attempt += 1) {
3452
+ if (state_.identity.getSessionToken() || state_.disposed) return;
3453
+ if (state_.identity.isSignInRequired()) throw signInRequiredError();
3454
+ await anonymousSignInOnce(state_);
3013
3455
  }
3456
+ if (!state_.identity.getSessionToken() && state_.identity.isSignInRequired()) throw signInRequiredError();
3457
+ }
3458
+ /** One run of the lazy sign-in, shared by every caller that waits for it. */
3459
+ function anonymousSignInOnce(state_) {
3014
3460
  if (!state_.anonSignInInflight) state_.anonSignInInflight = (async () => {
3015
3461
  try {
3462
+ await state_.identity.vaultStillUnreadable();
3463
+ if (await state_.identity.anonymousIdStillUnknown()) throw secureStoreUnavailableError();
3464
+ if (state_.identity.getSessionToken()) return;
3016
3465
  if (await resumeWithRefreshToken(state_)) return;
3017
- await signInAnonymouslyFresh(state_);
3466
+ if (state_.identity.getSessionToken() || state_.identity.isSignInRequired()) return;
3467
+ await signInAnonymouslyAutomatic(state_);
3018
3468
  } finally {
3019
3469
  state_.anonSignInInflight = null;
3020
3470
  }
3021
3471
  })();
3022
- await state_.anonSignInInflight;
3472
+ return state_.anonSignInInflight;
3023
3473
  }
3024
3474
  /**
3025
3475
  * Restore a stored session whose session token expired while the app was
@@ -3027,7 +3477,7 @@ async function ensureAnonymousSession(state_) {
3027
3477
  * `onAuthStateChange` show the signed-in user without waiting for an
3028
3478
  * authenticated call. Shares the in-flight slot with the lazy sign-in, so a
3029
3479
  * concurrent authenticated call waits for it. A rejected refresh token ends
3030
- * the session (subscribers hear `null`); this never signs in anonymously.
3480
+ * the session (`endLostSession`); this never signs in anonymously.
3031
3481
  */
3032
3482
  function restoreStoredSession(state_) {
3033
3483
  if (!state_.anonSignInInflight) state_.anonSignInInflight = (async () => {
@@ -3040,11 +3490,16 @@ function restoreStoredSession(state_) {
3040
3490
  return state_.anonSignInInflight;
3041
3491
  }
3042
3492
  /**
3043
- * Server answers that mean the refresh token's session is over. A 401
3044
- * without the server's envelope (a proxy, a captive portal) maps to
3045
- * `UNAUTHORIZED` and leaves the session in place.
3493
+ * Answers that mean the refresh token's session is over: the server
3494
+ * rejected or revoked it, or it renewed a different user than the stored
3495
+ * one (`OWNER_MISMATCH`). A 401 without the server's envelope (a proxy, a
3496
+ * captive portal) maps to `UNAUTHORIZED` and leaves the session in place.
3046
3497
  */
3047
- const SESSION_ENDED_CODES = new Set(["INVALID_TOKEN", "SESSION_REVOKED"]);
3498
+ const SESSION_ENDED_CODES = new Set([
3499
+ "INVALID_TOKEN",
3500
+ "SESSION_REVOKED",
3501
+ "OWNER_MISMATCH"
3502
+ ]);
3048
3503
  function isSessionEnded(err) {
3049
3504
  return err instanceof AmbaApiError && SESSION_ENDED_CODES.has(err.code);
3050
3505
  }
@@ -3071,8 +3526,8 @@ function trackLink(state_, link) {
3071
3526
  * waited for first;
3072
3527
  * - a session another flow stored after the call left is used as is;
3073
3528
  * - otherwise the refresh token continues the session. A rejected refresh
3074
- * token ends it (subscribers hear `null`) and the call's own error
3075
- * reaches the app.
3529
+ * token ends it (`endLostSession`) and the call's own error reaches the
3530
+ * app.
3076
3531
  * A session of any other user (a sign-in meanwhile, or the anonymous user
3077
3532
  * a lazy sign-in starts after the session ended) answers false, and the
3078
3533
  * call fails with the server's code. Offline or a server error keeps the
@@ -3108,8 +3563,8 @@ async function recoverRefusedSession(state_, usedToken, usedUserId) {
3108
3563
  * reaction on that promise), so recovery never waits on itself.
3109
3564
  */
3110
3565
  async function withBodySession(state_, send) {
3111
- const usedToken = state_.identity.getSessionToken();
3112
- if (!usedToken) throw new AmbaApiError("UNAUTHORIZED", "no active session — call signIn* first");
3566
+ await loadUnreadSession(state_);
3567
+ const usedToken = requireSession(state_);
3113
3568
  const usedUserId = state_.identity.getAppUserId();
3114
3569
  try {
3115
3570
  return await send(usedToken);
@@ -3122,78 +3577,136 @@ async function withBodySession(state_, send) {
3122
3577
  }
3123
3578
  }
3124
3579
  /**
3580
+ * Read a store the launch couldn't read (a locked device) again. When that
3581
+ * loads a session, wait for the renewal it starts, so the caller acts on
3582
+ * the loaded session (a sign-out revokes it, a link sends it).
3583
+ */
3584
+ async function loadUnreadSession(state_) {
3585
+ if (!state_.identity.credentialsUnknown()) return;
3586
+ await state_.identity.credentialsStillUnreadable();
3587
+ if (!state_.identity.credentialsUnknown()) await settleSessionInFlight(state_);
3588
+ }
3589
+ /**
3125
3590
  * Before a read of the current session (`me`, `getSession`,
3126
- * `getCurrentIdentifiers`) while no session token is stored: wait for the
3127
- * launch restore or sign-in in flight, so a check made at launch sees the
3128
- * session it restores.
3591
+ * `getCurrentIdentifiers`) while no session token is stored: load a stored
3592
+ * session the launch couldn't read yet, then wait for the launch restore or
3593
+ * sign-in in flight, so a check made at launch sees the session it
3594
+ * restores.
3129
3595
  */
3130
3596
  async function settleSessionInFlight(state_) {
3597
+ if (state_.identity.getSessionToken()) return;
3598
+ await state_.identity.credentialsStillUnreadable();
3131
3599
  if (state_.identity.getSessionToken() || !state_.anonSignInInflight) return;
3132
3600
  await state_.anonSignInInflight.catch(() => {});
3133
3601
  }
3134
3602
  /**
3135
3603
  * Continue the stored session with its refresh token. True when it did.
3136
- * False when there is no refresh token, or the server rejected it: the
3137
- * session is then cleared (the anonymous id rotates unless the session was
3138
- * purely anonymous) and subscribers hear the sign-out. Any other failure
3139
- * (offline, a 5xx) is thrown, so a flaky network never starts a new user.
3604
+ * False when there is no refresh token, or the session is over (the server
3605
+ * rejected the refresh token, or it renewed another user): the session then
3606
+ * ends through `endLostSession`. Any other failure (offline, a 5xx) is
3607
+ * thrown, so a flaky network never ends a session or starts a new user.
3140
3608
  */
3141
3609
  async function resumeWithRefreshToken(state_) {
3142
3610
  const used = state_.identity.getRefreshToken();
3143
3611
  if (!used) return false;
3612
+ const startedAt = state_.identity.getGeneration();
3144
3613
  try {
3145
3614
  await refreshSession(state_);
3146
3615
  return true;
3147
3616
  } catch (err) {
3148
3617
  if (state_.identity.getRefreshToken() !== used) return state_.identity.getSessionToken() !== null;
3149
3618
  if (!isSessionEnded(err)) throw err;
3150
- await state_.identity.clearSession(false, { keepIdWhenKindUnknown: true });
3151
- notifyAuthSubscribers(null);
3619
+ await endLostSession(state_, startedAt);
3152
3620
  return false;
3153
3621
  }
3154
3622
  }
3155
3623
  /**
3156
- * `/v1/client/auth/anonymous` with the device's anonymous id. When the
3157
- * server answers `IDENTITY_SIGN_IN_REQUIRED` (the id belongs to a user who
3158
- * signs in with a linked identity), the device takes a new anonymous id and
3159
- * signs in once more as a new anonymous user.
3624
+ * End a session that is over without a sign-out, once, unless the session
3625
+ * changed since `startedAt`.
3626
+ *
3627
+ * - A session that gained an identity (a sign-in other than anonymous, or a
3628
+ * link) leaves the user to sign in again: its tokens are cleared, the
3629
+ * anonymous id rotates (it belongs to the identified account), and
3630
+ * subscribers hear `null` with `sign_in_required`. No new anonymous user
3631
+ * starts; calls that need a session fail with `SIGN_IN_REQUIRED`.
3632
+ * - A purely anonymous session keeps its anonymous id, so the next call that
3633
+ * needs a session resumes the same anonymous user through `/anonymous`
3634
+ * (or the server starts a new one). Subscribers hear `null` with
3635
+ * `session_ended`.
3636
+ * - A session of unknown kind (stored by an SDK before 4.0.14) is treated
3637
+ * like an anonymous one: `/anonymous` resumes a still-anonymous user, and
3638
+ * answers `IDENTITY_SIGN_IN_REQUIRED` for one who linked an identity,
3639
+ * which then leaves the user to sign in again.
3160
3640
  */
3161
- async function signInAnonymouslyFresh(state_) {
3641
+ async function endLostSession(state_, startedAt) {
3642
+ if (state_.identity.snapshot().anonymousSession === false) {
3643
+ if (await state_.identity.clearSession(false, {
3644
+ signInRequired: true,
3645
+ ifGeneration: startedAt
3646
+ })) notifyAuthSubscribers(state_, null, "sign_in_required");
3647
+ return;
3648
+ }
3649
+ if (await state_.identity.clearSession(false, {
3650
+ keepIdWhenKindUnknown: true,
3651
+ ifGeneration: startedAt
3652
+ })) notifyAuthSubscribers(state_, null, "session_ended");
3653
+ }
3654
+ /**
3655
+ * The anonymous sign-in the SDK starts on its own before an authenticated
3656
+ * call: `/v1/client/auth/anonymous` with the device's anonymous id. It
3657
+ * stores its session only when nothing replaced the stored identity while
3658
+ * it was in flight; a sign-in that landed first wins and this answer is
3659
+ * dropped.
3660
+ *
3661
+ * When the server answers `IDENTITY_SIGN_IN_REQUIRED` (the id belongs to a
3662
+ * user who signs in with a linked identity), the stored session continues
3663
+ * when this device holds its refresh token. Otherwise the user must sign in
3664
+ * again (`sign_in_required`): no new anonymous user starts on its own.
3665
+ */
3666
+ async function signInAnonymouslyAutomatic(state_) {
3667
+ if (state_.disposed) return;
3668
+ const startedAt = state_.identity.getGeneration();
3162
3669
  try {
3163
- return await postAuth(state_, P.authAnonymous, { anonymous_id: state_.identity.getAnonymousId() });
3670
+ await postAuth(state_, P.authAnonymous, { anonymous_id: state_.identity.getAnonymousId() }, { ifGeneration: startedAt });
3164
3671
  } catch (err) {
3672
+ if (isSessionChanged(err)) return;
3165
3673
  if (!(err instanceof AmbaApiError) || err.code !== IDENTITY_SIGN_IN_REQUIRED) throw err;
3166
- return recoverFromHeldAnonymousId(state_);
3674
+ if (await continueHeldSession(state_)) return;
3675
+ if (await state_.identity.clearSession(true, {
3676
+ signInRequired: true,
3677
+ ifGeneration: startedAt
3678
+ })) notifyAuthSubscribers(state_, null, "sign_in_required");
3167
3679
  }
3168
3680
  }
3169
3681
  const IDENTITY_SIGN_IN_REQUIRED = "IDENTITY_SIGN_IN_REQUIRED";
3170
3682
  /**
3171
3683
  * After `/anonymous` answered `IDENTITY_SIGN_IN_REQUIRED`: continue the
3172
- * stored session when this device holds its refresh token, and otherwise
3173
- * take a new anonymous id and sign in as a new anonymous user. While the
3174
- * secure store can't be read (an iOS device not yet unlocked since a
3175
- * restart), the stored session is unknown, so the call fails with
3176
- * `SECURE_STORE_UNAVAILABLE` and replaces nothing.
3684
+ * stored session when this device holds its refresh token. Null when it
3685
+ * holds none, or the server rejected it. While the stored session can't be
3686
+ * read (an iOS device not yet unlocked since a restart), it is unknown, so
3687
+ * the call fails with `SECURE_STORE_UNAVAILABLE` and replaces nothing.
3177
3688
  */
3178
- async function recoverFromHeldAnonymousId(state_) {
3179
- if (await state_.identity.vaultStillUnreadable()) throw new AmbaApiError("SECURE_STORE_UNAVAILABLE", "the stored session can't be read yet (the device may be locked); retry once it is unlocked");
3689
+ async function continueHeldSession(state_) {
3690
+ if (await state_.identity.credentialsStillUnreadable()) throw secureStoreUnavailableError();
3180
3691
  const used = state_.identity.getRefreshToken();
3181
- if (used) try {
3692
+ if (!used) return null;
3693
+ try {
3182
3694
  return await refreshSession(state_);
3183
3695
  } catch (refreshErr) {
3184
3696
  if (!isSessionEnded(refreshErr)) throw refreshErr;
3185
3697
  const current = state_.identity.getRefreshToken();
3186
3698
  if (current !== null && current !== used) throw refreshErr;
3699
+ return null;
3187
3700
  }
3188
- await state_.identity.clearSession(true);
3189
- return postAuth(state_, P.authAnonymous, { anonymous_id: state_.identity.getAnonymousId() });
3190
3701
  }
3191
3702
  /**
3192
3703
  * Rotate the session pair with the stored refresh token. The session keeps
3193
3704
  * its user and its kind (anonymous or identified), and auth-state
3194
3705
  * subscribers hear the refreshed session. The server includes the user; an
3195
3706
  * older server that leaves it out keeps the stored user id, and the result's
3196
- * user then carries only that id.
3707
+ * user then carries only that id. A pair that renews a different user than
3708
+ * the stored one is never stored: the session ends (`endLostSession`) and
3709
+ * the call fails with `OWNER_MISMATCH`.
3197
3710
  */
3198
3711
  function refreshSession(state_) {
3199
3712
  if (state_.refreshInflight) return state_.refreshInflight;
@@ -3206,32 +3719,66 @@ function refreshSession(state_) {
3206
3719
  return inflight;
3207
3720
  }
3208
3721
  async function refreshSessionOnce(state_) {
3722
+ if (state_.disposed) throw sessionChangedError("the SDK was configured again");
3209
3723
  const refreshToken = state_.identity.getRefreshToken();
3724
+ if (!refreshToken && state_.identity.isSignInRequired()) throw signInRequiredError();
3210
3725
  if (!refreshToken) throw new AmbaApiError("UNAUTHORIZED", "no refresh token available — call signIn* first");
3211
- const env = await state_.http.post(P.authRefresh, { refresh_token: refreshToken });
3212
- if (state_.identity.getRefreshToken() !== refreshToken) throw new AmbaApiError("SESSION_CHANGED", "the session changed while it was being refreshed");
3213
- const appUserId = env.data.user?.id ?? state_.identity.getAppUserId();
3214
- if (!appUserId) throw new AmbaApiError("UNAUTHORIZED", "the refreshed session names no user — call signIn* first");
3215
- const user = env.data.user ? normalizeUserAuthProviders(env.data.user) : {
3216
- id: appUserId,
3217
- auth_providers: [],
3218
- properties: {}
3219
- };
3220
- const result = {
3221
- session_token: env.data.session_token,
3222
- refresh_token: env.data.refresh_token,
3223
- user,
3224
- expires_at: env.data.expires_at ?? null
3225
- };
3226
- await persistAuthResult(state_, result, void 0);
3227
- const anonymousId = nonEmptyString(user.anonymous_id);
3228
- if (anonymousId && anonymousId !== state_.identity.getAnonymousId() && state_.identity.getRefreshToken() === result.refresh_token) {
3229
- if (state_.identity.snapshot().anonymousSession === true) await state_.identity.markIdentified();
3230
- await adoptAnonymousId(state_, anonymousId);
3726
+ const startedAt = state_.identity.getGeneration();
3727
+ const exchange$1 = refreshExchangeFor(state_, refreshToken);
3728
+ try {
3729
+ const env = await exchange$1.response;
3730
+ if (state_.identity.getRefreshToken() !== refreshToken || state_.identity.getGeneration() !== startedAt) throw sessionChangedError("the session changed while it was being refreshed");
3731
+ const storedUserId = state_.identity.getAppUserId();
3732
+ const renewedUserId = nonEmptyString(env.data.user?.id);
3733
+ if (renewedUserId !== null && storedUserId !== null && renewedUserId !== storedUserId) {
3734
+ await endLostSession(state_, startedAt);
3735
+ throw new AmbaApiError("OWNER_MISMATCH", "the refreshed session belongs to a different user than the stored one; sign in again");
3736
+ }
3737
+ const appUserId = renewedUserId ?? storedUserId;
3738
+ if (!appUserId) throw new AmbaApiError("UNAUTHORIZED", "the refreshed session names no user — call signIn* first");
3739
+ const user = env.data.user ? normalizeUserAuthProviders(env.data.user) : {
3740
+ id: appUserId,
3741
+ auth_providers: [],
3742
+ properties: {}
3743
+ };
3744
+ const result = {
3745
+ session_token: env.data.session_token,
3746
+ refresh_token: env.data.refresh_token,
3747
+ user,
3748
+ expires_at: env.data.expires_at ?? null
3749
+ };
3750
+ const committedAt = await persistAuthResult(state_, result, void 0, {
3751
+ ifGeneration: startedAt,
3752
+ advance: false
3753
+ });
3754
+ if (committedAt === null) throw sessionChangedError("the session changed while it was being refreshed");
3755
+ const anonymousId = nonEmptyString(user.anonymous_id);
3756
+ if (anonymousId && anonymousId !== state_.identity.getAnonymousId() && state_.identity.getRefreshToken() === result.refresh_token) {
3757
+ if (state_.identity.snapshot().anonymousSession === true) await state_.identity.markIdentified({ ifGeneration: committedAt });
3758
+ await adoptAnonymousId(state_, anonymousId, committedAt);
3759
+ }
3760
+ state_.eventQueue?.flush();
3761
+ notifyFromAuthResult(state_, result, "token_refreshed");
3762
+ return result;
3763
+ } finally {
3764
+ if (state_.refreshExchange === exchange$1) state_.refreshExchange = null;
3231
3765
  }
3232
- state_.eventQueue?.flush();
3233
- await notifyFromAuthResult(result);
3234
- return result;
3766
+ }
3767
+ /**
3768
+ * The `/refresh` request for `refreshToken`: the one the previous state had
3769
+ * in flight for the same token when `configure()` ran again, or a new one.
3770
+ * It stays on the state until its answer is stored, so a `configure()`
3771
+ * meanwhile hands it on instead of presenting the token again.
3772
+ */
3773
+ function refreshExchangeFor(state_, refreshToken) {
3774
+ const inherited = state_.inheritedRefresh;
3775
+ if (inherited) state_.inheritedRefresh = null;
3776
+ const exchange$1 = inherited && inherited.token === refreshToken ? inherited : {
3777
+ token: refreshToken,
3778
+ response: state_.http.post(P.authRefresh, { refresh_token: refreshToken })
3779
+ };
3780
+ state_.refreshExchange = exchange$1;
3781
+ return exchange$1;
3235
3782
  }
3236
3783
  /**
3237
3784
  * POST an OTP or email + password link. The request asks the server to
@@ -3248,6 +3795,11 @@ function postLink(state_, body) {
3248
3795
  })));
3249
3796
  }
3250
3797
  async function postLinkOnce(state_, body) {
3798
+ const startedAt = state_.identity.getGeneration();
3799
+ const fence = {
3800
+ ifGeneration: startedAt,
3801
+ advance: false
3802
+ };
3251
3803
  const env = await state_.http.post(P.authLink, {
3252
3804
  ...body,
3253
3805
  rotate_session: true
@@ -3255,7 +3807,7 @@ async function postLinkOnce(state_, body) {
3255
3807
  const user = normalizeUserAuthProviders(env.data.user);
3256
3808
  const sessionToken = nonEmptyString(env.data.session_token);
3257
3809
  const refreshToken = nonEmptyString(env.data.refresh_token);
3258
- if (sessionToken && refreshToken) await state_.identity.setSession({
3810
+ if (!(sessionToken && refreshToken ? await state_.identity.setSession({
3259
3811
  appUserId: user.id,
3260
3812
  sessionToken,
3261
3813
  refreshToken,
@@ -3266,10 +3818,9 @@ async function postLinkOnce(state_, body) {
3266
3818
  expires_at: env.data.expires_at ?? null
3267
3819
  }),
3268
3820
  anonymous: false
3269
- });
3270
- else await state_.identity.markIdentified();
3821
+ }, fence) !== null : await state_.identity.markIdentified(fence))) throw sessionChangedError("the session changed while the link was in flight; the newer session stays");
3271
3822
  const anonymousId = nonEmptyString(user.anonymous_id);
3272
- if (anonymousId) await adoptAnonymousId(state_, anonymousId);
3823
+ if (anonymousId) await adoptAnonymousId(state_, anonymousId, startedAt);
3273
3824
  return user;
3274
3825
  }
3275
3826
  /**
@@ -3280,7 +3831,7 @@ async function postUserNoRotate(state_, path, body) {
3280
3831
  return normalizeUserAuthProviders((await state_.http.post(path, body)).data.user);
3281
3832
  }
3282
3833
  async function unlinkProvider(provider) {
3283
- const s = getState();
3834
+ const s = await liveState();
3284
3835
  return withBodySession(s, (sessionToken) => postUserNoRotate(s, P.authUnlink, {
3285
3836
  provider,
3286
3837
  session_token: sessionToken
@@ -3537,107 +4088,238 @@ function detectDeviceRegion() {
3537
4088
  }
3538
4089
  }
3539
4090
  /**
3540
- * Top-level SDK namespace for React Native.
4091
+ * The send path of `events.track()`: the event batch queue when batching is
4092
+ * on, otherwise the live request (with the offline outbox behind it when
4093
+ * enabled). `occurredAtOverride` is when the event happened, for one that
4094
+ * waited (see `releaseHeldEvents`).
3541
4095
  */
3542
- const Amba = {
3543
- configure: async (config) => {
3544
- if (!config.projectId || config.projectId.trim() === "") throw new Error("Amba.configure: projectId is required");
3545
- if (!config.clientKey || config.clientKey.trim() === "") throw new Error("Amba.configure: clientKey is required");
3546
- const batchSize = config.events?.batchSize ?? 100;
3547
- const flushIntervalMs = config.events?.flushIntervalMs ?? 6e4;
3548
- const maxQueue = config.events?.maxQueue ?? 1e4;
3549
- if (config.events?.batching) {
3550
- if (!Number.isInteger(batchSize) || batchSize < 1 || batchSize > 500) throw new Error("Amba.configure: events.batchSize must be 1–500");
3551
- if (!Number.isInteger(flushIntervalMs) || flushIntervalMs < 0) throw new Error("Amba.configure: events.flushIntervalMs must be a non-negative integer");
3552
- if (!Number.isInteger(maxQueue) || maxQueue < 1) throw new Error("Amba.configure: events.maxQueue must be positive");
4096
+ async function sendEvent(s, event, properties, options, occurredAtOverride) {
4097
+ if (s.eventQueue && !options?.immediate) return s.eventQueue.enqueue(event, properties, options?.telemetry === true, occurredAtOverride);
4098
+ const occurredAt = occurredAtOverride ?? (/* @__PURE__ */ new Date()).toISOString();
4099
+ const props = s.offlineEnabled ? snapshotProperties(properties) : properties;
4100
+ const body = {
4101
+ event,
4102
+ properties: props ?? void 0,
4103
+ anonymous_id: s.identity.getAnonymousId(),
4104
+ timestamp: occurredAt
4105
+ };
4106
+ if (options?.telemetry) body["telemetry"] = true;
4107
+ const idempotencyKey = s.offlineEnabled ? newIdempotencyKey() : void 0;
4108
+ if (idempotencyKey && keyedLiveTrack(props, options?.telemetry)) body["idempotency_key"] = idempotencyKey;
4109
+ const outboxTicket = s.offlineEnabled ? getOfflineQueue(s).ticket() : void 0;
4110
+ try {
4111
+ await s.http.post(P.events, body);
4112
+ } catch (err) {
4113
+ if (s.offlineEnabled && isOfflineError(err)) {
4114
+ await getOfflineQueue(s).enqueue({
4115
+ kind: "track",
4116
+ event,
4117
+ properties: props,
4118
+ telemetry: options?.telemetry,
4119
+ occurredAt,
4120
+ idempotencyKey
4121
+ }, outboxTicket);
4122
+ return;
3553
4123
  }
3554
- const deadlines = resolveRequestDeadlines(config);
3555
- const tokenStorage = config.tokenStorage ?? "secure";
3556
- if (!TOKEN_STORAGE_MODES.includes(tokenStorage)) throw new Error("Amba.configure: tokenStorage must be \"secure\", \"memory\" or \"asyncStorage\"");
3557
- const secureStorage = config.secureStorage;
3558
- if (secureStorage !== void 0 && (typeof secureStorage?.getItem !== "function" || typeof secureStorage?.setItem !== "function" || typeof secureStorage?.removeItem !== "function")) throw new Error("Amba.configure: secureStorage must provide getItem, setItem and removeItem");
3559
- const configDefaults = readConfigDefaults(config.configDefaults);
3560
- _pendingConfigDefaults = configDefaults;
3561
- const storagePrefix = config.storagePrefix ?? "amba";
3562
- const persistence = new AsyncStoragePersistence(storagePrefix);
3563
- if (config.events?.batching) await persistence.assertDurable();
3564
- await persistence.hydrate();
3565
- const vault = await resolveTokenVault(tokenStorage, storagePrefix, secureStorage);
3566
- const identity = await IdentityStore.create(persistence, vault);
3567
- const baseUrl = (config.apiUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
3568
- const remoteValues = new RemoteValuesCache(persistence, recordKey(baseUrl, config.clientKey), configDefaults ?? {}, () => identity.getAppUserId());
4124
+ throw err;
4125
+ }
4126
+ }
4127
+ /**
4128
+ * The local half of a sign-out on `state_`: clear the session (unless a
4129
+ * sign-in stored a newer one since `startedAt`), drop the signed-out
4130
+ * user's cached values and queued writes, and tell subscribers. False when
4131
+ * nothing was cleared.
4132
+ */
4133
+ async function endSignedOutSession(state_, rotateAnonymousId, startedAt) {
4134
+ const signedOutUser = state_.identity.getAppUserId();
4135
+ if (!await state_.identity.clearSession(rotateAnonymousId, { unlessSignedInSince: startedAt })) return false;
4136
+ dropHeldEvents(state_, "the device signed out before its identity could be read");
4137
+ state_.remoteValues.clearForSignOut(signedOutUser);
4138
+ await state_.remoteValues.flushed();
4139
+ try {
4140
+ await getOfflineQueue(state_).purge();
4141
+ } catch (err) {
4142
+ if (typeof console !== "undefined") console.warn("[amba] signOut could not clear the offline outbox", err);
4143
+ }
4144
+ state_.eventQueue?.flush();
4145
+ notifyAuthSubscribers(state_, null, "signed_out");
4146
+ return true;
4147
+ }
4148
+ /**
4149
+ * One `configure()`: build the state for `config` and install it in place
4150
+ * of the previous one (see `Amba.configure`).
4151
+ */
4152
+ async function configureOnce(config) {
4153
+ if (!config.projectId || config.projectId.trim() === "") throw new Error("Amba.configure: projectId is required");
4154
+ if (!config.clientKey || config.clientKey.trim() === "") throw new Error("Amba.configure: clientKey is required");
4155
+ const batchSize = config.events?.batchSize ?? 100;
4156
+ const flushIntervalMs = config.events?.flushIntervalMs ?? 6e4;
4157
+ const maxQueue = config.events?.maxQueue ?? 1e4;
4158
+ if (config.events?.batching) {
4159
+ if (!Number.isInteger(batchSize) || batchSize < 1 || batchSize > 500) throw new Error("Amba.configure: events.batchSize must be 1–500");
4160
+ if (!Number.isInteger(flushIntervalMs) || flushIntervalMs < 0) throw new Error("Amba.configure: events.flushIntervalMs must be a non-negative integer");
4161
+ if (!Number.isInteger(maxQueue) || maxQueue < 1) throw new Error("Amba.configure: events.maxQueue must be positive");
4162
+ }
4163
+ const deadlines = resolveRequestDeadlines(config);
4164
+ const tokenStorage = config.tokenStorage ?? "secure";
4165
+ if (!TOKEN_STORAGE_MODES.includes(tokenStorage)) throw new Error("Amba.configure: tokenStorage must be \"secure\", \"memory\" or \"asyncStorage\"");
4166
+ const secureStorage = config.secureStorage;
4167
+ if (secureStorage !== void 0 && (typeof secureStorage?.getItem !== "function" || typeof secureStorage?.setItem !== "function" || typeof secureStorage?.removeItem !== "function")) throw new Error("Amba.configure: secureStorage must provide getItem, setItem and removeItem");
4168
+ const configDefaults = readConfigDefaults(config.configDefaults);
4169
+ _pendingConfigDefaults = configDefaults;
4170
+ const storagePrefix = config.storagePrefix ?? "amba";
4171
+ const persistence = new AsyncStoragePersistence(storagePrefix);
4172
+ if (config.events?.batching) await persistence.assertDurable();
4173
+ await persistence.hydrate();
4174
+ const vault = await resolveTokenVault(tokenStorage, storagePrefix, secureStorage);
4175
+ const prior = state;
4176
+ let inheritedRefresh = null;
4177
+ const detached = prior !== null && !prior.disposed;
4178
+ if (prior && detached) {
4179
+ prior.disposed = true;
4180
+ inheritedRefresh = prior.refreshExchange ?? prior.inheritedRefresh ?? null;
4181
+ await prior.identity.dispose();
4182
+ }
4183
+ const baseUrl = (config.apiUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
4184
+ let identity;
4185
+ let remoteValues;
4186
+ try {
4187
+ identity = await IdentityStore.create(persistence, vault);
4188
+ const store = identity;
4189
+ remoteValues = new RemoteValuesCache(persistence, recordKey(baseUrl, config.clientKey), configDefaults ?? {}, () => store.getAppUserId());
3569
4190
  await remoteValues.load();
3570
- const sdkVersionHeader = `amba-sdk/${SDK_VERSION} ${`amba-react-native/${SDK_VERSION}`}`;
3571
- const http = new AmbaHttpClient({
3572
- apiKey: config.clientKey,
3573
- baseUrl,
3574
- sdkPlatform: "react-native",
3575
- sdkVersionHeader,
3576
- appVersion: normalizeAppVersion(config.appVersion),
3577
- deviceRegion: detectDeviceRegion(),
3578
- deviceOs: detectDeviceOs(),
3579
- identity,
3580
- requestTimeoutMs: deadlines.requestMs,
3581
- longRequestTimeoutMs: deadlines.longMs,
3582
- ensureSession: config.autoAnonymousSession === false ? void 0 : async (path) => {
3583
- if (pathSkipsLazySession(path)) return;
3584
- await ensureAnonymousSession(getState());
3585
- },
3586
- onSessionRefused: (usedToken, usedUserId) => recoverRefusedSession(getState(), usedToken, usedUserId)
4191
+ } catch (err) {
4192
+ if (prior && detached && state === prior) {
4193
+ prior.disposed = false;
4194
+ prior.identity.reattach();
4195
+ prior.inheritedRefresh = inheritedRefresh;
4196
+ if (inheritedRefresh && prior.identity.getRefreshToken() === inheritedRefresh.token) restoreStoredSession(prior).catch(() => {});
4197
+ }
4198
+ throw err;
4199
+ }
4200
+ const sdkVersionHeader = `amba-sdk/${SDK_VERSION} ${`amba-react-native/${SDK_VERSION}`}`;
4201
+ const http = new AmbaHttpClient({
4202
+ apiKey: config.clientKey,
4203
+ baseUrl,
4204
+ sdkPlatform: "react-native",
4205
+ sdkVersionHeader,
4206
+ appVersion: normalizeAppVersion(config.appVersion),
4207
+ deviceRegion: detectDeviceRegion(),
4208
+ deviceOs: detectDeviceOs(),
4209
+ identity,
4210
+ requestTimeoutMs: deadlines.requestMs,
4211
+ longRequestTimeoutMs: deadlines.longMs,
4212
+ ensureSession: async (path) => {
4213
+ if (pathSkipsLazySession(path)) return;
4214
+ if (state?.identity.isSignInRequired()) throw signInRequiredError();
4215
+ if (config.autoAnonymousSession === false) return;
4216
+ await ensureAnonymousSession(getState());
4217
+ },
4218
+ onSessionRefused: (usedToken, usedUserId) => recoverRefusedSession(getState(), usedToken, usedUserId)
4219
+ });
4220
+ _offlineEnableToken += 1;
4221
+ if (prior?.offlineQueue) await prior.offlineQueue.dispose();
4222
+ prior?.detachEventAppState?.();
4223
+ prior?.detachIdentityAppState?.();
4224
+ if (prior?.eventQueue) await prior.eventQueue.dispose();
4225
+ experimentReporter.reset();
4226
+ const nextState = {
4227
+ http,
4228
+ identity,
4229
+ debug: config.debug ?? false,
4230
+ projectId: config.projectId,
4231
+ clientKey: config.clientKey,
4232
+ baseUrl,
4233
+ functionsBaseUrl: config.functionsBaseUrl ?? null,
4234
+ resolvedFunctionsProjectId: null,
4235
+ functionsProjectIdInflight: null,
4236
+ persistence,
4237
+ remoteValues,
4238
+ refreshedFor: {
4239
+ config: null,
4240
+ flags: null
4241
+ },
4242
+ offlineEnabled: false,
4243
+ offlineQueue: null,
4244
+ eventQueue: null,
4245
+ inheritedRefresh
4246
+ };
4247
+ if (prior?.heldEvents?.length || prior?.heldEventsDropped) if (sameIdentityTarget(prior, nextState)) {
4248
+ const launchedAt = identity.getGeneration();
4249
+ nextState.heldEvents = (prior.heldEvents ?? []).map((held) => ({
4250
+ ...held,
4251
+ generation: launchedAt
4252
+ }));
4253
+ nextState.heldEventsDropped = prior.heldEventsDropped;
4254
+ prior.heldEvents = [];
4255
+ prior.heldEventsDropped = 0;
4256
+ } else dropHeldEvents(prior, "the SDK was configured for another project");
4257
+ state = nextState;
4258
+ identity.onAnonymousIdKnown(() => {
4259
+ releaseHeldEvents(nextState);
4260
+ });
4261
+ identity.onStoredSessionLoaded(() => {
4262
+ const userId = identity.getAppUserId();
4263
+ if (identity.getSessionToken() && userId) notifyAuthSubscribers(nextState, snapshotSession({
4264
+ id: userId,
4265
+ auth_providers: [],
4266
+ properties: {}
4267
+ }), "session_restored");
4268
+ else if (config.autoAnonymousSession !== false && identity.getRefreshToken()) restoreStoredSession(nextState).catch(() => {});
4269
+ });
4270
+ try {
4271
+ const { AppState } = require("react-native");
4272
+ const sub = AppState.addEventListener("change", (status) => {
4273
+ if (status === "active" && identity.credentialsUnknown()) identity.credentialsStillUnreadable();
3587
4274
  });
3588
- const prior = state;
3589
- _offlineEnableToken += 1;
3590
- if (prior?.offlineQueue) await prior.offlineQueue.dispose();
3591
- prior?.detachEventAppState?.();
3592
- if (prior?.eventQueue) await prior.eventQueue.dispose();
3593
- experimentReporter.reset();
3594
- const nextState = {
3595
- http,
3596
- identity,
3597
- debug: config.debug ?? false,
3598
- projectId: config.projectId,
3599
- clientKey: config.clientKey,
3600
- baseUrl,
3601
- functionsBaseUrl: config.functionsBaseUrl ?? null,
3602
- resolvedFunctionsProjectId: null,
3603
- functionsProjectIdInflight: null,
3604
- persistence,
3605
- remoteValues,
3606
- refreshedFor: {
3607
- config: null,
3608
- flags: null
3609
- },
3610
- offlineEnabled: false,
3611
- offlineQueue: null,
3612
- eventQueue: null
4275
+ nextState.detachIdentityAppState = () => sub.remove();
4276
+ } catch {}
4277
+ if (config.events?.batching) {
4278
+ const queue = new EventBatchQueue(offlineStore(nextState), `event_outbox:${nextState.projectId}`, {
4279
+ batchSize,
4280
+ flushIntervalMs,
4281
+ maxQueue,
4282
+ onDrop: config.events.onDrop
4283
+ }, () => ({
4284
+ userId: nextState.identity.getAppUserId(),
4285
+ anonymousId: nextState.identity.getAnonymousId(),
4286
+ sessionToken: nextState.identity.getSessionToken()
4287
+ }), (body, token) => nextState.http.postWithSession("/v1/client/events/batch", body, token), (body, token) => nextState.http.postWithSession(P.events, body, token));
4288
+ nextState.eventQueue = queue;
4289
+ await queue.start();
4290
+ try {
4291
+ const { AppState } = require("react-native");
4292
+ const sub = AppState.addEventListener("change", (status) => {
4293
+ if (status === "background") queue.flush();
4294
+ });
4295
+ nextState.detachEventAppState = () => sub.remove();
4296
+ } catch {}
4297
+ }
4298
+ if (!nextState.functionsBaseUrl && !PROJECT_ID_PATTERN.test(nextState.projectId)) resolveFunctionsProjectId(nextState).catch(() => {});
4299
+ if (nextState.heldEvents?.length && !identity.hasStandInAnonymousId()) releaseHeldEvents(nextState);
4300
+ if (inheritedRefresh !== null && identity.getRefreshToken() === inheritedRefresh.token || config.autoAnonymousSession !== false && !identity.getSessionToken() && identity.getRefreshToken()) restoreStoredSession(nextState).catch(() => {});
4301
+ }
4302
+ /**
4303
+ * Top-level SDK namespace for React Native.
4304
+ */
4305
+ const Amba = {
4306
+ configure: (config) => {
4307
+ const run = configuring ? configureChain.then(() => configureOnce(config)) : configureOnce(config);
4308
+ configureChain = run.catch(() => void 0);
4309
+ configuring = run;
4310
+ const settle = () => {
4311
+ if (configuring === run) configuring = null;
3613
4312
  };
3614
- state = nextState;
3615
- if (config.events?.batching) {
3616
- const queue = new EventBatchQueue(offlineStore(nextState), `event_outbox:${nextState.projectId}`, {
3617
- batchSize,
3618
- flushIntervalMs,
3619
- maxQueue,
3620
- onDrop: config.events.onDrop
3621
- }, () => ({
3622
- userId: nextState.identity.getAppUserId(),
3623
- anonymousId: nextState.identity.getAnonymousId(),
3624
- sessionToken: nextState.identity.getSessionToken()
3625
- }), (body, token) => nextState.http.postWithSession("/v1/client/events/batch", body, token), (body, token) => nextState.http.postWithSession(P.events, body, token));
3626
- nextState.eventQueue = queue;
3627
- await queue.start();
3628
- try {
3629
- const { AppState } = require("react-native");
3630
- const sub = AppState.addEventListener("change", (status) => {
3631
- if (status === "background") queue.flush();
3632
- });
3633
- nextState.detachEventAppState = () => sub.remove();
3634
- } catch {}
3635
- }
3636
- if (!nextState.functionsBaseUrl && !PROJECT_ID_PATTERN.test(nextState.projectId)) resolveFunctionsProjectId(nextState).catch(() => {});
3637
- if (config.autoAnonymousSession !== false && !identity.getSessionToken() && identity.getRefreshToken()) restoreStoredSession(nextState).catch(() => {});
4313
+ run.then(settle, settle);
4314
+ return run;
3638
4315
  },
3639
4316
  _reset: () => {
4317
+ if (state) {
4318
+ state.disposed = true;
4319
+ state.identity.dispose();
4320
+ }
3640
4321
  state?.detachEventAppState?.();
4322
+ state?.detachIdentityAppState?.();
3641
4323
  state?.eventQueue?.dispose();
3642
4324
  state = null;
3643
4325
  authSubscribers.clear();
@@ -3671,35 +4353,18 @@ const Amba = {
3671
4353
  },
3672
4354
  events: {
3673
4355
  track: async (event, properties, options) => {
3674
- const s = getState();
3675
- if (s.eventQueue && !options?.immediate) return s.eventQueue.enqueue(event, properties, options?.telemetry === true);
3676
- const occurredAt = (/* @__PURE__ */ new Date()).toISOString();
3677
- const props = s.offlineEnabled ? snapshotProperties(properties) : properties;
3678
- const body = {
3679
- event,
3680
- properties: props ?? void 0,
3681
- anonymous_id: s.identity.getAnonymousId(),
3682
- timestamp: occurredAt
3683
- };
3684
- if (options?.telemetry) body["telemetry"] = true;
3685
- const idempotencyKey = s.offlineEnabled ? newIdempotencyKey() : void 0;
3686
- if (idempotencyKey && keyedLiveTrack(props, options?.telemetry)) body["idempotency_key"] = idempotencyKey;
3687
- const outboxTicket = s.offlineEnabled ? getOfflineQueue(s).ticket() : void 0;
3688
- try {
3689
- await s.http.post(P.events, body);
3690
- } catch (err) {
3691
- if (s.offlineEnabled && isOfflineError(err)) {
3692
- await getOfflineQueue(s).enqueue({
3693
- kind: "track",
3694
- event,
3695
- properties: props,
3696
- telemetry: options?.telemetry,
3697
- occurredAt,
3698
- idempotencyKey
3699
- }, outboxTicket);
3700
- return;
4356
+ let s = getState();
4357
+ for (let attempt = 0;; attempt += 1) {
4358
+ const hold = (s.heldEvents?.length ?? 0) > 0 || await s.identity.anonymousIdStillUnknown();
4359
+ if (s.disposed && attempt === 0) {
4360
+ s = await liveState();
4361
+ continue;
3701
4362
  }
3702
- throw err;
4363
+ if (!hold) return sendEvent(s, event, properties, options);
4364
+ if (typeof event !== "string" || event.trim() === "") throw new AmbaApiError("VALIDATION_ERROR", "event name must be non-empty");
4365
+ holdEvent(s, event, properties, options);
4366
+ if (!s.identity.hasStandInAnonymousId()) releaseHeldEvents(s);
4367
+ return;
3703
4368
  }
3704
4369
  },
3705
4370
  flush: async () => getState().eventQueue?.flush() ?? {
@@ -3721,18 +4386,24 @@ const Amba = {
3721
4386
  },
3722
4387
  auth: {
3723
4388
  signInAnonymously: async () => {
3724
- const s = getState();
4389
+ const s = await liveState();
4390
+ await loadUnreadSession(s);
4391
+ if (await s.identity.anonymousIdStillUnknown()) throw secureStoreUnavailableError();
4392
+ const startedAt = s.identity.getGeneration();
3725
4393
  try {
3726
4394
  return await postAuth(s, P.authAnonymous, { anonymous_id: s.identity.getAnonymousId() });
3727
4395
  } catch (err) {
3728
4396
  if (!(err instanceof AmbaApiError) || err.code !== IDENTITY_SIGN_IN_REQUIRED) throw err;
3729
- return recoverFromHeldAnonymousId(s);
4397
+ const held = await continueHeldSession(s);
4398
+ if (held) return held;
4399
+ if (!await s.identity.clearSession(true, { unlessSignedInSince: startedAt })) throw sessionChangedError("a sign-in stored a newer session while this call was in flight");
4400
+ return postAuth(s, P.authAnonymous, { anonymous_id: s.identity.getAnonymousId() });
3730
4401
  }
3731
4402
  },
3732
4403
  signInWithEmail: async (email, password) => {
3733
4404
  requireEmail(email);
3734
4405
  if (password === "") throw new AmbaApiError("VALIDATION_ERROR", "password must not be empty");
3735
- return postAuth(getState(), P.authEmailLogin, {
4406
+ return postAuth(await liveState(), P.authEmailLogin, {
3736
4407
  email,
3737
4408
  password
3738
4409
  });
@@ -3740,14 +4411,14 @@ const Amba = {
3740
4411
  signUpWithEmail: async (email, password) => {
3741
4412
  requireEmail(email);
3742
4413
  requirePassword(password);
3743
- return postAuth(getState(), P.authEmailSignup, {
4414
+ return postAuth(await liveState(), P.authEmailSignup, {
3744
4415
  email,
3745
4416
  password
3746
4417
  });
3747
4418
  },
3748
4419
  signInWithApple: async (identityToken, options) => {
3749
4420
  if (identityToken.trim() === "") throw new AmbaApiError("VALIDATION_ERROR", "identity_token must not be empty");
3750
- return postAuth(getState(), P.authSocial, {
4421
+ return postAuth(await liveState(), P.authSocial, {
3751
4422
  provider: "apple",
3752
4423
  id_token: identityToken,
3753
4424
  ...nonceField(options)
@@ -3755,7 +4426,7 @@ const Amba = {
3755
4426
  },
3756
4427
  signInWithGoogle: async (idToken, options) => {
3757
4428
  if (idToken.trim() === "") throw new AmbaApiError("VALIDATION_ERROR", "id_token must not be empty");
3758
- return postAuth(getState(), P.authSocial, {
4429
+ return postAuth(await liveState(), P.authSocial, {
3759
4430
  provider: "google",
3760
4431
  id_token: idToken,
3761
4432
  ...nonceField(options)
@@ -3768,7 +4439,7 @@ const Amba = {
3768
4439
  verifyEmailOtp: async (email, code) => {
3769
4440
  requireEmail(email);
3770
4441
  requireOtpCode(code);
3771
- return postAuth(getState(), P.authEmailOtpVerify, {
4442
+ return postAuth(await liveState(), P.authEmailOtpVerify, {
3772
4443
  email,
3773
4444
  code
3774
4445
  });
@@ -3780,7 +4451,7 @@ const Amba = {
3780
4451
  verifySmsOtp: async (phone, code) => {
3781
4452
  requireE164Phone(phone);
3782
4453
  requireOtpCode(code);
3783
- return postAuth(getState(), P.authSmsOtpVerify, {
4454
+ return postAuth(await liveState(), P.authSmsOtpVerify, {
3784
4455
  phone,
3785
4456
  code
3786
4457
  });
@@ -3791,24 +4462,27 @@ const Amba = {
3791
4462
  },
3792
4463
  verifyMagicLink: async (token) => {
3793
4464
  if (token.trim() === "") throw new AmbaApiError("VALIDATION_ERROR", "token must not be empty");
3794
- return postAuth(getState(), P.authMagicLinkVerify, { token });
4465
+ return postAuth(await liveState(), P.authMagicLinkVerify, { token });
3795
4466
  },
3796
4467
  linkAccount: async (provider, credential, options) => {
3797
4468
  const providerTrimmed = provider?.trim() ?? "";
3798
4469
  if (providerTrimmed === "") throw new AmbaApiError("VALIDATION_ERROR", "provider must not be empty");
3799
4470
  if (typeof credential !== "string" || credential.trim() === "") throw new AmbaApiError("VALIDATION_ERROR", "credential must not be empty");
3800
- const s = getState();
4471
+ const s = await liveState();
3801
4472
  return withBodySession(s, (sessionToken) => trackLink(s, postAuth(s, P.authLink, {
3802
4473
  provider: providerTrimmed,
3803
4474
  token: credential,
3804
4475
  session_token: sessionToken,
3805
4476
  ...nonceField(options)
4477
+ }, {
4478
+ ifGeneration: s.identity.getGeneration(),
4479
+ advance: false
3806
4480
  })));
3807
4481
  },
3808
4482
  linkSmsOtp: async (phone, code) => {
3809
4483
  requireE164Phone(phone);
3810
4484
  requireOtpCode(code);
3811
- return postLink(getState(), {
4485
+ return postLink(await liveState(), {
3812
4486
  provider: "sms_otp",
3813
4487
  phone,
3814
4488
  code
@@ -3817,7 +4491,7 @@ const Amba = {
3817
4491
  linkEmailOtp: async (email, code) => {
3818
4492
  requireEmail(email);
3819
4493
  requireOtpCode(code);
3820
- return postLink(getState(), {
4494
+ return postLink(await liveState(), {
3821
4495
  provider: "email_otp",
3822
4496
  email,
3823
4497
  code
@@ -3826,7 +4500,7 @@ const Amba = {
3826
4500
  linkEmailPassword: async (email, password) => {
3827
4501
  requireEmail(email);
3828
4502
  requirePassword(password);
3829
- return postLink(getState(), {
4503
+ return postLink(await liveState(), {
3830
4504
  provider: "email_password",
3831
4505
  email,
3832
4506
  password
@@ -3839,34 +4513,29 @@ const Amba = {
3839
4513
  getCurrentIdentifiers: async () => {
3840
4514
  const s = getState();
3841
4515
  await settleSessionInFlight(s);
3842
- if (!s.identity.getSessionToken()) throw new AmbaApiError("UNAUTHORIZED", "no active session — call signIn* first");
4516
+ requireSession(s);
3843
4517
  return (await s.http.get(P.authIdentifiers)).data.identifiers;
3844
4518
  },
3845
4519
  signOut: async (rotateAnonymousId = false) => {
3846
- const s = getState();
4520
+ const s = await liveState();
4521
+ await loadUnreadSession(s);
4522
+ const startedAt = s.identity.getGeneration();
4523
+ const revoked = s.identity.getRefreshToken();
3847
4524
  if (s.identity.getSessionToken()) try {
3848
- const refreshToken = s.identity.getRefreshToken();
3849
- await s.http.post(P.authLogout, refreshToken ? { refresh_token: refreshToken } : {});
4525
+ await s.http.post(P.authLogout, revoked ? { refresh_token: revoked } : {});
3850
4526
  } catch (err) {
3851
4527
  if (typeof console !== "undefined") console.warn("[amba] signOut server call failed", err);
3852
4528
  }
3853
- const signedOutUser = s.identity.getAppUserId();
3854
- await s.identity.clearSession(rotateAnonymousId);
3855
- s.remoteValues.clearForSignOut(signedOutUser);
3856
- await s.remoteValues.flushed();
3857
- try {
3858
- await getOfflineQueue(s).purge();
3859
- } catch (err) {
3860
- if (typeof console !== "undefined") console.warn("[amba] signOut could not clear the offline outbox", err);
3861
- }
3862
- s.eventQueue?.flush();
3863
- notifyAuthSubscribers(null);
4529
+ if (await endSignedOutSession(s, rotateAnonymousId, startedAt)) return;
4530
+ if (!s.disposed || revoked === null) return;
4531
+ const next = await liveState();
4532
+ if (next !== s && !next.disposed && sameIdentityTarget(next, s) && next.identity.continuesLaunchSession(revoked)) await endSignedOutSession(next, rotateAnonymousId, next.identity.getGeneration());
3864
4533
  },
3865
- refresh: async () => refreshSession(getState()),
4534
+ refresh: async () => refreshSession(await liveState()),
3866
4535
  me: async () => {
3867
4536
  const s = getState();
3868
4537
  await settleSessionInFlight(s);
3869
- if (!s.identity.getSessionToken()) throw new AmbaApiError("UNAUTHORIZED", "no active session — call signIn* first");
4538
+ requireSession(s);
3870
4539
  return normalizeUserAuthProviders((await s.http.get(P.authMe)).data);
3871
4540
  },
3872
4541
  getSession: async () => {
@@ -3881,7 +4550,9 @@ const Amba = {
3881
4550
  }
3882
4551
  },
3883
4552
  getAnonymousId: async () => {
3884
- return getState().identity.getAnonymousId();
4553
+ const s = getState();
4554
+ if (await s.identity.anonymousIdStillUnknown()) throw secureStoreUnavailableError();
4555
+ return s.identity.getAnonymousId();
3885
4556
  },
3886
4557
  onAuthStateChange: (cb) => {
3887
4558
  authSubscribers.add(cb);
@@ -3893,17 +4564,19 @@ const Amba = {
3893
4564
  users: {
3894
4565
  get: async (userId) => {
3895
4566
  const s = getState();
3896
- if (!s.identity.getSessionToken()) throw new AmbaApiError("UNAUTHORIZED", "no active session — call signIn* first");
4567
+ await settleSessionInFlight(s);
4568
+ requireSession(s);
3897
4569
  const path = userId === void 0 || userId === null || userId === "" ? P.usersMe : `${P.users}/${encodePathSegment(userId)}`;
3898
4570
  return normalizeUserAuthProviders((await s.http.get(path)).data);
3899
4571
  },
3900
- update: ((arg1, arg2) => {
4572
+ update: (async (arg1, arg2) => {
3901
4573
  const s = getState();
3902
- if (!s.identity.getSessionToken()) throw new AmbaApiError("UNAUTHORIZED", "no active session — call signIn* first");
4574
+ await settleSessionInFlight(s);
4575
+ requireSession(s);
3903
4576
  const isById = typeof arg1 === "string";
3904
4577
  const patch = isById ? arg2 ?? {} : arg1;
3905
4578
  const path = isById ? `${P.users}/${encodePathSegment(arg1)}` : `${P.users}/me`;
3906
- return s.http.patch(path, patch).then((env) => normalizeUserAuthProviders(env.data));
4579
+ return normalizeUserAuthProviders((await s.http.patch(path, patch)).data);
3907
4580
  })
3908
4581
  },
3909
4582
  sessions: {