@layers/amba-react-native 4.0.7 → 4.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -269,8 +269,23 @@ var IdentityStore = class IdentityStore {
269
269
  sessionToken = null;
270
270
  refreshToken = null;
271
271
  expiresAt = null;
272
- constructor(persistence, seed) {
272
+ /**
273
+ * Credentials an earlier SDK version left in the AsyncStorage record, with
274
+ * the user they belong to. They stay on disk until the vault holds the
275
+ * session, so a vault write that fails or is skipped (or a process killed
276
+ * between the two writes) never loses the only stored copy.
277
+ */
278
+ plaintext;
279
+ /**
280
+ * Set while the vault could not be read. Writes are held back until a read
281
+ * succeeds, so a session this launch never loaded is not overwritten.
282
+ */
283
+ vaultUnreadable;
284
+ constructor(persistence, seed, vault, plaintext, vaultUnreadable) {
273
285
  this.persistence = persistence;
286
+ this.vault = vault;
287
+ this.plaintext = plaintext;
288
+ this.vaultUnreadable = vaultUnreadable;
274
289
  this.anonymousId = seed.anonymous_id;
275
290
  this.appUserId = seed.app_user_id;
276
291
  this.sessionToken = seed.session_token;
@@ -278,13 +293,19 @@ var IdentityStore = class IdentityStore {
278
293
  this.expiresAt = seed.expires_at;
279
294
  }
280
295
  /**
281
- * Build a store, hydrating any prior identity from AsyncStorage.
282
- * If no prior identity exists, a fresh `anonymous_id` is minted.
283
- * Expired session tokens are dropped at hydration (the refresh token
284
- * survives so callers can `auth.refresh()` to mint a new pair).
296
+ * Build a store, hydrating any prior identity from AsyncStorage and the
297
+ * credential vault. If no prior identity exists, a fresh `anonymous_id`
298
+ * is minted. Expired session tokens are dropped at hydration (the refresh
299
+ * token survives so callers can `auth.refresh()` to mint a new pair).
300
+ *
301
+ * With a vault, credentials an earlier SDK version left in the
302
+ * AsyncStorage record are used for this launch and moved into the vault;
303
+ * the AsyncStorage copy is removed once the vault write succeeds. A vault
304
+ * holding its own credentials wins over them.
285
305
  */
286
- static async create(persistence) {
306
+ static async create(persistence, vault = null) {
287
307
  const raw = await persistence.read(IDENTITY_KEY);
308
+ let recordPresent = false;
288
309
  let seed;
289
310
  if (raw) try {
290
311
  const parsed = JSON.parse(raw);
@@ -295,16 +316,42 @@ var IdentityStore = class IdentityStore {
295
316
  refresh_token: parsed.refresh_token ?? null,
296
317
  expires_at: parsed.expires_at ?? null
297
318
  };
319
+ recordPresent = typeof parsed.anonymous_id === "string";
298
320
  } catch {
299
321
  seed = freshSeed();
300
322
  }
301
323
  else seed = freshSeed();
324
+ const plaintext = vault && (seed.session_token !== null || seed.refresh_token !== null || seed.expires_at !== null) ? {
325
+ appUserId: seed.app_user_id,
326
+ slice: {
327
+ session_token: seed.session_token,
328
+ refresh_token: seed.refresh_token,
329
+ expires_at: seed.expires_at
330
+ }
331
+ } : null;
332
+ let vaultReadable = true;
333
+ if (vault) {
334
+ let fromVault = null;
335
+ try {
336
+ fromVault = await vault.read();
337
+ } catch {
338
+ vaultReadable = false;
339
+ }
340
+ const belongs = fromVault !== null && recordPresent && fromVault.anonymous_id === seed.anonymous_id;
341
+ if (fromVault && belongs) {
342
+ seed.session_token = fromVault.session_token;
343
+ seed.refresh_token = fromVault.refresh_token;
344
+ seed.expires_at = fromVault.expires_at;
345
+ if (fromVault.app_user_id != null) seed.app_user_id = fromVault.app_user_id;
346
+ }
347
+ }
348
+ if (vaultReadable && seed.session_token === null && seed.refresh_token === null) seed.app_user_id = null;
302
349
  if (isExpired(seed.expires_at)) {
303
350
  seed.session_token = null;
304
351
  seed.expires_at = null;
305
352
  }
306
- const store = new IdentityStore(persistence, seed);
307
- await store.flush();
353
+ const store = new IdentityStore(persistence, seed, vault, plaintext, !vaultReadable);
354
+ await store.flush({ hydrating: true });
308
355
  return store;
309
356
  }
310
357
  snapshot() {
@@ -344,17 +391,64 @@ var IdentityStore = class IdentityStore {
344
391
  this.refreshToken = null;
345
392
  this.expiresAt = null;
346
393
  if (rotateAnonymousId) this.anonymousId = uuidv4();
347
- await this.flush();
394
+ await this.flush({ signingOut: true });
348
395
  }
349
- async flush() {
350
- const payload = {
396
+ /**
397
+ * Persist the identity. `hydrating` marks the write that follows `create`:
398
+ * when the vault was unreadable and a retry now succeeds, the session it
399
+ * holds is adopted instead of overwritten. `signingOut` marks the write
400
+ * from `clearSession`: when the vault can't be emptied, the install moves
401
+ * to a new anonymous id so the entry left behind no longer belongs to it.
402
+ */
403
+ async flush(opts) {
404
+ if (!this.vault) {
405
+ const payload = {
406
+ anonymous_id: this.anonymousId,
407
+ app_user_id: this.appUserId,
408
+ session_token: this.sessionToken,
409
+ refresh_token: this.refreshToken,
410
+ expires_at: this.expiresAt
411
+ };
412
+ await this.persistence.write(IDENTITY_KEY, JSON.stringify(payload));
413
+ return;
414
+ }
415
+ let saved = false;
416
+ if (this.vaultUnreadable) try {
417
+ const stored = await this.vault.read();
418
+ this.vaultUnreadable = false;
419
+ if (opts?.hydrating && stored && (stored.session_token !== null || stored.refresh_token !== null) && stored.anonymous_id === this.anonymousId) {
420
+ this.sessionToken = isExpired(stored.expires_at) ? null : stored.session_token;
421
+ this.refreshToken = stored.refresh_token;
422
+ this.expiresAt = isExpired(stored.expires_at) ? null : stored.expires_at;
423
+ if (stored.app_user_id != null) this.appUserId = stored.app_user_id;
424
+ }
425
+ } catch {}
426
+ if (!this.vaultUnreadable) try {
427
+ await this.vault.write({
428
+ session_token: this.sessionToken,
429
+ refresh_token: this.refreshToken,
430
+ expires_at: this.expiresAt,
431
+ anonymous_id: this.anonymousId,
432
+ app_user_id: this.appUserId
433
+ });
434
+ saved = true;
435
+ } catch (err) {
436
+ if (typeof console !== "undefined") console.warn("[amba] could not save the session to the secure store", err);
437
+ }
438
+ if (opts?.signingOut && !saved) {
439
+ this.anonymousId = uuidv4();
440
+ 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");
441
+ }
442
+ 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;
443
+ const record = {
351
444
  anonymous_id: this.anonymousId,
352
445
  app_user_id: this.appUserId,
353
- session_token: this.sessionToken,
354
- refresh_token: this.refreshToken,
355
- expires_at: this.expiresAt
446
+ session_token: keep?.session_token ?? null,
447
+ refresh_token: keep?.refresh_token ?? null,
448
+ expires_at: keep?.expires_at ?? null
356
449
  };
357
- await this.persistence.write(IDENTITY_KEY, JSON.stringify(payload));
450
+ await this.persistence.write(IDENTITY_KEY, JSON.stringify(record));
451
+ if (!keep) this.plaintext = null;
358
452
  }
359
453
  };
360
454
  function freshSeed() {
@@ -367,9 +461,167 @@ function freshSeed() {
367
461
  };
368
462
  }
369
463
 
464
+ //#endregion
465
+ //#region src/token-storage.ts
466
+ const TOKEN_STORAGE_MODES = [
467
+ "secure",
468
+ "memory",
469
+ "asyncStorage"
470
+ ];
471
+ function isEmpty(slice) {
472
+ return slice.session_token === null && slice.refresh_token === null && slice.expires_at === null;
473
+ }
474
+ /**
475
+ * Memory-held credentials, keyed like the keystore entry. They last as long
476
+ * as the JS runtime, so a second `Amba.configure()` (Fast Refresh, a
477
+ * re-configure) keeps the session, and a relaunch starts signed out.
478
+ */
479
+ const memorySlices = /* @__PURE__ */ new Map();
480
+ /** Credentials held in this JS runtime only. */
481
+ var MemoryTokenVault = class {
482
+ kind = "memory";
483
+ constructor(key) {
484
+ this.key = key;
485
+ }
486
+ async read() {
487
+ const slice = memorySlices.get(this.key);
488
+ return slice ? { ...slice } : null;
489
+ }
490
+ async write(slice) {
491
+ if (isEmpty(slice)) memorySlices.delete(this.key);
492
+ else memorySlices.set(this.key, { ...slice });
493
+ }
494
+ };
495
+ /**
496
+ * Keystore key for a storage prefix. Kept to `[A-Za-z0-9_.-]` because some
497
+ * secure stores (for example `expo-secure-store`) reject other characters.
498
+ */
499
+ function secureTokenKey(prefix) {
500
+ return `${prefix.replace(/[^A-Za-z0-9_.-]/g, "_")}_session_tokens_v1`;
501
+ }
502
+ /** Credentials in a keystore-backed {@link SecureTokenStorage}. */
503
+ var SecureTokenVault = class {
504
+ kind = "secure";
505
+ constructor(storage, key) {
506
+ this.storage = storage;
507
+ this.key = key;
508
+ }
509
+ async read() {
510
+ const raw = await this.storage.getItem(this.key);
511
+ if (!raw) return null;
512
+ try {
513
+ const parsed = JSON.parse(raw);
514
+ return {
515
+ session_token: parsed.session_token ?? null,
516
+ refresh_token: parsed.refresh_token ?? null,
517
+ expires_at: parsed.expires_at ?? null,
518
+ anonymous_id: parsed.anonymous_id ?? null,
519
+ app_user_id: parsed.app_user_id ?? null
520
+ };
521
+ } catch {
522
+ return null;
523
+ }
524
+ }
525
+ async write(slice) {
526
+ if (isEmpty(slice)) {
527
+ await this.storage.removeItem(this.key);
528
+ return;
529
+ }
530
+ await this.storage.setItem(this.key, JSON.stringify(slice));
531
+ }
532
+ };
533
+ function isKeychainLike(mod) {
534
+ const k = mod;
535
+ return typeof k === "object" && k !== null && typeof k.setGenericPassword === "function" && typeof k.getGenericPassword === "function" && typeof k.resetGenericPassword === "function";
536
+ }
537
+ /**
538
+ * Adapt `react-native-keychain` to {@link SecureTokenStorage}. Items use the
539
+ * `AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY` class on iOS: readable after the
540
+ * first unlock (so background refreshes work) and left behind by backups
541
+ * restored onto another device.
542
+ */
543
+ function keychainStorage(keychain) {
544
+ const accessible = keychain.ACCESSIBLE?.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY;
545
+ return {
546
+ async getItem(key) {
547
+ const entry = await keychain.getGenericPassword({ service: key });
548
+ return entry ? entry.password : null;
549
+ },
550
+ async setItem(key, value) {
551
+ if (await keychain.setGenericPassword("amba", value, {
552
+ service: key,
553
+ ...accessible !== void 0 ? { accessible } : {}
554
+ }) === false) throw new Error("react-native-keychain refused to store the session");
555
+ },
556
+ async removeItem(key) {
557
+ if (await keychain.resetGenericPassword({ service: key }) === false && await keychain.getGenericPassword({ service: key })) throw new Error("react-native-keychain could not remove the session");
558
+ }
559
+ };
560
+ }
561
+ let cachedKeychain;
562
+ /**
563
+ * Load `react-native-keychain` when the app has it installed. Resolves null
564
+ * otherwise (module missing, or a build without its native half).
565
+ */
566
+ async function loadKeychainStorage() {
567
+ if (cachedKeychain !== void 0) return cachedKeychain;
568
+ try {
569
+ const mod = await import("react-native-keychain");
570
+ const candidate = isKeychainLike(mod) ? mod : mod.default;
571
+ cachedKeychain = isKeychainLike(candidate) ? keychainStorage(candidate) : null;
572
+ } catch {
573
+ cachedKeychain = null;
574
+ }
575
+ return cachedKeychain;
576
+ }
577
+ let memoryFallbackWarned = false;
578
+ /**
579
+ * Pick the vault for `mode`. Resolves null for `"asyncStorage"`, which keeps
580
+ * the credentials inside the AsyncStorage identity record.
581
+ */
582
+ async function resolveTokenVault(mode, prefix, secureStorage) {
583
+ if (mode === "asyncStorage") return null;
584
+ const key = secureTokenKey(prefix);
585
+ if (mode === "memory") return new MemoryTokenVault(key);
586
+ const storage = secureStorage ?? await loadKeychainStorage();
587
+ if (storage) return new SecureTokenVault(storage, key);
588
+ if (!memoryFallbackWarned && typeof console !== "undefined") {
589
+ memoryFallbackWarned = true;
590
+ console.warn("[amba] No secure token store found, so the session is kept in memory and users sign in again after every launch. Install react-native-keychain (or pass `secureStorage` to Amba.configure) to keep sessions across launches in the Keychain/Keystore.");
591
+ }
592
+ return new MemoryTokenVault(key);
593
+ }
594
+
595
+ //#endregion
596
+ //#region src/path-segment.ts
597
+ /**
598
+ * Encode one caller-supplied id, key, slug or name as a single URL path
599
+ * segment. `encodeURIComponent` escapes `/`, `?` and `#`, so the value stays
600
+ * inside its segment. `.` and `..` are refused because URL resolution treats
601
+ * them as steps to another route, and the empty string is refused because it
602
+ * collapses onto the parent route.
603
+ */
604
+ function pathSegment(value) {
605
+ if (value === "" || value === "." || value === "..") throw new AmbaApiError("VALIDATION_ERROR", `path segment must not be ${value === "" ? "empty" : `'${value}'`}`);
606
+ return encodeURIComponent(value);
607
+ }
608
+
370
609
  //#endregion
371
610
  //#region src/resilience.ts
372
611
  /**
612
+ * Whether `op` belongs to `current`. An op queued under a user id matches only
613
+ * that user id. An op queued before any session matches the same anonymous id,
614
+ * so a guest's offline write lands once that guest's session starts. An op
615
+ * with no recorded owner (queued by an SDK version before owners existed)
616
+ * matches nobody.
617
+ */
618
+ function outboxOwnerMatches(op, current) {
619
+ const owner = op.owner;
620
+ if (!owner) return false;
621
+ if (owner.appUserId) return owner.appUserId === current.appUserId;
622
+ return owner.anonymousId !== null && owner.anonymousId === current.anonymousId;
623
+ }
624
+ /**
373
625
  * Best-effort online probe. In a browser/RN runtime `navigator.onLine`
374
626
  * exists; under Node / SSR it doesn't, so we treat "unknown" as online
375
627
  * (the executor's own error handling then decides).
@@ -453,8 +705,18 @@ var OfflineQueue = class {
453
705
  * SDK surface the loss (debug log) instead of silently discarding.
454
706
  */
455
707
  onDropped;
708
+ /**
709
+ * The identity that is signed in right now. When set, enqueue stamps it on
710
+ * each op and flush/pending only touch ops it owns.
711
+ */
712
+ currentOwner;
456
713
  flushing = null;
457
714
  /**
715
+ * Bumped by `purge()`. A replay that started before a purge sees the change
716
+ * and does not re-queue its op.
717
+ */
718
+ purgeGeneration = 0;
719
+ /**
458
720
  * Serializes every read-modify-write of the persisted outbox. Each mutation
459
721
  * chains onto the previous one's promise, so concurrent `enqueue`s — and an
460
722
  * `enqueue` racing the per-op removal inside `flush` — can never read a stale
@@ -477,6 +739,16 @@ var OfflineQueue = class {
477
739
  this.executor = opts.executor;
478
740
  this.storageKey = opts.storageKey ?? "offline_outbox";
479
741
  this.onDropped = opts.onDropped;
742
+ this.currentOwner = opts.currentOwner;
743
+ }
744
+ /**
745
+ * The ops the current owner may see and replay. Without an owner source
746
+ * every op qualifies.
747
+ */
748
+ visible(ops) {
749
+ if (!this.currentOwner) return ops;
750
+ const current = this.currentOwner();
751
+ return ops.filter((op) => outboxOwnerMatches(op, current));
480
752
  }
481
753
  /**
482
754
  * Run `fn` exclusively against the persisted outbox: it chains after every
@@ -533,28 +805,55 @@ var OfflineQueue = class {
533
805
  get isDisposed() {
534
806
  return this.disposed;
535
807
  }
536
- /** Append an op to the durable outbox. Returns the persisted op. */
537
- async enqueue(op) {
808
+ /**
809
+ * Capture the current owner and purge generation. Take it before a write's
810
+ * live attempt and pass it to {@link enqueue}.
811
+ */
812
+ ticket() {
813
+ return {
814
+ owner: this.currentOwner?.(),
815
+ generation: this.purgeGeneration
816
+ };
817
+ }
818
+ /**
819
+ * Append an op to the durable outbox. Returns the persisted op. With a
820
+ * `ticket`, the op belongs to the ticket's owner, and the enqueue rejects
821
+ * (code `OFFLINE_QUEUE_PURGED`) when the queue was purged after the ticket
822
+ * was taken, which is what a sign-out during the live attempt does.
823
+ */
824
+ async enqueue(op, ticket) {
538
825
  if (this.disposed) throw new Error("amba offline queue was disposed (SDK reconfigured)");
826
+ const owner = op.owner ?? (ticket ? ticket.owner : this.currentOwner?.());
539
827
  const full = {
540
828
  ...op,
829
+ ...owner ? { owner: { ...owner } } : {},
541
830
  idempotencyKey: op.idempotencyKey ?? newIdempotencyKey(),
542
831
  enqueuedAt: (/* @__PURE__ */ new Date()).toISOString()
543
832
  };
544
- return this.mutate((ops) => ({
545
- next: [...ops, full],
546
- result: full
547
- }), { rejectIfDisposed: true });
833
+ return this.mutate((ops) => {
834
+ if (ticket && ticket.generation !== this.purgeGeneration) {
835
+ const err = /* @__PURE__ */ new Error("amba offline queue was cleared by a sign-out while this write was in flight");
836
+ err.code = "OFFLINE_QUEUE_PURGED";
837
+ throw err;
838
+ }
839
+ return {
840
+ next: [...ops, full],
841
+ result: full
842
+ };
843
+ }, { rejectIfDisposed: true });
548
844
  }
549
- /** Number of ops awaiting replay. */
845
+ /** Number of the current owner's ops awaiting replay. */
550
846
  async pendingCount() {
551
- return (await this.load()).length;
847
+ return this.visible(await this.load()).length;
552
848
  }
553
- /** Snapshot the pending ops (a copy — mutating it does not affect the queue). */
849
+ /**
850
+ * Snapshot the current owner's pending ops (a copy — mutating it does not
851
+ * affect the queue). Ops queued by another user stay out of the list.
852
+ */
554
853
  async pending() {
555
- return [...await this.load()];
854
+ return [...this.visible(await this.load())];
556
855
  }
557
- /** Drop every queued op without replaying. */
856
+ /** Drop every queued op, whoever queued it, without replaying. */
558
857
  async clear() {
559
858
  await this.mutate(() => ({
560
859
  next: [],
@@ -562,10 +861,23 @@ var OfflineQueue = class {
562
861
  }));
563
862
  }
564
863
  /**
565
- * Replay the outbox in FIFO order. Each op is removed only after it
566
- * succeeds; the first failure stops the flush and retains the remainder
567
- * (so order is preserved and nothing is dropped mid-batch). Concurrent
568
- * calls share one in-flight flush.
864
+ * Drop every queued op, including one whose replay is in flight right now:
865
+ * that op is not re-queued when its replay fails. The SDKs call this on
866
+ * sign-out so nothing the signed-out user queued survives on the device.
867
+ */
868
+ async purge() {
869
+ this.purgeGeneration += 1;
870
+ await this.clear();
871
+ }
872
+ /**
873
+ * Replay the current owner's ops in FIFO order. Each op is removed only
874
+ * after it succeeds; the first failure stops the flush and retains the
875
+ * remainder (so order is preserved and nothing is dropped mid-batch). Ops
876
+ * another user queued stay held for that user. Ops that can never replay
877
+ * are dropped through `onDropped`: one with no recorded owner (code
878
+ * `OFFLINE_OWNER_UNKNOWN`), and one queued before any session under an
879
+ * anonymous id the install no longer has (code `OFFLINE_OWNER_GONE`).
880
+ * Concurrent calls share one in-flight flush.
569
881
  */
570
882
  async flush() {
571
883
  if (this.disposed) return {
@@ -584,8 +896,26 @@ var OfflineQueue = class {
584
896
  let dropped = 0;
585
897
  for (;;) {
586
898
  if (this.disposed) break;
587
- const head = (await this.load())[0];
899
+ const all = await this.load();
900
+ if (this.currentOwner) {
901
+ const current = this.currentOwner();
902
+ const orphan = all.find((o) => !o.owner || o.owner.appUserId === null && current.anonymousId !== null && o.owner.anonymousId !== current.anonymousId);
903
+ if (orphan) {
904
+ if (!await this.removeOp(orphan)) break;
905
+ dropped += 1;
906
+ this.reportDropped(orphan, orphan.owner ? {
907
+ code: "OFFLINE_OWNER_GONE",
908
+ message: "queued before sign-in under an anonymous id this install no longer has; not replayed"
909
+ } : {
910
+ code: "OFFLINE_OWNER_UNKNOWN",
911
+ message: "queued by an earlier SDK version without an owner; not replayed"
912
+ });
913
+ continue;
914
+ }
915
+ }
916
+ const head = this.visible(all)[0];
588
917
  if (!head) break;
918
+ const generation = this.purgeGeneration;
589
919
  let replayError = void 0;
590
920
  let ok = false;
591
921
  try {
@@ -597,32 +927,20 @@ var OfflineQueue = class {
597
927
  if (!ok && !isDeterministicError(replayError)) {
598
928
  await this.mutate((ops) => {
599
929
  return {
600
- next: ops.some((o) => o.idempotencyKey === head.idempotencyKey) ? ops : [head, ...ops],
930
+ next: !ops.some((o) => o.idempotencyKey === head.idempotencyKey) && generation === this.purgeGeneration ? [head, ...ops] : ops,
601
931
  result: void 0
602
932
  };
603
933
  });
604
934
  break;
605
935
  }
606
- if (!await this.mutate((ops) => {
607
- const idx = ops.findIndex((o) => o.idempotencyKey === head.idempotencyKey);
608
- if (idx === -1) return {
609
- next: ops,
610
- result: false
611
- };
612
- return {
613
- next: ops.slice(0, idx).concat(ops.slice(idx + 1)),
614
- result: true
615
- };
616
- })) break;
936
+ if (!await this.removeOp(head)) break;
617
937
  if (ok) flushed += 1;
618
938
  else {
619
939
  dropped += 1;
620
- try {
621
- this.onDropped?.(head, replayError);
622
- } catch {}
940
+ this.reportDropped(head, replayError);
623
941
  }
624
942
  }
625
- const remaining = (await this.load()).length;
943
+ const remaining = this.visible(await this.load()).length;
626
944
  return {
627
945
  flushed,
628
946
  dropped,
@@ -630,6 +948,29 @@ var OfflineQueue = class {
630
948
  };
631
949
  }
632
950
  /**
951
+ * Remove exactly `op` (by idempotency key) under the lock. Robust against a
952
+ * concurrent enqueue having shifted the array. Resolves false when the op
953
+ * is already gone.
954
+ */
955
+ removeOp(op) {
956
+ return this.mutate((ops) => {
957
+ const idx = ops.findIndex((o) => o.idempotencyKey === op.idempotencyKey);
958
+ if (idx === -1) return {
959
+ next: ops,
960
+ result: false
961
+ };
962
+ return {
963
+ next: ops.slice(0, idx).concat(ops.slice(idx + 1)),
964
+ result: true
965
+ };
966
+ });
967
+ }
968
+ reportDropped(op, err) {
969
+ try {
970
+ this.onDropped?.(op, err);
971
+ } catch {}
972
+ }
973
+ /**
633
974
  * Install an auto-flush listener on the runtime's `online` event so the
634
975
  * queue drains the moment connectivity returns. Idempotent — a second
635
976
  * call is a no-op. Returns a detach function.
@@ -1198,7 +1539,7 @@ var CollectionSubscription = class {
1198
1539
  if (!this.ctx.authHeaders()) throw new Error("Amba.collection().subscribe() requires an authenticated session — call Amba.auth.signIn* first.");
1199
1540
  const qs = Object.keys(this.filters).length > 0 ? `?where=${encodeURIComponent(JSON.stringify(this.filters))}` : "";
1200
1541
  return openStream({
1201
- url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/collections/${encodeURIComponent(this.name)}${qs}`,
1542
+ url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/collections/${pathSegment(this.name)}${qs}`,
1202
1543
  authHeaders: this.ctx.authHeaders,
1203
1544
  onChange: cb,
1204
1545
  options,
@@ -1222,7 +1563,7 @@ var ConversationSubscription = class {
1222
1563
  subscribe(cb, options = {}) {
1223
1564
  if (!this.ctx.authHeaders()) throw new Error("Amba.messaging.conversation().subscribe() requires an authenticated session — call Amba.auth.signIn* first.");
1224
1565
  return openStream({
1225
- url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/conversations/${encodeURIComponent(this.conversationId)}`,
1566
+ url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/conversations/${pathSegment(this.conversationId)}`,
1226
1567
  authHeaders: this.ctx.authHeaders,
1227
1568
  onChange: cb,
1228
1569
  options,
@@ -1482,7 +1823,7 @@ function delay(ms) {
1482
1823
  //#endregion
1483
1824
  //#region src/index.ts
1484
1825
  /** SDK semver — burned in at build time. Bump alongside package.json. */
1485
- const SDK_VERSION = "4.0.7";
1826
+ const SDK_VERSION = "4.0.8";
1486
1827
  /**
1487
1828
  * Default host that serves deployed functions. `Amba.functions.invoke(name)`
1488
1829
  * targets `https://<projectId>.<DEFAULT_FUNCTIONS_HOST>/<name>` unless a
@@ -1723,8 +2064,20 @@ const P = {
1723
2064
  deepLinks: "/v1/client/deep-links",
1724
2065
  onboarding: "/v1/client/onboarding"
1725
2066
  };
2067
+ /**
2068
+ * Encode one caller-supplied id, key or name as a single URL path segment.
2069
+ * `encodeURIComponent` escapes `/`, `?` and `#`; `.` and `..` are refused
2070
+ * because URL resolution treats them as steps to another route, and the
2071
+ * empty string is refused because it collapses onto the parent route.
2072
+ */
1726
2073
  function encodePathSegment(s) {
1727
- return encodeURIComponent(s);
2074
+ return pathSegment(s);
2075
+ }
2076
+ function nonceField(options) {
2077
+ const nonce = options?.nonce;
2078
+ if (nonce === void 0) return {};
2079
+ if (typeof nonce !== "string" || nonce.trim() === "") throw new AmbaApiError("VALIDATION_ERROR", "nonce must be a non-empty string");
2080
+ return { nonce };
1728
2081
  }
1729
2082
  /**
1730
2083
  * Resolve the full invocation URL for a deployed function. Resolution checks
@@ -1734,8 +2087,8 @@ function encodePathSegment(s) {
1734
2087
  * cached for subsequent invokes.
1735
2088
  */
1736
2089
  async function resolveFunctionUrl(s, name) {
1737
- if (s.functionsBaseUrl) return `${s.functionsBaseUrl.replace(/\/$/, "")}/${encodeURIComponent(name)}`;
1738
- return `https://${await resolveFunctionsProjectId(s)}.${DEFAULT_FUNCTIONS_HOST}/${encodeURIComponent(name)}`;
2090
+ if (s.functionsBaseUrl) return `${s.functionsBaseUrl.replace(/\/$/, "")}/${pathSegment(name)}`;
2091
+ return `https://${await resolveFunctionsProjectId(s)}.${DEFAULT_FUNCTIONS_HOST}/${pathSegment(name)}`;
1739
2092
  }
1740
2093
  async function resolveFunctionsProjectId(s) {
1741
2094
  if (s.resolvedFunctionsProjectId) return s.resolvedFunctionsProjectId;
@@ -1812,6 +2165,10 @@ function getOfflineQueue(s) {
1812
2165
  store: offlineStore(s),
1813
2166
  storageKey: `offline_outbox:${s.projectId}`,
1814
2167
  executor: (op) => replayOutboxOp(s, op),
2168
+ currentOwner: () => ({
2169
+ appUserId: s.identity.getSessionToken() || s.identity.getRefreshToken() ? s.identity.getAppUserId() : null,
2170
+ anonymousId: s.identity.getAnonymousId()
2171
+ }),
1815
2172
  onDropped: (op, err) => {
1816
2173
  if (typeof console !== "undefined") console.warn(`[amba] dropped un-replayable offline ${op.kind} (${op.collection ?? op.event ?? "?"}):`, err);
1817
2174
  }
@@ -1842,10 +2199,16 @@ const Amba = {
1842
2199
  if (!Number.isInteger(flushIntervalMs) || flushIntervalMs < 0) throw new Error("Amba.configure: events.flushIntervalMs must be a non-negative integer");
1843
2200
  if (!Number.isInteger(maxQueue) || maxQueue < 1) throw new Error("Amba.configure: events.maxQueue must be positive");
1844
2201
  }
1845
- const persistence = new AsyncStoragePersistence(config.storagePrefix ?? "amba");
2202
+ const tokenStorage = config.tokenStorage ?? "secure";
2203
+ if (!TOKEN_STORAGE_MODES.includes(tokenStorage)) throw new Error("Amba.configure: tokenStorage must be \"secure\", \"memory\" or \"asyncStorage\"");
2204
+ const secureStorage = config.secureStorage;
2205
+ 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");
2206
+ const storagePrefix = config.storagePrefix ?? "amba";
2207
+ const persistence = new AsyncStoragePersistence(storagePrefix);
1846
2208
  if (config.events?.batching) await persistence.assertDurable();
1847
2209
  await persistence.hydrate();
1848
- const identity = await IdentityStore.create(persistence);
2210
+ const vault = await resolveTokenVault(tokenStorage, storagePrefix, secureStorage);
2211
+ const identity = await IdentityStore.create(persistence, vault);
1849
2212
  const baseUrl = (config.apiUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
1850
2213
  const sdkVersionHeader = `amba-sdk/${SDK_VERSION} ${`amba-react-native/${SDK_VERSION}`}`;
1851
2214
  const http = new AmbaHttpClient({
@@ -1946,6 +2309,7 @@ const Amba = {
1946
2309
  timestamp: occurredAt
1947
2310
  };
1948
2311
  if (options?.telemetry) body["telemetry"] = true;
2312
+ const outboxTicket = s.offlineEnabled ? getOfflineQueue(s).ticket() : void 0;
1949
2313
  try {
1950
2314
  await s.http.post(P.events, body);
1951
2315
  } catch (err) {
@@ -1956,7 +2320,7 @@ const Amba = {
1956
2320
  properties,
1957
2321
  telemetry: options?.telemetry,
1958
2322
  occurredAt
1959
- });
2323
+ }, outboxTicket);
1960
2324
  return;
1961
2325
  }
1962
2326
  throw err;
@@ -2000,18 +2364,20 @@ const Amba = {
2000
2364
  password
2001
2365
  });
2002
2366
  },
2003
- signInWithApple: async (identityToken) => {
2367
+ signInWithApple: async (identityToken, options) => {
2004
2368
  if (identityToken.trim() === "") throw new AmbaApiError("VALIDATION_ERROR", "identity_token must not be empty");
2005
2369
  return postAuth(getState(), P.authSocial, {
2006
2370
  provider: "apple",
2007
- id_token: identityToken
2371
+ id_token: identityToken,
2372
+ ...nonceField(options)
2008
2373
  });
2009
2374
  },
2010
- signInWithGoogle: async (idToken) => {
2375
+ signInWithGoogle: async (idToken, options) => {
2011
2376
  if (idToken.trim() === "") throw new AmbaApiError("VALIDATION_ERROR", "id_token must not be empty");
2012
2377
  return postAuth(getState(), P.authSocial, {
2013
2378
  provider: "google",
2014
- id_token: idToken
2379
+ id_token: idToken,
2380
+ ...nonceField(options)
2015
2381
  });
2016
2382
  },
2017
2383
  requestEmailOtp: async (email) => {
@@ -2046,7 +2412,7 @@ const Amba = {
2046
2412
  if (token.trim() === "") throw new AmbaApiError("VALIDATION_ERROR", "token must not be empty");
2047
2413
  return postAuth(getState(), P.authMagicLinkVerify, { token });
2048
2414
  },
2049
- linkAccount: async (provider, credential) => {
2415
+ linkAccount: async (provider, credential, options) => {
2050
2416
  const providerTrimmed = provider?.trim() ?? "";
2051
2417
  if (providerTrimmed === "") throw new AmbaApiError("VALIDATION_ERROR", "provider must not be empty");
2052
2418
  if (typeof credential !== "string" || credential.trim() === "") throw new AmbaApiError("VALIDATION_ERROR", "credential must not be empty");
@@ -2056,7 +2422,8 @@ const Amba = {
2056
2422
  return postAuth(s, P.authLink, {
2057
2423
  provider: providerTrimmed,
2058
2424
  token: credential,
2059
- session_token: sessionToken
2425
+ session_token: sessionToken,
2426
+ ...nonceField(options)
2060
2427
  });
2061
2428
  },
2062
2429
  linkSmsOtp: async (phone, code) => {
@@ -2115,6 +2482,11 @@ const Amba = {
2115
2482
  if (typeof console !== "undefined") console.warn("[amba] signOut server call failed", err);
2116
2483
  }
2117
2484
  await s.identity.clearSession(rotateAnonymousId);
2485
+ try {
2486
+ await getOfflineQueue(s).purge();
2487
+ } catch (err) {
2488
+ if (typeof console !== "undefined") console.warn("[amba] signOut could not clear the offline outbox", err);
2489
+ }
2118
2490
  s.eventQueue?.flush();
2119
2491
  notifyAuthSubscribers(null);
2120
2492
  },
@@ -2230,6 +2602,7 @@ const Amba = {
2230
2602
  const s = getState();
2231
2603
  if (s.offlineEnabled) {
2232
2604
  const idempotencyKey = (typeof row["idempotency_key"] === "string" ? row["idempotency_key"] : void 0) ?? newIdempotencyKey();
2605
+ const outboxTicket = s.offlineEnabled ? getOfflineQueue(s).ticket() : void 0;
2233
2606
  try {
2234
2607
  return (await s.http.post(`${P.collections}/${encodePathSegment(name)}`, {
2235
2608
  ...row,
@@ -2242,7 +2615,7 @@ const Amba = {
2242
2615
  collection: name,
2243
2616
  payload: row,
2244
2617
  idempotencyKey
2245
- });
2618
+ }, outboxTicket);
2246
2619
  return {
2247
2620
  ...row,
2248
2621
  _amba_pending: true
@@ -2255,40 +2628,46 @@ const Amba = {
2255
2628
  },
2256
2629
  update: async (name, id, set) => {
2257
2630
  const s = getState();
2258
- if (s.offlineEnabled) try {
2259
- return (await s.http.patch(`${P.collections}/${encodePathSegment(name)}/${encodePathSegment(id)}`, { set })).data;
2260
- } catch (err) {
2261
- if (isOfflineError(err)) {
2262
- await getOfflineQueue(s).enqueue({
2263
- kind: "collection.update",
2264
- collection: name,
2265
- id,
2266
- payload: set
2267
- });
2268
- return {
2269
- id,
2270
- ...set,
2271
- _amba_pending: true
2272
- };
2631
+ if (s.offlineEnabled) {
2632
+ const outboxTicket = s.offlineEnabled ? getOfflineQueue(s).ticket() : void 0;
2633
+ try {
2634
+ return (await s.http.patch(`${P.collections}/${encodePathSegment(name)}/${encodePathSegment(id)}`, { set })).data;
2635
+ } catch (err) {
2636
+ if (isOfflineError(err)) {
2637
+ await getOfflineQueue(s).enqueue({
2638
+ kind: "collection.update",
2639
+ collection: name,
2640
+ id,
2641
+ payload: set
2642
+ }, outboxTicket);
2643
+ return {
2644
+ id,
2645
+ ...set,
2646
+ _amba_pending: true
2647
+ };
2648
+ }
2649
+ throw err;
2273
2650
  }
2274
- throw err;
2275
2651
  }
2276
2652
  return (await s.http.patch(`${P.collections}/${encodePathSegment(name)}/${encodePathSegment(id)}`, { set })).data;
2277
2653
  },
2278
2654
  delete: async (name, id) => {
2279
2655
  const s = getState();
2280
- if (s.offlineEnabled) try {
2281
- return await s.http.delete(`${P.collections}/${encodePathSegment(name)}/${encodePathSegment(id)}`) ?? { data: { deleted: true } };
2282
- } catch (err) {
2283
- if (isOfflineError(err)) {
2284
- await getOfflineQueue(s).enqueue({
2285
- kind: "collection.delete",
2286
- collection: name,
2287
- id
2288
- });
2289
- return { data: { deleted: true } };
2656
+ if (s.offlineEnabled) {
2657
+ const outboxTicket = s.offlineEnabled ? getOfflineQueue(s).ticket() : void 0;
2658
+ try {
2659
+ return await s.http.delete(`${P.collections}/${encodePathSegment(name)}/${encodePathSegment(id)}`) ?? { data: { deleted: true } };
2660
+ } catch (err) {
2661
+ if (isOfflineError(err)) {
2662
+ await getOfflineQueue(s).enqueue({
2663
+ kind: "collection.delete",
2664
+ collection: name,
2665
+ id
2666
+ }, outboxTicket);
2667
+ return { data: { deleted: true } };
2668
+ }
2669
+ throw err;
2290
2670
  }
2291
- throw err;
2292
2671
  }
2293
2672
  return await s.http.delete(`${P.collections}/${encodePathSegment(name)}/${encodePathSegment(id)}`) ?? { data: { deleted: true } };
2294
2673
  },