@layers/amba-react-native 4.0.11 → 4.0.14

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
@@ -88,6 +88,8 @@ var AmbaApiError = class extends Error {
88
88
  //#region src/http-client.ts
89
89
  /** Default amba API endpoint. */
90
90
  const DEFAULT_BASE_URL = "https://api.amba.dev";
91
+ /** Server codes for a session token the server no longer accepts. */
92
+ const SESSION_REFUSED_CODES = new Set(["SESSION_REVOKED", "INVALID_SESSION_TOKEN"]);
91
93
  var AmbaHttpClient = class {
92
94
  apiKey;
93
95
  baseUrl;
@@ -96,6 +98,7 @@ var AmbaHttpClient = class {
96
98
  identity;
97
99
  fetchImpl;
98
100
  ensureSession;
101
+ onSessionRefused;
99
102
  constructor(opts) {
100
103
  this.apiKey = opts.apiKey;
101
104
  this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
@@ -103,6 +106,7 @@ var AmbaHttpClient = class {
103
106
  this.sdkVersionHeader = opts.sdkVersionHeader;
104
107
  this.identity = opts.identity;
105
108
  this.ensureSession = opts.ensureSession;
109
+ this.onSessionRefused = opts.onSessionRefused;
106
110
  this.fetchImpl = opts.fetchImpl ?? globalThis.fetch.bind(globalThis);
107
111
  }
108
112
  /** Build the absolute URL for an API path, optionally with a query string. */
@@ -148,11 +152,13 @@ var AmbaHttpClient = class {
148
152
  async delete(path) {
149
153
  return this.request("DELETE", path);
150
154
  }
151
- async request(method, path, body, query, sessionToken) {
155
+ async request(method, path, body, query, sessionToken, resent = false) {
152
156
  if (this.ensureSession && !sessionToken && !this.identity.getSessionToken()) await this.ensureSession(path);
153
157
  const url = this.url(path, query);
154
158
  const headers = this.buildHeaders();
155
159
  if (sessionToken) headers.Authorization = `Bearer ${sessionToken}`;
160
+ const storedToken = sessionToken ? null : this.identity.getSessionToken();
161
+ const storedUserId = storedToken ? this.identity.getAppUserId() : null;
156
162
  const init = {
157
163
  method,
158
164
  headers,
@@ -164,7 +170,20 @@ var AmbaHttpClient = class {
164
170
  } catch (err) {
165
171
  throw new AmbaApiError("NETWORK_ERROR", `Network error: ${err instanceof Error ? err.message : typeof err === "string" ? err : "fetch failed"}`, err);
166
172
  }
167
- return parseResponse(response);
173
+ try {
174
+ return await parseResponse(response);
175
+ } catch (err) {
176
+ if (!resent && storedToken && this.onSessionRefused && !path.startsWith("/v1/client/auth/") && err instanceof AmbaApiError && SESSION_REFUSED_CODES.has(err.code) && await this.recoverSession(storedToken, storedUserId)) return this.request(method, path, body, query, void 0, true);
177
+ throw err;
178
+ }
179
+ }
180
+ /** Run the `onSessionRefused` hook; a hook that throws counts as false. */
181
+ async recoverSession(usedToken, usedUserId) {
182
+ try {
183
+ return await this.onSessionRefused?.(usedToken, usedUserId) === true;
184
+ } catch {
185
+ return false;
186
+ }
168
187
  }
169
188
  };
170
189
  async function parseResponse(response) {
@@ -221,13 +240,31 @@ function mapHttpError(response, rawBody, isJson) {
221
240
 
222
241
  //#endregion
223
242
  //#region src/uuid.ts
243
+ /** Thrown when the runtime has no `crypto.getRandomValues`. */
244
+ var SecureRandomUnavailableError = class extends Error {
245
+ code = "SECURE_RANDOM_UNAVAILABLE";
246
+ constructor() {
247
+ super("Amba needs crypto.getRandomValues to generate identifiers, and this runtime does not provide it. Install react-native-get-random-values and import it on the first line of your app's entry file, before any Amba import: import 'react-native-get-random-values';");
248
+ this.name = "SecureRandomUnavailableError";
249
+ }
250
+ };
251
+ function secureGetRandomValues() {
252
+ const crypto = globalThis.crypto;
253
+ const grv = crypto?.getRandomValues;
254
+ if (!crypto || typeof grv !== "function") throw new SecureRandomUnavailableError();
255
+ return grv.bind(crypto);
256
+ }
257
+ /**
258
+ * Throws `SecureRandomUnavailableError` when the runtime cannot generate
259
+ * secure identifiers. `IdentityStore.create` calls it so a missing polyfill
260
+ * surfaces at `configure()`, before any identifier is needed.
261
+ */
262
+ function assertSecureRandomAvailable() {
263
+ secureGetRandomValues();
264
+ }
224
265
  function getRandomBytes(n) {
225
266
  const bytes = new Uint8Array(n);
226
- if (typeof globalThis.crypto?.getRandomValues === "function") {
227
- globalThis.crypto.getRandomValues(bytes);
228
- return bytes;
229
- }
230
- for (let i = 0; i < n; i++) bytes[i] = Math.floor(Math.random() * 256);
267
+ secureGetRandomValues()(bytes);
231
268
  return bytes;
232
269
  }
233
270
  /**
@@ -246,6 +283,30 @@ function uuidv4() {
246
283
  //#region src/identity.ts
247
284
  /** Storage key under the configured prefix. Bumped when shape changes. */
248
285
  const IDENTITY_KEY = "identity_v1";
286
+ /**
287
+ * Whether a vault entry belongs to this install's record. An entry another
288
+ * install wrote (`install_id`) never does. Each flush writes the vault and
289
+ * then the record, both stamped with the flush's sequence number. An entry
290
+ * of this install newer than the record comes from a flush that stopped
291
+ * between the two writes, so it holds the latest session. Otherwise the
292
+ * entry belongs only when the record vouches for it (`vault_seq`), which a
293
+ * sign-in or sign-out that couldn't write the vault withdraws. An entry
294
+ * written before 4.0.14 belongs while a record from then vouches for it and
295
+ * carries the same anonymous id and user.
296
+ */
297
+ function entryBelongs(entry, anonymousId, appUserId, installId, recordSeq, vaultSeq) {
298
+ const entrySeq = typeof entry.write_seq === "number" ? entry.write_seq : 0;
299
+ if (entrySeq === 0 || entry.install_id == null) {
300
+ const sameUser = entry.app_user_id == null || appUserId === null || entry.app_user_id === appUserId;
301
+ return vaultSeq === 0 && entry.anonymous_id === anonymousId && sameUser;
302
+ }
303
+ if (installId === null || entry.install_id !== installId) return false;
304
+ if (entrySeq > recordSeq) return true;
305
+ return entrySeq === vaultSeq;
306
+ }
307
+ function seqOf(value) {
308
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null;
309
+ }
249
310
  function isExpired(expiresAt) {
250
311
  if (!expiresAt) return false;
251
312
  const t = Date.parse(expiresAt);
@@ -263,6 +324,7 @@ var IdentityStore = class IdentityStore {
263
324
  sessionToken = null;
264
325
  refreshToken = null;
265
326
  expiresAt = null;
327
+ anonymousSession = null;
266
328
  /**
267
329
  * Credentials an earlier SDK version left in the AsyncStorage record, with
268
330
  * the user they belong to. They stay on disk until the vault holds the
@@ -275,16 +337,44 @@ var IdentityStore = class IdentityStore {
275
337
  * succeeds, so a session this launch never loaded is not overwritten.
276
338
  */
277
339
  vaultUnreadable;
278
- constructor(persistence, seed, vault, plaintext, vaultUnreadable) {
340
+ /** Highest flush sequence number seen or used. */
341
+ seq;
342
+ /** `write_seq` of the stored record. */
343
+ recordSeq;
344
+ /** `vault_seq` of the stored record (see `PersistedIdentity`). */
345
+ vaultSeq;
346
+ /**
347
+ * The session the vouched vault entry holds, by refresh token and user. A
348
+ * failed or held vault write withdraws the vouch only when the session
349
+ * changed from it, so a link that only moves the anonymous id keeps it.
350
+ */
351
+ vaultHolds;
352
+ /** Serializes flushes, so the vault and the record stay in write order. */
353
+ flushChain = Promise.resolve();
354
+ /** This install's id (see `PersistedIdentity.install_id`). */
355
+ installId;
356
+ /** Whether the stored record carries `installId`. */
357
+ installIdStored;
358
+ constructor(persistence, seed, vault, plaintext, vaultUnreadable, seq) {
279
359
  this.persistence = persistence;
280
360
  this.vault = vault;
281
361
  this.plaintext = plaintext;
282
362
  this.vaultUnreadable = vaultUnreadable;
363
+ this.seq = seq;
364
+ this.installId = seed.install_id ?? uuidv4();
365
+ this.installIdStored = seed.install_id !== null;
366
+ this.recordSeq = seed.write_seq;
367
+ this.vaultSeq = seed.vault_seq;
368
+ this.vaultHolds = {
369
+ refreshToken: seed.refresh_token,
370
+ appUserId: seed.app_user_id
371
+ };
283
372
  this.anonymousId = seed.anonymous_id;
284
373
  this.appUserId = seed.app_user_id;
285
374
  this.sessionToken = seed.session_token;
286
375
  this.refreshToken = seed.refresh_token;
287
376
  this.expiresAt = seed.expires_at;
377
+ this.anonymousSession = seed.anonymous_session;
288
378
  }
289
379
  /**
290
380
  * Build a store, hydrating any prior identity from AsyncStorage and the
@@ -296,8 +386,13 @@ var IdentityStore = class IdentityStore {
296
386
  * AsyncStorage record are used for this launch and moved into the vault;
297
387
  * the AsyncStorage copy is removed once the vault write succeeds. A vault
298
388
  * holding its own credentials wins over them.
389
+ *
390
+ * Throws `SecureRandomUnavailableError` when the runtime has no
391
+ * `crypto.getRandomValues`: every anonymous id the store mints must come
392
+ * from a secure generator.
299
393
  */
300
394
  static async create(persistence, vault = null) {
395
+ assertSecureRandomAvailable();
301
396
  const raw = await persistence.read(IDENTITY_KEY);
302
397
  let recordPresent = false;
303
398
  let seed;
@@ -308,7 +403,11 @@ var IdentityStore = class IdentityStore {
308
403
  app_user_id: parsed.app_user_id ?? null,
309
404
  session_token: parsed.session_token ?? null,
310
405
  refresh_token: parsed.refresh_token ?? null,
311
- expires_at: parsed.expires_at ?? null
406
+ expires_at: parsed.expires_at ?? null,
407
+ anonymous_session: typeof parsed.anonymous_session === "boolean" ? parsed.anonymous_session : null,
408
+ write_seq: seqOf(parsed.write_seq) ?? 0,
409
+ vault_seq: parsed.vault_seq === void 0 ? 0 : seqOf(parsed.vault_seq),
410
+ install_id: typeof parsed.install_id === "string" ? parsed.install_id : null
312
411
  };
313
412
  recordPresent = typeof parsed.anonymous_id === "string";
314
413
  } catch {
@@ -324,6 +423,7 @@ var IdentityStore = class IdentityStore {
324
423
  }
325
424
  } : null;
326
425
  let vaultReadable = true;
426
+ let seq = seed.write_seq;
327
427
  if (vault) {
328
428
  let fromVault = null;
329
429
  try {
@@ -331,20 +431,28 @@ var IdentityStore = class IdentityStore {
331
431
  } catch {
332
432
  vaultReadable = false;
333
433
  }
334
- const belongs = fromVault !== null && recordPresent && fromVault.anonymous_id === seed.anonymous_id;
434
+ if (fromVault) seq = Math.max(seq, seqOf(fromVault.write_seq) ?? 0);
435
+ const belongs = fromVault !== null && recordPresent && entryBelongs(fromVault, seed.anonymous_id, seed.app_user_id, seed.install_id, seed.write_seq, seed.vault_seq);
335
436
  if (fromVault && belongs) {
336
437
  seed.session_token = fromVault.session_token;
337
438
  seed.refresh_token = fromVault.refresh_token;
338
439
  seed.expires_at = fromVault.expires_at;
339
440
  if (fromVault.app_user_id != null) seed.app_user_id = fromVault.app_user_id;
441
+ } else if (fromVault) {
442
+ seed.write_seq = Math.max(seed.write_seq, seqOf(fromVault.write_seq) ?? 0);
443
+ seed.vault_seq = null;
340
444
  }
341
445
  }
342
- if (vaultReadable && seed.session_token === null && seed.refresh_token === null) seed.app_user_id = null;
446
+ if (vaultReadable && seed.session_token === null && seed.refresh_token === null) {
447
+ if (seed.app_user_id !== null && seed.anonymous_session === false) seed.anonymous_id = uuidv4();
448
+ seed.app_user_id = null;
449
+ seed.anonymous_session = null;
450
+ }
343
451
  if (isExpired(seed.expires_at)) {
344
452
  seed.session_token = null;
345
453
  seed.expires_at = null;
346
454
  }
347
- const store = new IdentityStore(persistence, seed, vault, plaintext, !vaultReadable);
455
+ const store = new IdentityStore(persistence, seed, vault, plaintext, !vaultReadable, seq);
348
456
  await store.flush({ hydrating: true });
349
457
  return store;
350
458
  }
@@ -354,7 +462,43 @@ var IdentityStore = class IdentityStore {
354
462
  appUserId: this.appUserId,
355
463
  sessionToken: this.sessionToken,
356
464
  refreshToken: this.refreshToken,
357
- expiresAt: this.expiresAt
465
+ expiresAt: this.expiresAt,
466
+ anonymousSession: this.anonymousSession
467
+ };
468
+ }
469
+ /**
470
+ * Whether the vault still can't be read. When the launch read failed, the
471
+ * vault is read again first, and a session it holds for this install is
472
+ * loaded (same rule as `create`). While it can't be read, the stored
473
+ * session is unknown: callers must not replace it with a new user.
474
+ */
475
+ async vaultStillUnreadable() {
476
+ if (!this.vault || !this.vaultUnreadable) return false;
477
+ let stored;
478
+ try {
479
+ stored = await this.vault.read();
480
+ } catch {
481
+ return true;
482
+ }
483
+ this.vaultUnreadable = false;
484
+ this.noteStoredSeq(stored);
485
+ if (this.sessionToken === null && this.refreshToken === null) this.adoptStored(stored);
486
+ return false;
487
+ }
488
+ /** Keep the next sequence number above an entry read from the vault. */
489
+ noteStoredSeq(stored) {
490
+ if (stored) this.seq = Math.max(this.seq, seqOf(stored.write_seq) ?? 0);
491
+ }
492
+ /** Load a stored vault session that belongs to this install. */
493
+ adoptStored(stored) {
494
+ if (!stored || stored.session_token === null && stored.refresh_token === null || !entryBelongs(stored, this.anonymousId, this.appUserId, this.installIdStored ? this.installId : null, this.recordSeq, this.vaultSeq)) return;
495
+ this.sessionToken = isExpired(stored.expires_at) ? null : stored.session_token;
496
+ this.refreshToken = stored.refresh_token;
497
+ this.expiresAt = isExpired(stored.expires_at) ? null : stored.expires_at;
498
+ if (stored.app_user_id != null) this.appUserId = stored.app_user_id;
499
+ this.vaultHolds = {
500
+ refreshToken: this.refreshToken,
501
+ appUserId: this.appUserId
358
502
  };
359
503
  }
360
504
  getAnonymousId() {
@@ -372,19 +516,53 @@ var IdentityStore = class IdentityStore {
372
516
  isAuthenticated() {
373
517
  return this.sessionToken !== null;
374
518
  }
519
+ /**
520
+ * Store a session. `anonymous` records whether it is a purely anonymous
521
+ * one (true only for `/v1/client/auth/anonymous`); omit it to keep the
522
+ * current value (a token refresh of the same session).
523
+ */
375
524
  async setSession(args) {
376
525
  this.appUserId = args.appUserId;
377
526
  this.sessionToken = args.sessionToken;
378
527
  this.refreshToken = args.refreshToken;
379
528
  this.expiresAt = args.expiresAt;
529
+ if (args.anonymous !== void 0) this.anonymousSession = args.anonymous;
530
+ await this.flush();
531
+ }
532
+ /** The current session gained an identity (a link). */
533
+ async markIdentified() {
534
+ if (this.anonymousSession === false) return;
535
+ this.anonymousSession = false;
536
+ await this.flush();
537
+ }
538
+ /**
539
+ * Adopt the anonymous id the server holds for this install's user: the id
540
+ * an anonymous sign-in answered with, or the fresh id a link gave the user.
541
+ */
542
+ async setAnonymousId(anonymousId) {
543
+ if (anonymousId === this.anonymousId) return;
544
+ this.anonymousId = anonymousId;
380
545
  await this.flush();
381
546
  }
382
- async clearSession(rotateAnonymousId) {
547
+ /**
548
+ * End the session. The anonymous id is rotated when `rotateAnonymousId`
549
+ * is set, and whenever the session being ended was not purely anonymous:
550
+ * that id then identifies an account with a real sign-in, and the next
551
+ * person on the device must start fresh. A session of unknown kind (stored
552
+ * by an SDK before 4.0.14) counts as identified, unless
553
+ * `keepIdWhenKindUnknown` is set (the server decides on the next
554
+ * anonymous sign-in).
555
+ */
556
+ async clearSession(rotateAnonymousId, opts = {}) {
557
+ const hadSession = this.sessionToken !== null || this.refreshToken !== null;
558
+ const identified = this.anonymousSession === false || this.anonymousSession === null && !opts.keepIdWhenKindUnknown;
559
+ const nextAnonymousId = rotateAnonymousId || hadSession && identified ? uuidv4() : this.anonymousId;
383
560
  this.appUserId = null;
384
561
  this.sessionToken = null;
385
562
  this.refreshToken = null;
386
563
  this.expiresAt = null;
387
- if (rotateAnonymousId) this.anonymousId = uuidv4();
564
+ this.anonymousSession = null;
565
+ this.anonymousId = nextAnonymousId;
388
566
  await this.flush({ signingOut: true });
389
567
  }
390
568
  /**
@@ -394,55 +572,107 @@ var IdentityStore = class IdentityStore {
394
572
  * from `clearSession`: when the vault can't be emptied, the install moves
395
573
  * to a new anonymous id so the entry left behind no longer belongs to it.
396
574
  */
397
- async flush(opts) {
575
+ flush(opts) {
576
+ const run = this.flushChain.then(() => this.flushNow(opts));
577
+ this.flushChain = run.catch(() => void 0);
578
+ return run;
579
+ }
580
+ async flushNow(opts) {
398
581
  if (!this.vault) {
582
+ const seq$1 = ++this.seq;
583
+ const recordSeq$1 = opts?.hydrating ? this.recordSeq : seq$1;
399
584
  const payload = {
400
585
  anonymous_id: this.anonymousId,
401
586
  app_user_id: this.appUserId,
402
587
  session_token: this.sessionToken,
403
588
  refresh_token: this.refreshToken,
404
- expires_at: this.expiresAt
589
+ expires_at: this.expiresAt,
590
+ anonymous_session: this.anonymousSession,
591
+ write_seq: recordSeq$1,
592
+ vault_seq: null,
593
+ install_id: this.installId
405
594
  };
406
595
  await this.persistence.write(IDENTITY_KEY, JSON.stringify(payload));
596
+ this.recordSeq = recordSeq$1;
597
+ this.installIdStored = true;
407
598
  return;
408
599
  }
409
- let saved = false;
410
600
  if (this.vaultUnreadable) try {
411
601
  const stored = await this.vault.read();
412
602
  this.vaultUnreadable = false;
413
- if (opts?.hydrating && stored && (stored.session_token !== null || stored.refresh_token !== null) && stored.anonymous_id === this.anonymousId) {
414
- this.sessionToken = isExpired(stored.expires_at) ? null : stored.session_token;
415
- this.refreshToken = stored.refresh_token;
416
- this.expiresAt = isExpired(stored.expires_at) ? null : stored.expires_at;
417
- if (stored.app_user_id != null) this.appUserId = stored.app_user_id;
418
- }
603
+ this.noteStoredSeq(stored);
604
+ if (opts?.hydrating) this.adoptStored(stored);
419
605
  } catch {}
420
- if (!this.vaultUnreadable) try {
421
- await this.vault.write({
422
- session_token: this.sessionToken,
423
- refresh_token: this.refreshToken,
424
- expires_at: this.expiresAt,
425
- anonymous_id: this.anonymousId,
426
- app_user_id: this.appUserId
427
- });
606
+ const seq = ++this.seq;
607
+ if (opts?.signingOut) {
608
+ this.vaultSeq = null;
609
+ try {
610
+ await this.persistence.write(IDENTITY_KEY, JSON.stringify(this.recordFor(seq, false)));
611
+ this.recordSeq = seq;
612
+ this.installIdStored = true;
613
+ } catch {}
614
+ }
615
+ let holdVaultWrite = false;
616
+ if (!this.installIdStored && opts?.hydrating && !this.vaultUnreadable) try {
617
+ await this.persistence.write(IDENTITY_KEY, JSON.stringify(this.recordFor(this.recordSeq, false)));
618
+ this.installIdStored = true;
619
+ } catch {
620
+ holdVaultWrite = true;
621
+ }
622
+ const slice = {
623
+ session_token: this.sessionToken,
624
+ refresh_token: this.refreshToken,
625
+ expires_at: this.expiresAt,
626
+ anonymous_id: this.anonymousId,
627
+ app_user_id: this.appUserId,
628
+ write_seq: seq,
629
+ install_id: this.installId
630
+ };
631
+ let saved = false;
632
+ if (!this.vaultUnreadable && !holdVaultWrite) try {
633
+ await this.vault.write(slice);
428
634
  saved = true;
635
+ this.vaultSeq = seq;
636
+ this.vaultHolds = {
637
+ refreshToken: slice.refresh_token,
638
+ appUserId: slice.app_user_id ?? null
639
+ };
429
640
  } catch (err) {
430
641
  if (typeof console !== "undefined") console.warn("[amba] could not save the session to the secure store", err);
431
642
  }
643
+ const vaultHasLive = this.vaultHolds.refreshToken === this.refreshToken && this.vaultHolds.appUserId === this.appUserId;
644
+ if (opts?.signingOut && !saved || !vaultHasLive) this.vaultSeq = null;
432
645
  if (opts?.signingOut && !saved) {
433
646
  this.anonymousId = uuidv4();
434
647
  if (typeof console !== "undefined") console.warn("[amba] could not clear the secure store at sign-out; starting a new anonymous id so the stored session can't be restored");
435
648
  }
649
+ const recordSeq = opts?.hydrating && !saved ? this.recordSeq : seq;
650
+ const record = this.recordFor(recordSeq, saved && vaultHasLive);
651
+ await this.persistence.write(IDENTITY_KEY, JSON.stringify(record));
652
+ this.recordSeq = recordSeq;
653
+ this.installIdStored = true;
654
+ if (record.refresh_token === null) this.plaintext = null;
655
+ }
656
+ /**
657
+ * The AsyncStorage record for a vault in use. It keeps the earlier
658
+ * version's plaintext copy only while that copy is still the live session
659
+ * (same user, same refresh token) and the vault doesn't hold it yet
660
+ * (`saved`). Sign-out, another user or a refresh (which retires the old
661
+ * refresh token) drops it.
662
+ */
663
+ recordFor(recordSeq, saved) {
436
664
  const keep = !saved && this.plaintext !== null && this.refreshToken !== null && this.plaintext.appUserId === this.appUserId && this.plaintext.slice.refresh_token === this.refreshToken ? this.plaintext.slice : null;
437
- const record = {
665
+ return {
438
666
  anonymous_id: this.anonymousId,
439
667
  app_user_id: this.appUserId,
440
668
  session_token: keep?.session_token ?? null,
441
669
  refresh_token: keep?.refresh_token ?? null,
442
- expires_at: keep?.expires_at ?? null
670
+ expires_at: keep?.expires_at ?? null,
671
+ anonymous_session: this.anonymousSession,
672
+ write_seq: recordSeq,
673
+ vault_seq: this.vaultSeq,
674
+ install_id: this.installId
443
675
  };
444
- await this.persistence.write(IDENTITY_KEY, JSON.stringify(record));
445
- if (!keep) this.plaintext = null;
446
676
  }
447
677
  };
448
678
  function freshSeed() {
@@ -451,7 +681,11 @@ function freshSeed() {
451
681
  app_user_id: null,
452
682
  session_token: null,
453
683
  refresh_token: null,
454
- expires_at: null
684
+ expires_at: null,
685
+ anonymous_session: null,
686
+ write_seq: 0,
687
+ vault_seq: null,
688
+ install_id: null
455
689
  };
456
690
  }
457
691
 
@@ -510,7 +744,9 @@ var SecureTokenVault = class {
510
744
  refresh_token: parsed.refresh_token ?? null,
511
745
  expires_at: parsed.expires_at ?? null,
512
746
  anonymous_id: parsed.anonymous_id ?? null,
513
- app_user_id: parsed.app_user_id ?? null
747
+ app_user_id: parsed.app_user_id ?? null,
748
+ write_seq: parsed.write_seq ?? null,
749
+ install_id: parsed.install_id ?? null
514
750
  };
515
751
  } catch {
516
752
  return null;
@@ -518,7 +754,15 @@ var SecureTokenVault = class {
518
754
  }
519
755
  async write(slice) {
520
756
  if (isEmpty(slice)) {
521
- await this.storage.removeItem(this.key);
757
+ try {
758
+ await this.storage.removeItem(this.key);
759
+ } catch (err) {
760
+ try {
761
+ await this.storage.setItem(this.key, JSON.stringify(slice));
762
+ } catch {
763
+ throw err;
764
+ }
765
+ }
522
766
  return;
523
767
  }
524
768
  await this.storage.setItem(this.key, JSON.stringify(slice));
@@ -864,6 +1108,25 @@ var OfflineQueue = class {
864
1108
  await this.clear();
865
1109
  }
866
1110
  /**
1111
+ * Give the ops queued before any session under `anonymousId` to the
1112
+ * signed-in `appUserId`. Those ops replay under the session that holds
1113
+ * their anonymous id; the SDK calls this before that session's anonymous
1114
+ * id changes (a link, or adopting the account's id), so they keep an owner
1115
+ * that matches.
1116
+ */
1117
+ async claimAnonymousOps(anonymousId, appUserId) {
1118
+ await this.mutate((ops) => ({
1119
+ next: ops.map((op) => op.owner && op.owner.appUserId === null && op.owner.anonymousId === anonymousId ? {
1120
+ ...op,
1121
+ owner: {
1122
+ appUserId,
1123
+ anonymousId
1124
+ }
1125
+ } : op),
1126
+ result: void 0
1127
+ }));
1128
+ }
1129
+ /**
867
1130
  * Replay the current owner's ops in FIFO order. Each op is removed only
868
1131
  * after it succeeds; the first failure stops the flush and retains the
869
1132
  * remainder (so order is preserved and nothing is dropped mid-batch). Ops
@@ -1817,7 +2080,7 @@ function delay(ms) {
1817
2080
  //#endregion
1818
2081
  //#region src/index.ts
1819
2082
  /** SDK semver — burned in at build time. Bump alongside package.json. */
1820
- const SDK_VERSION = "4.0.11";
2083
+ const SDK_VERSION = "4.0.14";
1821
2084
  /**
1822
2085
  * Default host that serves deployed functions. `Amba.functions.invoke(name)`
1823
2086
  * targets `https://<projectId>.<DEFAULT_FUNCTIONS_HOST>/<name>` unless a
@@ -1929,21 +2192,52 @@ function expiresAtFromResult(result) {
1929
2192
  if (result.expires_at && typeof result.expires_at === "string") return result.expires_at;
1930
2193
  return new Date(Date.now() + 3600 * 1e3).toISOString();
1931
2194
  }
1932
- async function persistAuthResult(state_, result) {
2195
+ async function persistAuthResult(state_, result, anonymous) {
1933
2196
  await state_.identity.setSession({
1934
2197
  appUserId: result.user.id,
1935
2198
  sessionToken: result.session_token,
1936
2199
  refreshToken: result.refresh_token,
1937
- expiresAt: expiresAtFromResult(result)
2200
+ expiresAt: expiresAtFromResult(result),
2201
+ anonymous
1938
2202
  });
1939
2203
  }
2204
+ function nonEmptyString(value) {
2205
+ return typeof value === "string" && value.length > 0 ? value : null;
2206
+ }
2207
+ /**
2208
+ * POST to an endpoint that answers with a session pair, and store it.
2209
+ * An anonymous sign-in records a purely anonymous session and adopts the
2210
+ * anonymous id the server answered with (the server mints one when the
2211
+ * request carried none). A link adopts the account's rotated anonymous id.
2212
+ * A refresh keeps the session's kind; every other sign-in records an
2213
+ * identified session.
2214
+ */
1940
2215
  async function postAuth(state_, path, body) {
1941
2216
  const result = normalizeAuthResult((await state_.http.post(path, body)).data);
1942
- await persistAuthResult(state_, result);
2217
+ await persistAuthResult(state_, result, path === P.authAnonymous ? true : path === P.authRefresh ? void 0 : false);
2218
+ const adoptedAnonymousId = path === P.authAnonymous ? nonEmptyString(result.anonymous_id) ?? nonEmptyString(result.user?.anonymous_id) : path === P.authLink ? nonEmptyString(result.user?.anonymous_id) : null;
2219
+ if (adoptedAnonymousId) await adoptAnonymousId(state_, adoptedAnonymousId);
1943
2220
  state_.eventQueue?.flush();
1944
2221
  await notifyFromAuthResult(result);
1945
2222
  return result;
1946
2223
  }
2224
+ /**
2225
+ * Adopt the anonymous id the server holds for the signed-in user. Offline
2226
+ * writes queued before any session under the old id would replay under this
2227
+ * session; they are handed to the signed-in user first, so the id change
2228
+ * leaves them replayable.
2229
+ */
2230
+ async function adoptAnonymousId(state_, anonymousId) {
2231
+ const previous = state_.identity.getAnonymousId();
2232
+ if (anonymousId === previous) return;
2233
+ const appUserId = state_.identity.getAppUserId();
2234
+ if (appUserId && state_.offlineEnabled) try {
2235
+ await getOfflineQueue(state_).claimAnonymousOps(previous, appUserId);
2236
+ } catch (err) {
2237
+ if (typeof console !== "undefined") console.warn("[amba] could not reassign queued offline writes", err);
2238
+ }
2239
+ await state_.identity.setAnonymousId(anonymousId);
2240
+ }
1947
2241
  /** Paths that must NOT trigger lazy anonymous sign-in. */
1948
2242
  function pathSkipsLazySession(path) {
1949
2243
  if (path.startsWith("/v1/client/auth/")) return true;
@@ -1955,9 +2249,14 @@ function pathSkipsLazySession(path) {
1955
2249
  }
1956
2250
  async function ensureAnonymousSession(state_) {
1957
2251
  if (state_.identity.getSessionToken()) return;
2252
+ if (state_.anonSignInInflight) {
2253
+ await state_.anonSignInInflight.catch(() => {});
2254
+ if (state_.identity.getSessionToken()) return;
2255
+ }
1958
2256
  if (!state_.anonSignInInflight) state_.anonSignInInflight = (async () => {
1959
2257
  try {
1960
- await postAuth(state_, P.authAnonymous, { anonymous_id: state_.identity.getAnonymousId() });
2258
+ if (await resumeWithRefreshToken(state_)) return;
2259
+ await signInAnonymouslyFresh(state_);
1961
2260
  } finally {
1962
2261
  state_.anonSignInInflight = null;
1963
2262
  }
@@ -1965,10 +2264,218 @@ async function ensureAnonymousSession(state_) {
1965
2264
  await state_.anonSignInInflight;
1966
2265
  }
1967
2266
  /**
1968
- * POST helper for endpoints that return `{ data: { user } }` WITHOUT
1969
- * minting new tokens. Used by `linkSmsOtp` / `linkEmailOtp` / `unlink*`
1970
- * — these are "decorate the current user," not "re-authenticate."
1971
- * Existing session tokens stay untouched.
2267
+ * Restore a stored session whose session token expired while the app was
2268
+ * closed, using its refresh token, so `isAuthenticated` and
2269
+ * `onAuthStateChange` show the signed-in user without waiting for an
2270
+ * authenticated call. Shares the in-flight slot with the lazy sign-in, so a
2271
+ * concurrent authenticated call waits for it. A rejected refresh token ends
2272
+ * the session (subscribers hear `null`); this never signs in anonymously.
2273
+ */
2274
+ function restoreStoredSession(state_) {
2275
+ if (!state_.anonSignInInflight) state_.anonSignInInflight = (async () => {
2276
+ try {
2277
+ await resumeWithRefreshToken(state_);
2278
+ } finally {
2279
+ state_.anonSignInInflight = null;
2280
+ }
2281
+ })();
2282
+ return state_.anonSignInInflight;
2283
+ }
2284
+ /**
2285
+ * Server answers that mean the refresh token's session is over. A 401
2286
+ * without the server's envelope (a proxy, a captive portal) maps to
2287
+ * `UNAUTHORIZED` and leaves the session in place.
2288
+ */
2289
+ const SESSION_ENDED_CODES = new Set(["INVALID_TOKEN", "SESSION_REVOKED"]);
2290
+ function isSessionEnded(err) {
2291
+ return err instanceof AmbaApiError && SESSION_ENDED_CODES.has(err.code);
2292
+ }
2293
+ /** Record a link in flight until it settles; returns the same promise. */
2294
+ function trackLink(state_, link) {
2295
+ const links = state_.linksInflight ??= /* @__PURE__ */ new Set();
2296
+ links.add(link);
2297
+ const settle = () => {
2298
+ links.delete(link);
2299
+ };
2300
+ link.then(settle, settle);
2301
+ return link;
2302
+ }
2303
+ /**
2304
+ * After the server refused the stored session token on an authenticated
2305
+ * call (`SESSION_REVOKED`, `INVALID_SESSION_TOKEN`). True when a usable
2306
+ * session for the user the call was sent for is stored afterwards, so the
2307
+ * call is sent once more as that user:
2308
+ * - a link in flight on this device stores the pair it returns, which is
2309
+ * waited for first;
2310
+ * - a session another flow stored after the call left is used as is;
2311
+ * - otherwise the refresh token continues the session. A rejected refresh
2312
+ * token ends it (subscribers hear `null`) and the call's own error
2313
+ * reaches the app.
2314
+ * A session of any other user (a sign-in meanwhile, or the anonymous user
2315
+ * a lazy sign-in starts after the session ended) answers false, and the
2316
+ * call fails with the server's code. Offline or a server error keeps the
2317
+ * session and answers false. The
2318
+ * refresh runs in the in-flight slot the launch restore and the lazy
2319
+ * sign-in share, so calls refused together end or continue the session
2320
+ * once.
2321
+ */
2322
+ async function recoverRefusedSession(state_, usedToken, usedUserId) {
2323
+ if (usedUserId === null) return false;
2324
+ const usableForCaller = (token) => token !== null && token !== usedToken && state_.identity.getAppUserId() === usedUserId;
2325
+ if (state_.linksInflight && state_.linksInflight.size > 0) await Promise.allSettled(state_.linksInflight);
2326
+ const current = state_.identity.getSessionToken();
2327
+ if (current !== usedToken) return usableForCaller(current);
2328
+ if (!state_.anonSignInInflight) state_.anonSignInInflight = (async () => {
2329
+ try {
2330
+ await resumeWithRefreshToken(state_);
2331
+ } catch {} finally {
2332
+ state_.anonSignInInflight = null;
2333
+ }
2334
+ })();
2335
+ await state_.anonSignInInflight.catch(() => {});
2336
+ return usableForCaller(state_.identity.getSessionToken());
2337
+ }
2338
+ /**
2339
+ * Continue the stored session with its refresh token. True when it did.
2340
+ * False when there is no refresh token, or the server rejected it: the
2341
+ * session is then cleared (the anonymous id rotates unless the session was
2342
+ * purely anonymous) and subscribers hear the sign-out. Any other failure
2343
+ * (offline, a 5xx) is thrown, so a flaky network never starts a new user.
2344
+ */
2345
+ async function resumeWithRefreshToken(state_) {
2346
+ const used = state_.identity.getRefreshToken();
2347
+ if (!used) return false;
2348
+ try {
2349
+ await refreshSession(state_);
2350
+ return true;
2351
+ } catch (err) {
2352
+ if (state_.identity.getRefreshToken() !== used) return state_.identity.getSessionToken() !== null;
2353
+ if (!isSessionEnded(err)) throw err;
2354
+ await state_.identity.clearSession(false, { keepIdWhenKindUnknown: true });
2355
+ notifyAuthSubscribers(null);
2356
+ return false;
2357
+ }
2358
+ }
2359
+ /**
2360
+ * `/v1/client/auth/anonymous` with the device's anonymous id. When the
2361
+ * server answers `IDENTITY_SIGN_IN_REQUIRED` (the id belongs to a user who
2362
+ * signs in with a linked identity), the device takes a new anonymous id and
2363
+ * signs in once more as a new anonymous user.
2364
+ */
2365
+ async function signInAnonymouslyFresh(state_) {
2366
+ try {
2367
+ return await postAuth(state_, P.authAnonymous, { anonymous_id: state_.identity.getAnonymousId() });
2368
+ } catch (err) {
2369
+ if (!(err instanceof AmbaApiError) || err.code !== IDENTITY_SIGN_IN_REQUIRED) throw err;
2370
+ return recoverFromHeldAnonymousId(state_);
2371
+ }
2372
+ }
2373
+ const IDENTITY_SIGN_IN_REQUIRED = "IDENTITY_SIGN_IN_REQUIRED";
2374
+ /**
2375
+ * After `/anonymous` answered `IDENTITY_SIGN_IN_REQUIRED`: continue the
2376
+ * stored session when this device holds its refresh token, and otherwise
2377
+ * take a new anonymous id and sign in as a new anonymous user. While the
2378
+ * secure store can't be read (an iOS device not yet unlocked since a
2379
+ * restart), the stored session is unknown, so the call fails with
2380
+ * `SECURE_STORE_UNAVAILABLE` and replaces nothing.
2381
+ */
2382
+ async function recoverFromHeldAnonymousId(state_) {
2383
+ 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");
2384
+ const used = state_.identity.getRefreshToken();
2385
+ if (used) try {
2386
+ return await refreshSession(state_);
2387
+ } catch (refreshErr) {
2388
+ if (!isSessionEnded(refreshErr)) throw refreshErr;
2389
+ const current = state_.identity.getRefreshToken();
2390
+ if (current !== null && current !== used) throw refreshErr;
2391
+ }
2392
+ await state_.identity.clearSession(true);
2393
+ return postAuth(state_, P.authAnonymous, { anonymous_id: state_.identity.getAnonymousId() });
2394
+ }
2395
+ /**
2396
+ * Rotate the session pair with the stored refresh token. The session keeps
2397
+ * its user and its kind (anonymous or identified), and auth-state
2398
+ * subscribers hear the refreshed session. The server includes the user; an
2399
+ * older server that leaves it out keeps the stored user id, and the result's
2400
+ * user then carries only that id.
2401
+ */
2402
+ function refreshSession(state_) {
2403
+ if (state_.refreshInflight) return state_.refreshInflight;
2404
+ const inflight = refreshSessionOnce(state_);
2405
+ state_.refreshInflight = inflight;
2406
+ const release = () => {
2407
+ if (state_.refreshInflight === inflight) state_.refreshInflight = null;
2408
+ };
2409
+ inflight.then(release, release);
2410
+ return inflight;
2411
+ }
2412
+ async function refreshSessionOnce(state_) {
2413
+ const refreshToken = state_.identity.getRefreshToken();
2414
+ if (!refreshToken) throw new AmbaApiError("UNAUTHORIZED", "no refresh token available — call signIn* first");
2415
+ const env = await state_.http.post(P.authRefresh, { refresh_token: refreshToken });
2416
+ if (state_.identity.getRefreshToken() !== refreshToken) throw new AmbaApiError("SESSION_CHANGED", "the session changed while it was being refreshed");
2417
+ const appUserId = env.data.user?.id ?? state_.identity.getAppUserId();
2418
+ if (!appUserId) throw new AmbaApiError("UNAUTHORIZED", "the refreshed session names no user — call signIn* first");
2419
+ const user = env.data.user ? normalizeUserAuthProviders(env.data.user) : {
2420
+ id: appUserId,
2421
+ auth_providers: [],
2422
+ properties: {}
2423
+ };
2424
+ const result = {
2425
+ session_token: env.data.session_token,
2426
+ refresh_token: env.data.refresh_token,
2427
+ user,
2428
+ expires_at: env.data.expires_at ?? null
2429
+ };
2430
+ await persistAuthResult(state_, result, void 0);
2431
+ const anonymousId = nonEmptyString(user.anonymous_id);
2432
+ if (anonymousId && anonymousId !== state_.identity.getAnonymousId() && state_.identity.getRefreshToken() === result.refresh_token) {
2433
+ if (state_.identity.snapshot().anonymousSession === true) await state_.identity.markIdentified();
2434
+ await adoptAnonymousId(state_, anonymousId);
2435
+ }
2436
+ state_.eventQueue?.flush();
2437
+ await notifyFromAuthResult(result);
2438
+ return result;
2439
+ }
2440
+ /**
2441
+ * POST an OTP or email + password link. The request asks the server to
2442
+ * rotate the session (`rotate_session: true`): the link revokes every token
2443
+ * minted for this user before it, and answers with the pair that replaces
2444
+ * them, which is stored here. The user id does not change, so no auth-state
2445
+ * change fires. The session counts as identified from now on, and the
2446
+ * account's rotated anonymous id is adopted.
2447
+ */
2448
+ function postLink(state_, body) {
2449
+ return trackLink(state_, postLinkOnce(state_, body));
2450
+ }
2451
+ async function postLinkOnce(state_, body) {
2452
+ const env = await state_.http.post(P.authLink, {
2453
+ ...body,
2454
+ rotate_session: true
2455
+ });
2456
+ const user = normalizeUserAuthProviders(env.data.user);
2457
+ const sessionToken = nonEmptyString(env.data.session_token);
2458
+ const refreshToken = nonEmptyString(env.data.refresh_token);
2459
+ if (sessionToken && refreshToken) await state_.identity.setSession({
2460
+ appUserId: user.id,
2461
+ sessionToken,
2462
+ refreshToken,
2463
+ expiresAt: expiresAtFromResult({
2464
+ session_token: sessionToken,
2465
+ refresh_token: refreshToken,
2466
+ user,
2467
+ expires_at: env.data.expires_at ?? null
2468
+ }),
2469
+ anonymous: false
2470
+ });
2471
+ else await state_.identity.markIdentified();
2472
+ const anonymousId = nonEmptyString(user.anonymous_id);
2473
+ if (anonymousId) await adoptAnonymousId(state_, anonymousId);
2474
+ return user;
2475
+ }
2476
+ /**
2477
+ * POST helper for endpoints that return `{ data: { user } }` and mint no
2478
+ * tokens. Used by `unlink*`: existing session tokens stay untouched.
1972
2479
  */
1973
2480
  async function postUserNoRotate(state_, path, body) {
1974
2481
  return normalizeUserAuthProviders((await state_.http.post(path, body)).data.user);
@@ -2214,7 +2721,8 @@ const Amba = {
2214
2721
  ensureSession: config.autoAnonymousSession === false ? void 0 : async (path) => {
2215
2722
  if (pathSkipsLazySession(path)) return;
2216
2723
  await ensureAnonymousSession(getState());
2217
- }
2724
+ },
2725
+ onSessionRefused: (usedToken, usedUserId) => recoverRefusedSession(getState(), usedToken, usedUserId)
2218
2726
  });
2219
2727
  const prior = state;
2220
2728
  _offlineEnableToken += 1;
@@ -2259,6 +2767,7 @@ const Amba = {
2259
2767
  } catch {}
2260
2768
  }
2261
2769
  if (!nextState.functionsBaseUrl && !PROJECT_ID_PATTERN.test(nextState.projectId)) resolveFunctionsProjectId(nextState).catch(() => {});
2770
+ if (config.autoAnonymousSession !== false && !identity.getSessionToken() && identity.getRefreshToken()) restoreStoredSession(nextState).catch(() => {});
2262
2771
  },
2263
2772
  _reset: () => {
2264
2773
  state?.detachEventAppState?.();
@@ -2340,7 +2849,12 @@ const Amba = {
2340
2849
  auth: {
2341
2850
  signInAnonymously: async () => {
2342
2851
  const s = getState();
2343
- return postAuth(s, P.authAnonymous, { anonymous_id: s.identity.getAnonymousId() });
2852
+ try {
2853
+ return await postAuth(s, P.authAnonymous, { anonymous_id: s.identity.getAnonymousId() });
2854
+ } catch (err) {
2855
+ if (!(err instanceof AmbaApiError) || err.code !== IDENTITY_SIGN_IN_REQUIRED) throw err;
2856
+ return recoverFromHeldAnonymousId(s);
2857
+ }
2344
2858
  },
2345
2859
  signInWithEmail: async (email, password) => {
2346
2860
  requireEmail(email);
@@ -2413,12 +2927,12 @@ const Amba = {
2413
2927
  const s = getState();
2414
2928
  const sessionToken = s.identity.getSessionToken();
2415
2929
  if (!sessionToken) throw new AmbaApiError("UNAUTHORIZED", "no active session — call signIn* first");
2416
- return postAuth(s, P.authLink, {
2930
+ return trackLink(s, postAuth(s, P.authLink, {
2417
2931
  provider: providerTrimmed,
2418
2932
  token: credential,
2419
2933
  session_token: sessionToken,
2420
2934
  ...nonceField(options)
2421
- });
2935
+ }));
2422
2936
  },
2423
2937
  linkSmsOtp: async (phone, code) => {
2424
2938
  requireE164Phone(phone);
@@ -2426,7 +2940,7 @@ const Amba = {
2426
2940
  const s = getState();
2427
2941
  const sessionToken = s.identity.getSessionToken();
2428
2942
  if (!sessionToken) throw new AmbaApiError("UNAUTHORIZED", "no active session — call signIn* first");
2429
- return postUserNoRotate(s, P.authLink, {
2943
+ return postLink(s, {
2430
2944
  provider: "sms_otp",
2431
2945
  phone,
2432
2946
  code,
@@ -2439,7 +2953,7 @@ const Amba = {
2439
2953
  const s = getState();
2440
2954
  const sessionToken = s.identity.getSessionToken();
2441
2955
  if (!sessionToken) throw new AmbaApiError("UNAUTHORIZED", "no active session — call signIn* first");
2442
- return postUserNoRotate(s, P.authLink, {
2956
+ return postLink(s, {
2443
2957
  provider: "email_otp",
2444
2958
  email,
2445
2959
  code,
@@ -2452,7 +2966,7 @@ const Amba = {
2452
2966
  const s = getState();
2453
2967
  const sessionToken = s.identity.getSessionToken();
2454
2968
  if (!sessionToken) throw new AmbaApiError("UNAUTHORIZED", "no active session — call signIn* first");
2455
- return postUserNoRotate(s, P.authLink, {
2969
+ return postLink(s, {
2456
2970
  provider: "email_password",
2457
2971
  email,
2458
2972
  password,
@@ -2471,7 +2985,8 @@ const Amba = {
2471
2985
  signOut: async (rotateAnonymousId = false) => {
2472
2986
  const s = getState();
2473
2987
  if (s.identity.getSessionToken()) try {
2474
- await s.http.post(P.authLogout, {});
2988
+ const refreshToken = s.identity.getRefreshToken();
2989
+ await s.http.post(P.authLogout, refreshToken ? { refresh_token: refreshToken } : {});
2475
2990
  } catch (err) {
2476
2991
  if (typeof console !== "undefined") console.warn("[amba] signOut server call failed", err);
2477
2992
  }
@@ -2484,12 +2999,7 @@ const Amba = {
2484
2999
  s.eventQueue?.flush();
2485
3000
  notifyAuthSubscribers(null);
2486
3001
  },
2487
- refresh: async () => {
2488
- const s = getState();
2489
- const refreshToken = s.identity.getRefreshToken();
2490
- if (!refreshToken) throw new AmbaApiError("UNAUTHORIZED", "no refresh token available — call signIn* first");
2491
- return postAuth(s, P.authRefresh, { refresh_token: refreshToken });
2492
- },
3002
+ refresh: async () => refreshSession(getState()),
2493
3003
  me: async () => {
2494
3004
  const s = getState();
2495
3005
  if (!s.identity.getSessionToken()) throw new AmbaApiError("UNAUTHORIZED", "no active session — call signIn* first");