@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.cjs CHANGED
@@ -263,8 +263,23 @@ var IdentityStore = class IdentityStore {
263
263
  sessionToken = null;
264
264
  refreshToken = null;
265
265
  expiresAt = null;
266
- constructor(persistence, seed) {
266
+ /**
267
+ * Credentials an earlier SDK version left in the AsyncStorage record, with
268
+ * the user they belong to. They stay on disk until the vault holds the
269
+ * session, so a vault write that fails or is skipped (or a process killed
270
+ * between the two writes) never loses the only stored copy.
271
+ */
272
+ plaintext;
273
+ /**
274
+ * Set while the vault could not be read. Writes are held back until a read
275
+ * succeeds, so a session this launch never loaded is not overwritten.
276
+ */
277
+ vaultUnreadable;
278
+ constructor(persistence, seed, vault, plaintext, vaultUnreadable) {
267
279
  this.persistence = persistence;
280
+ this.vault = vault;
281
+ this.plaintext = plaintext;
282
+ this.vaultUnreadable = vaultUnreadable;
268
283
  this.anonymousId = seed.anonymous_id;
269
284
  this.appUserId = seed.app_user_id;
270
285
  this.sessionToken = seed.session_token;
@@ -272,13 +287,19 @@ var IdentityStore = class IdentityStore {
272
287
  this.expiresAt = seed.expires_at;
273
288
  }
274
289
  /**
275
- * Build a store, hydrating any prior identity from AsyncStorage.
276
- * If no prior identity exists, a fresh `anonymous_id` is minted.
277
- * Expired session tokens are dropped at hydration (the refresh token
278
- * survives so callers can `auth.refresh()` to mint a new pair).
290
+ * Build a store, hydrating any prior identity from AsyncStorage and the
291
+ * credential vault. If no prior identity exists, a fresh `anonymous_id`
292
+ * is minted. Expired session tokens are dropped at hydration (the refresh
293
+ * token survives so callers can `auth.refresh()` to mint a new pair).
294
+ *
295
+ * With a vault, credentials an earlier SDK version left in the
296
+ * AsyncStorage record are used for this launch and moved into the vault;
297
+ * the AsyncStorage copy is removed once the vault write succeeds. A vault
298
+ * holding its own credentials wins over them.
279
299
  */
280
- static async create(persistence) {
300
+ static async create(persistence, vault = null) {
281
301
  const raw = await persistence.read(IDENTITY_KEY);
302
+ let recordPresent = false;
282
303
  let seed;
283
304
  if (raw) try {
284
305
  const parsed = JSON.parse(raw);
@@ -289,16 +310,42 @@ var IdentityStore = class IdentityStore {
289
310
  refresh_token: parsed.refresh_token ?? null,
290
311
  expires_at: parsed.expires_at ?? null
291
312
  };
313
+ recordPresent = typeof parsed.anonymous_id === "string";
292
314
  } catch {
293
315
  seed = freshSeed();
294
316
  }
295
317
  else seed = freshSeed();
318
+ const plaintext = vault && (seed.session_token !== null || seed.refresh_token !== null || seed.expires_at !== null) ? {
319
+ appUserId: seed.app_user_id,
320
+ slice: {
321
+ session_token: seed.session_token,
322
+ refresh_token: seed.refresh_token,
323
+ expires_at: seed.expires_at
324
+ }
325
+ } : null;
326
+ let vaultReadable = true;
327
+ if (vault) {
328
+ let fromVault = null;
329
+ try {
330
+ fromVault = await vault.read();
331
+ } catch {
332
+ vaultReadable = false;
333
+ }
334
+ const belongs = fromVault !== null && recordPresent && fromVault.anonymous_id === seed.anonymous_id;
335
+ if (fromVault && belongs) {
336
+ seed.session_token = fromVault.session_token;
337
+ seed.refresh_token = fromVault.refresh_token;
338
+ seed.expires_at = fromVault.expires_at;
339
+ if (fromVault.app_user_id != null) seed.app_user_id = fromVault.app_user_id;
340
+ }
341
+ }
342
+ if (vaultReadable && seed.session_token === null && seed.refresh_token === null) seed.app_user_id = null;
296
343
  if (isExpired(seed.expires_at)) {
297
344
  seed.session_token = null;
298
345
  seed.expires_at = null;
299
346
  }
300
- const store = new IdentityStore(persistence, seed);
301
- await store.flush();
347
+ const store = new IdentityStore(persistence, seed, vault, plaintext, !vaultReadable);
348
+ await store.flush({ hydrating: true });
302
349
  return store;
303
350
  }
304
351
  snapshot() {
@@ -338,17 +385,64 @@ var IdentityStore = class IdentityStore {
338
385
  this.refreshToken = null;
339
386
  this.expiresAt = null;
340
387
  if (rotateAnonymousId) this.anonymousId = uuidv4();
341
- await this.flush();
388
+ await this.flush({ signingOut: true });
342
389
  }
343
- async flush() {
344
- const payload = {
390
+ /**
391
+ * Persist the identity. `hydrating` marks the write that follows `create`:
392
+ * when the vault was unreadable and a retry now succeeds, the session it
393
+ * holds is adopted instead of overwritten. `signingOut` marks the write
394
+ * from `clearSession`: when the vault can't be emptied, the install moves
395
+ * to a new anonymous id so the entry left behind no longer belongs to it.
396
+ */
397
+ async flush(opts) {
398
+ if (!this.vault) {
399
+ const payload = {
400
+ anonymous_id: this.anonymousId,
401
+ app_user_id: this.appUserId,
402
+ session_token: this.sessionToken,
403
+ refresh_token: this.refreshToken,
404
+ expires_at: this.expiresAt
405
+ };
406
+ await this.persistence.write(IDENTITY_KEY, JSON.stringify(payload));
407
+ return;
408
+ }
409
+ let saved = false;
410
+ if (this.vaultUnreadable) try {
411
+ const stored = await this.vault.read();
412
+ 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
+ }
419
+ } 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
+ });
428
+ saved = true;
429
+ } catch (err) {
430
+ if (typeof console !== "undefined") console.warn("[amba] could not save the session to the secure store", err);
431
+ }
432
+ if (opts?.signingOut && !saved) {
433
+ this.anonymousId = uuidv4();
434
+ 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
+ }
436
+ 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 = {
345
438
  anonymous_id: this.anonymousId,
346
439
  app_user_id: this.appUserId,
347
- session_token: this.sessionToken,
348
- refresh_token: this.refreshToken,
349
- expires_at: this.expiresAt
440
+ session_token: keep?.session_token ?? null,
441
+ refresh_token: keep?.refresh_token ?? null,
442
+ expires_at: keep?.expires_at ?? null
350
443
  };
351
- await this.persistence.write(IDENTITY_KEY, JSON.stringify(payload));
444
+ await this.persistence.write(IDENTITY_KEY, JSON.stringify(record));
445
+ if (!keep) this.plaintext = null;
352
446
  }
353
447
  };
354
448
  function freshSeed() {
@@ -361,9 +455,167 @@ function freshSeed() {
361
455
  };
362
456
  }
363
457
 
458
+ //#endregion
459
+ //#region src/token-storage.ts
460
+ const TOKEN_STORAGE_MODES = [
461
+ "secure",
462
+ "memory",
463
+ "asyncStorage"
464
+ ];
465
+ function isEmpty(slice) {
466
+ return slice.session_token === null && slice.refresh_token === null && slice.expires_at === null;
467
+ }
468
+ /**
469
+ * Memory-held credentials, keyed like the keystore entry. They last as long
470
+ * as the JS runtime, so a second `Amba.configure()` (Fast Refresh, a
471
+ * re-configure) keeps the session, and a relaunch starts signed out.
472
+ */
473
+ const memorySlices = /* @__PURE__ */ new Map();
474
+ /** Credentials held in this JS runtime only. */
475
+ var MemoryTokenVault = class {
476
+ kind = "memory";
477
+ constructor(key) {
478
+ this.key = key;
479
+ }
480
+ async read() {
481
+ const slice = memorySlices.get(this.key);
482
+ return slice ? { ...slice } : null;
483
+ }
484
+ async write(slice) {
485
+ if (isEmpty(slice)) memorySlices.delete(this.key);
486
+ else memorySlices.set(this.key, { ...slice });
487
+ }
488
+ };
489
+ /**
490
+ * Keystore key for a storage prefix. Kept to `[A-Za-z0-9_.-]` because some
491
+ * secure stores (for example `expo-secure-store`) reject other characters.
492
+ */
493
+ function secureTokenKey(prefix) {
494
+ return `${prefix.replace(/[^A-Za-z0-9_.-]/g, "_")}_session_tokens_v1`;
495
+ }
496
+ /** Credentials in a keystore-backed {@link SecureTokenStorage}. */
497
+ var SecureTokenVault = class {
498
+ kind = "secure";
499
+ constructor(storage, key) {
500
+ this.storage = storage;
501
+ this.key = key;
502
+ }
503
+ async read() {
504
+ const raw = await this.storage.getItem(this.key);
505
+ if (!raw) return null;
506
+ try {
507
+ const parsed = JSON.parse(raw);
508
+ return {
509
+ session_token: parsed.session_token ?? null,
510
+ refresh_token: parsed.refresh_token ?? null,
511
+ expires_at: parsed.expires_at ?? null,
512
+ anonymous_id: parsed.anonymous_id ?? null,
513
+ app_user_id: parsed.app_user_id ?? null
514
+ };
515
+ } catch {
516
+ return null;
517
+ }
518
+ }
519
+ async write(slice) {
520
+ if (isEmpty(slice)) {
521
+ await this.storage.removeItem(this.key);
522
+ return;
523
+ }
524
+ await this.storage.setItem(this.key, JSON.stringify(slice));
525
+ }
526
+ };
527
+ function isKeychainLike(mod) {
528
+ const k = mod;
529
+ return typeof k === "object" && k !== null && typeof k.setGenericPassword === "function" && typeof k.getGenericPassword === "function" && typeof k.resetGenericPassword === "function";
530
+ }
531
+ /**
532
+ * Adapt `react-native-keychain` to {@link SecureTokenStorage}. Items use the
533
+ * `AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY` class on iOS: readable after the
534
+ * first unlock (so background refreshes work) and left behind by backups
535
+ * restored onto another device.
536
+ */
537
+ function keychainStorage(keychain) {
538
+ const accessible = keychain.ACCESSIBLE?.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY;
539
+ return {
540
+ async getItem(key) {
541
+ const entry = await keychain.getGenericPassword({ service: key });
542
+ return entry ? entry.password : null;
543
+ },
544
+ async setItem(key, value) {
545
+ if (await keychain.setGenericPassword("amba", value, {
546
+ service: key,
547
+ ...accessible !== void 0 ? { accessible } : {}
548
+ }) === false) throw new Error("react-native-keychain refused to store the session");
549
+ },
550
+ async removeItem(key) {
551
+ if (await keychain.resetGenericPassword({ service: key }) === false && await keychain.getGenericPassword({ service: key })) throw new Error("react-native-keychain could not remove the session");
552
+ }
553
+ };
554
+ }
555
+ let cachedKeychain;
556
+ /**
557
+ * Load `react-native-keychain` when the app has it installed. Resolves null
558
+ * otherwise (module missing, or a build without its native half).
559
+ */
560
+ async function loadKeychainStorage() {
561
+ if (cachedKeychain !== void 0) return cachedKeychain;
562
+ try {
563
+ const mod = await import("react-native-keychain");
564
+ const candidate = isKeychainLike(mod) ? mod : mod.default;
565
+ cachedKeychain = isKeychainLike(candidate) ? keychainStorage(candidate) : null;
566
+ } catch {
567
+ cachedKeychain = null;
568
+ }
569
+ return cachedKeychain;
570
+ }
571
+ let memoryFallbackWarned = false;
572
+ /**
573
+ * Pick the vault for `mode`. Resolves null for `"asyncStorage"`, which keeps
574
+ * the credentials inside the AsyncStorage identity record.
575
+ */
576
+ async function resolveTokenVault(mode, prefix, secureStorage) {
577
+ if (mode === "asyncStorage") return null;
578
+ const key = secureTokenKey(prefix);
579
+ if (mode === "memory") return new MemoryTokenVault(key);
580
+ const storage = secureStorage ?? await loadKeychainStorage();
581
+ if (storage) return new SecureTokenVault(storage, key);
582
+ if (!memoryFallbackWarned && typeof console !== "undefined") {
583
+ memoryFallbackWarned = true;
584
+ 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.");
585
+ }
586
+ return new MemoryTokenVault(key);
587
+ }
588
+
589
+ //#endregion
590
+ //#region src/path-segment.ts
591
+ /**
592
+ * Encode one caller-supplied id, key, slug or name as a single URL path
593
+ * segment. `encodeURIComponent` escapes `/`, `?` and `#`, so the value stays
594
+ * inside its segment. `.` and `..` are refused because URL resolution treats
595
+ * them as steps to another route, and the empty string is refused because it
596
+ * collapses onto the parent route.
597
+ */
598
+ function pathSegment(value) {
599
+ if (value === "" || value === "." || value === "..") throw new AmbaApiError("VALIDATION_ERROR", `path segment must not be ${value === "" ? "empty" : `'${value}'`}`);
600
+ return encodeURIComponent(value);
601
+ }
602
+
364
603
  //#endregion
365
604
  //#region src/resilience.ts
366
605
  /**
606
+ * Whether `op` belongs to `current`. An op queued under a user id matches only
607
+ * that user id. An op queued before any session matches the same anonymous id,
608
+ * so a guest's offline write lands once that guest's session starts. An op
609
+ * with no recorded owner (queued by an SDK version before owners existed)
610
+ * matches nobody.
611
+ */
612
+ function outboxOwnerMatches(op, current) {
613
+ const owner = op.owner;
614
+ if (!owner) return false;
615
+ if (owner.appUserId) return owner.appUserId === current.appUserId;
616
+ return owner.anonymousId !== null && owner.anonymousId === current.anonymousId;
617
+ }
618
+ /**
367
619
  * Best-effort online probe. In a browser/RN runtime `navigator.onLine`
368
620
  * exists; under Node / SSR it doesn't, so we treat "unknown" as online
369
621
  * (the executor's own error handling then decides).
@@ -447,8 +699,18 @@ var OfflineQueue = class {
447
699
  * SDK surface the loss (debug log) instead of silently discarding.
448
700
  */
449
701
  onDropped;
702
+ /**
703
+ * The identity that is signed in right now. When set, enqueue stamps it on
704
+ * each op and flush/pending only touch ops it owns.
705
+ */
706
+ currentOwner;
450
707
  flushing = null;
451
708
  /**
709
+ * Bumped by `purge()`. A replay that started before a purge sees the change
710
+ * and does not re-queue its op.
711
+ */
712
+ purgeGeneration = 0;
713
+ /**
452
714
  * Serializes every read-modify-write of the persisted outbox. Each mutation
453
715
  * chains onto the previous one's promise, so concurrent `enqueue`s — and an
454
716
  * `enqueue` racing the per-op removal inside `flush` — can never read a stale
@@ -471,6 +733,16 @@ var OfflineQueue = class {
471
733
  this.executor = opts.executor;
472
734
  this.storageKey = opts.storageKey ?? "offline_outbox";
473
735
  this.onDropped = opts.onDropped;
736
+ this.currentOwner = opts.currentOwner;
737
+ }
738
+ /**
739
+ * The ops the current owner may see and replay. Without an owner source
740
+ * every op qualifies.
741
+ */
742
+ visible(ops) {
743
+ if (!this.currentOwner) return ops;
744
+ const current = this.currentOwner();
745
+ return ops.filter((op) => outboxOwnerMatches(op, current));
474
746
  }
475
747
  /**
476
748
  * Run `fn` exclusively against the persisted outbox: it chains after every
@@ -527,28 +799,55 @@ var OfflineQueue = class {
527
799
  get isDisposed() {
528
800
  return this.disposed;
529
801
  }
530
- /** Append an op to the durable outbox. Returns the persisted op. */
531
- async enqueue(op) {
802
+ /**
803
+ * Capture the current owner and purge generation. Take it before a write's
804
+ * live attempt and pass it to {@link enqueue}.
805
+ */
806
+ ticket() {
807
+ return {
808
+ owner: this.currentOwner?.(),
809
+ generation: this.purgeGeneration
810
+ };
811
+ }
812
+ /**
813
+ * Append an op to the durable outbox. Returns the persisted op. With a
814
+ * `ticket`, the op belongs to the ticket's owner, and the enqueue rejects
815
+ * (code `OFFLINE_QUEUE_PURGED`) when the queue was purged after the ticket
816
+ * was taken, which is what a sign-out during the live attempt does.
817
+ */
818
+ async enqueue(op, ticket) {
532
819
  if (this.disposed) throw new Error("amba offline queue was disposed (SDK reconfigured)");
820
+ const owner = op.owner ?? (ticket ? ticket.owner : this.currentOwner?.());
533
821
  const full = {
534
822
  ...op,
823
+ ...owner ? { owner: { ...owner } } : {},
535
824
  idempotencyKey: op.idempotencyKey ?? newIdempotencyKey(),
536
825
  enqueuedAt: (/* @__PURE__ */ new Date()).toISOString()
537
826
  };
538
- return this.mutate((ops) => ({
539
- next: [...ops, full],
540
- result: full
541
- }), { rejectIfDisposed: true });
827
+ return this.mutate((ops) => {
828
+ if (ticket && ticket.generation !== this.purgeGeneration) {
829
+ const err = /* @__PURE__ */ new Error("amba offline queue was cleared by a sign-out while this write was in flight");
830
+ err.code = "OFFLINE_QUEUE_PURGED";
831
+ throw err;
832
+ }
833
+ return {
834
+ next: [...ops, full],
835
+ result: full
836
+ };
837
+ }, { rejectIfDisposed: true });
542
838
  }
543
- /** Number of ops awaiting replay. */
839
+ /** Number of the current owner's ops awaiting replay. */
544
840
  async pendingCount() {
545
- return (await this.load()).length;
841
+ return this.visible(await this.load()).length;
546
842
  }
547
- /** Snapshot the pending ops (a copy — mutating it does not affect the queue). */
843
+ /**
844
+ * Snapshot the current owner's pending ops (a copy — mutating it does not
845
+ * affect the queue). Ops queued by another user stay out of the list.
846
+ */
548
847
  async pending() {
549
- return [...await this.load()];
848
+ return [...this.visible(await this.load())];
550
849
  }
551
- /** Drop every queued op without replaying. */
850
+ /** Drop every queued op, whoever queued it, without replaying. */
552
851
  async clear() {
553
852
  await this.mutate(() => ({
554
853
  next: [],
@@ -556,10 +855,23 @@ var OfflineQueue = class {
556
855
  }));
557
856
  }
558
857
  /**
559
- * Replay the outbox in FIFO order. Each op is removed only after it
560
- * succeeds; the first failure stops the flush and retains the remainder
561
- * (so order is preserved and nothing is dropped mid-batch). Concurrent
562
- * calls share one in-flight flush.
858
+ * Drop every queued op, including one whose replay is in flight right now:
859
+ * that op is not re-queued when its replay fails. The SDKs call this on
860
+ * sign-out so nothing the signed-out user queued survives on the device.
861
+ */
862
+ async purge() {
863
+ this.purgeGeneration += 1;
864
+ await this.clear();
865
+ }
866
+ /**
867
+ * Replay the current owner's ops in FIFO order. Each op is removed only
868
+ * after it succeeds; the first failure stops the flush and retains the
869
+ * remainder (so order is preserved and nothing is dropped mid-batch). Ops
870
+ * another user queued stay held for that user. Ops that can never replay
871
+ * are dropped through `onDropped`: one with no recorded owner (code
872
+ * `OFFLINE_OWNER_UNKNOWN`), and one queued before any session under an
873
+ * anonymous id the install no longer has (code `OFFLINE_OWNER_GONE`).
874
+ * Concurrent calls share one in-flight flush.
563
875
  */
564
876
  async flush() {
565
877
  if (this.disposed) return {
@@ -578,8 +890,26 @@ var OfflineQueue = class {
578
890
  let dropped = 0;
579
891
  for (;;) {
580
892
  if (this.disposed) break;
581
- const head = (await this.load())[0];
893
+ const all = await this.load();
894
+ if (this.currentOwner) {
895
+ const current = this.currentOwner();
896
+ const orphan = all.find((o) => !o.owner || o.owner.appUserId === null && current.anonymousId !== null && o.owner.anonymousId !== current.anonymousId);
897
+ if (orphan) {
898
+ if (!await this.removeOp(orphan)) break;
899
+ dropped += 1;
900
+ this.reportDropped(orphan, orphan.owner ? {
901
+ code: "OFFLINE_OWNER_GONE",
902
+ message: "queued before sign-in under an anonymous id this install no longer has; not replayed"
903
+ } : {
904
+ code: "OFFLINE_OWNER_UNKNOWN",
905
+ message: "queued by an earlier SDK version without an owner; not replayed"
906
+ });
907
+ continue;
908
+ }
909
+ }
910
+ const head = this.visible(all)[0];
582
911
  if (!head) break;
912
+ const generation = this.purgeGeneration;
583
913
  let replayError = void 0;
584
914
  let ok = false;
585
915
  try {
@@ -591,32 +921,20 @@ var OfflineQueue = class {
591
921
  if (!ok && !isDeterministicError(replayError)) {
592
922
  await this.mutate((ops) => {
593
923
  return {
594
- next: ops.some((o) => o.idempotencyKey === head.idempotencyKey) ? ops : [head, ...ops],
924
+ next: !ops.some((o) => o.idempotencyKey === head.idempotencyKey) && generation === this.purgeGeneration ? [head, ...ops] : ops,
595
925
  result: void 0
596
926
  };
597
927
  });
598
928
  break;
599
929
  }
600
- if (!await this.mutate((ops) => {
601
- const idx = ops.findIndex((o) => o.idempotencyKey === head.idempotencyKey);
602
- if (idx === -1) return {
603
- next: ops,
604
- result: false
605
- };
606
- return {
607
- next: ops.slice(0, idx).concat(ops.slice(idx + 1)),
608
- result: true
609
- };
610
- })) break;
930
+ if (!await this.removeOp(head)) break;
611
931
  if (ok) flushed += 1;
612
932
  else {
613
933
  dropped += 1;
614
- try {
615
- this.onDropped?.(head, replayError);
616
- } catch {}
934
+ this.reportDropped(head, replayError);
617
935
  }
618
936
  }
619
- const remaining = (await this.load()).length;
937
+ const remaining = this.visible(await this.load()).length;
620
938
  return {
621
939
  flushed,
622
940
  dropped,
@@ -624,6 +942,29 @@ var OfflineQueue = class {
624
942
  };
625
943
  }
626
944
  /**
945
+ * Remove exactly `op` (by idempotency key) under the lock. Robust against a
946
+ * concurrent enqueue having shifted the array. Resolves false when the op
947
+ * is already gone.
948
+ */
949
+ removeOp(op) {
950
+ return this.mutate((ops) => {
951
+ const idx = ops.findIndex((o) => o.idempotencyKey === op.idempotencyKey);
952
+ if (idx === -1) return {
953
+ next: ops,
954
+ result: false
955
+ };
956
+ return {
957
+ next: ops.slice(0, idx).concat(ops.slice(idx + 1)),
958
+ result: true
959
+ };
960
+ });
961
+ }
962
+ reportDropped(op, err) {
963
+ try {
964
+ this.onDropped?.(op, err);
965
+ } catch {}
966
+ }
967
+ /**
627
968
  * Install an auto-flush listener on the runtime's `online` event so the
628
969
  * queue drains the moment connectivity returns. Idempotent — a second
629
970
  * call is a no-op. Returns a detach function.
@@ -1192,7 +1533,7 @@ var CollectionSubscription = class {
1192
1533
  if (!this.ctx.authHeaders()) throw new Error("Amba.collection().subscribe() requires an authenticated session — call Amba.auth.signIn* first.");
1193
1534
  const qs = Object.keys(this.filters).length > 0 ? `?where=${encodeURIComponent(JSON.stringify(this.filters))}` : "";
1194
1535
  return openStream({
1195
- url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/collections/${encodeURIComponent(this.name)}${qs}`,
1536
+ url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/collections/${pathSegment(this.name)}${qs}`,
1196
1537
  authHeaders: this.ctx.authHeaders,
1197
1538
  onChange: cb,
1198
1539
  options,
@@ -1216,7 +1557,7 @@ var ConversationSubscription = class {
1216
1557
  subscribe(cb, options = {}) {
1217
1558
  if (!this.ctx.authHeaders()) throw new Error("Amba.messaging.conversation().subscribe() requires an authenticated session — call Amba.auth.signIn* first.");
1218
1559
  return openStream({
1219
- url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/conversations/${encodeURIComponent(this.conversationId)}`,
1560
+ url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/conversations/${pathSegment(this.conversationId)}`,
1220
1561
  authHeaders: this.ctx.authHeaders,
1221
1562
  onChange: cb,
1222
1563
  options,
@@ -1476,7 +1817,7 @@ function delay(ms) {
1476
1817
  //#endregion
1477
1818
  //#region src/index.ts
1478
1819
  /** SDK semver — burned in at build time. Bump alongside package.json. */
1479
- const SDK_VERSION = "4.0.7";
1820
+ const SDK_VERSION = "4.0.8";
1480
1821
  /**
1481
1822
  * Default host that serves deployed functions. `Amba.functions.invoke(name)`
1482
1823
  * targets `https://<projectId>.<DEFAULT_FUNCTIONS_HOST>/<name>` unless a
@@ -1717,8 +2058,20 @@ const P = {
1717
2058
  deepLinks: "/v1/client/deep-links",
1718
2059
  onboarding: "/v1/client/onboarding"
1719
2060
  };
2061
+ /**
2062
+ * Encode one caller-supplied id, key or name as a single URL path segment.
2063
+ * `encodeURIComponent` escapes `/`, `?` and `#`; `.` and `..` are refused
2064
+ * because URL resolution treats them as steps to another route, and the
2065
+ * empty string is refused because it collapses onto the parent route.
2066
+ */
1720
2067
  function encodePathSegment(s) {
1721
- return encodeURIComponent(s);
2068
+ return pathSegment(s);
2069
+ }
2070
+ function nonceField(options) {
2071
+ const nonce = options?.nonce;
2072
+ if (nonce === void 0) return {};
2073
+ if (typeof nonce !== "string" || nonce.trim() === "") throw new AmbaApiError("VALIDATION_ERROR", "nonce must be a non-empty string");
2074
+ return { nonce };
1722
2075
  }
1723
2076
  /**
1724
2077
  * Resolve the full invocation URL for a deployed function. Resolution checks
@@ -1728,8 +2081,8 @@ function encodePathSegment(s) {
1728
2081
  * cached for subsequent invokes.
1729
2082
  */
1730
2083
  async function resolveFunctionUrl(s, name) {
1731
- if (s.functionsBaseUrl) return `${s.functionsBaseUrl.replace(/\/$/, "")}/${encodeURIComponent(name)}`;
1732
- return `https://${await resolveFunctionsProjectId(s)}.${DEFAULT_FUNCTIONS_HOST}/${encodeURIComponent(name)}`;
2084
+ if (s.functionsBaseUrl) return `${s.functionsBaseUrl.replace(/\/$/, "")}/${pathSegment(name)}`;
2085
+ return `https://${await resolveFunctionsProjectId(s)}.${DEFAULT_FUNCTIONS_HOST}/${pathSegment(name)}`;
1733
2086
  }
1734
2087
  async function resolveFunctionsProjectId(s) {
1735
2088
  if (s.resolvedFunctionsProjectId) return s.resolvedFunctionsProjectId;
@@ -1806,6 +2159,10 @@ function getOfflineQueue(s) {
1806
2159
  store: offlineStore(s),
1807
2160
  storageKey: `offline_outbox:${s.projectId}`,
1808
2161
  executor: (op) => replayOutboxOp(s, op),
2162
+ currentOwner: () => ({
2163
+ appUserId: s.identity.getSessionToken() || s.identity.getRefreshToken() ? s.identity.getAppUserId() : null,
2164
+ anonymousId: s.identity.getAnonymousId()
2165
+ }),
1809
2166
  onDropped: (op, err) => {
1810
2167
  if (typeof console !== "undefined") console.warn(`[amba] dropped un-replayable offline ${op.kind} (${op.collection ?? op.event ?? "?"}):`, err);
1811
2168
  }
@@ -1836,10 +2193,16 @@ const Amba = {
1836
2193
  if (!Number.isInteger(flushIntervalMs) || flushIntervalMs < 0) throw new Error("Amba.configure: events.flushIntervalMs must be a non-negative integer");
1837
2194
  if (!Number.isInteger(maxQueue) || maxQueue < 1) throw new Error("Amba.configure: events.maxQueue must be positive");
1838
2195
  }
1839
- const persistence = new AsyncStoragePersistence(config.storagePrefix ?? "amba");
2196
+ const tokenStorage = config.tokenStorage ?? "secure";
2197
+ if (!TOKEN_STORAGE_MODES.includes(tokenStorage)) throw new Error("Amba.configure: tokenStorage must be \"secure\", \"memory\" or \"asyncStorage\"");
2198
+ const secureStorage = config.secureStorage;
2199
+ 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");
2200
+ const storagePrefix = config.storagePrefix ?? "amba";
2201
+ const persistence = new AsyncStoragePersistence(storagePrefix);
1840
2202
  if (config.events?.batching) await persistence.assertDurable();
1841
2203
  await persistence.hydrate();
1842
- const identity = await IdentityStore.create(persistence);
2204
+ const vault = await resolveTokenVault(tokenStorage, storagePrefix, secureStorage);
2205
+ const identity = await IdentityStore.create(persistence, vault);
1843
2206
  const baseUrl = (config.apiUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
1844
2207
  const sdkVersionHeader = `amba-sdk/${SDK_VERSION} ${`amba-react-native/${SDK_VERSION}`}`;
1845
2208
  const http = new AmbaHttpClient({
@@ -1940,6 +2303,7 @@ const Amba = {
1940
2303
  timestamp: occurredAt
1941
2304
  };
1942
2305
  if (options?.telemetry) body["telemetry"] = true;
2306
+ const outboxTicket = s.offlineEnabled ? getOfflineQueue(s).ticket() : void 0;
1943
2307
  try {
1944
2308
  await s.http.post(P.events, body);
1945
2309
  } catch (err) {
@@ -1950,7 +2314,7 @@ const Amba = {
1950
2314
  properties,
1951
2315
  telemetry: options?.telemetry,
1952
2316
  occurredAt
1953
- });
2317
+ }, outboxTicket);
1954
2318
  return;
1955
2319
  }
1956
2320
  throw err;
@@ -1994,18 +2358,20 @@ const Amba = {
1994
2358
  password
1995
2359
  });
1996
2360
  },
1997
- signInWithApple: async (identityToken) => {
2361
+ signInWithApple: async (identityToken, options) => {
1998
2362
  if (identityToken.trim() === "") throw new AmbaApiError("VALIDATION_ERROR", "identity_token must not be empty");
1999
2363
  return postAuth(getState(), P.authSocial, {
2000
2364
  provider: "apple",
2001
- id_token: identityToken
2365
+ id_token: identityToken,
2366
+ ...nonceField(options)
2002
2367
  });
2003
2368
  },
2004
- signInWithGoogle: async (idToken) => {
2369
+ signInWithGoogle: async (idToken, options) => {
2005
2370
  if (idToken.trim() === "") throw new AmbaApiError("VALIDATION_ERROR", "id_token must not be empty");
2006
2371
  return postAuth(getState(), P.authSocial, {
2007
2372
  provider: "google",
2008
- id_token: idToken
2373
+ id_token: idToken,
2374
+ ...nonceField(options)
2009
2375
  });
2010
2376
  },
2011
2377
  requestEmailOtp: async (email) => {
@@ -2040,7 +2406,7 @@ const Amba = {
2040
2406
  if (token.trim() === "") throw new AmbaApiError("VALIDATION_ERROR", "token must not be empty");
2041
2407
  return postAuth(getState(), P.authMagicLinkVerify, { token });
2042
2408
  },
2043
- linkAccount: async (provider, credential) => {
2409
+ linkAccount: async (provider, credential, options) => {
2044
2410
  const providerTrimmed = provider?.trim() ?? "";
2045
2411
  if (providerTrimmed === "") throw new AmbaApiError("VALIDATION_ERROR", "provider must not be empty");
2046
2412
  if (typeof credential !== "string" || credential.trim() === "") throw new AmbaApiError("VALIDATION_ERROR", "credential must not be empty");
@@ -2050,7 +2416,8 @@ const Amba = {
2050
2416
  return postAuth(s, P.authLink, {
2051
2417
  provider: providerTrimmed,
2052
2418
  token: credential,
2053
- session_token: sessionToken
2419
+ session_token: sessionToken,
2420
+ ...nonceField(options)
2054
2421
  });
2055
2422
  },
2056
2423
  linkSmsOtp: async (phone, code) => {
@@ -2109,6 +2476,11 @@ const Amba = {
2109
2476
  if (typeof console !== "undefined") console.warn("[amba] signOut server call failed", err);
2110
2477
  }
2111
2478
  await s.identity.clearSession(rotateAnonymousId);
2479
+ try {
2480
+ await getOfflineQueue(s).purge();
2481
+ } catch (err) {
2482
+ if (typeof console !== "undefined") console.warn("[amba] signOut could not clear the offline outbox", err);
2483
+ }
2112
2484
  s.eventQueue?.flush();
2113
2485
  notifyAuthSubscribers(null);
2114
2486
  },
@@ -2224,6 +2596,7 @@ const Amba = {
2224
2596
  const s = getState();
2225
2597
  if (s.offlineEnabled) {
2226
2598
  const idempotencyKey = (typeof row["idempotency_key"] === "string" ? row["idempotency_key"] : void 0) ?? newIdempotencyKey();
2599
+ const outboxTicket = s.offlineEnabled ? getOfflineQueue(s).ticket() : void 0;
2227
2600
  try {
2228
2601
  return (await s.http.post(`${P.collections}/${encodePathSegment(name)}`, {
2229
2602
  ...row,
@@ -2236,7 +2609,7 @@ const Amba = {
2236
2609
  collection: name,
2237
2610
  payload: row,
2238
2611
  idempotencyKey
2239
- });
2612
+ }, outboxTicket);
2240
2613
  return {
2241
2614
  ...row,
2242
2615
  _amba_pending: true
@@ -2249,40 +2622,46 @@ const Amba = {
2249
2622
  },
2250
2623
  update: async (name, id, set) => {
2251
2624
  const s = getState();
2252
- if (s.offlineEnabled) try {
2253
- return (await s.http.patch(`${P.collections}/${encodePathSegment(name)}/${encodePathSegment(id)}`, { set })).data;
2254
- } catch (err) {
2255
- if (isOfflineError(err)) {
2256
- await getOfflineQueue(s).enqueue({
2257
- kind: "collection.update",
2258
- collection: name,
2259
- id,
2260
- payload: set
2261
- });
2262
- return {
2263
- id,
2264
- ...set,
2265
- _amba_pending: true
2266
- };
2625
+ if (s.offlineEnabled) {
2626
+ const outboxTicket = s.offlineEnabled ? getOfflineQueue(s).ticket() : void 0;
2627
+ try {
2628
+ return (await s.http.patch(`${P.collections}/${encodePathSegment(name)}/${encodePathSegment(id)}`, { set })).data;
2629
+ } catch (err) {
2630
+ if (isOfflineError(err)) {
2631
+ await getOfflineQueue(s).enqueue({
2632
+ kind: "collection.update",
2633
+ collection: name,
2634
+ id,
2635
+ payload: set
2636
+ }, outboxTicket);
2637
+ return {
2638
+ id,
2639
+ ...set,
2640
+ _amba_pending: true
2641
+ };
2642
+ }
2643
+ throw err;
2267
2644
  }
2268
- throw err;
2269
2645
  }
2270
2646
  return (await s.http.patch(`${P.collections}/${encodePathSegment(name)}/${encodePathSegment(id)}`, { set })).data;
2271
2647
  },
2272
2648
  delete: async (name, id) => {
2273
2649
  const s = getState();
2274
- if (s.offlineEnabled) try {
2275
- return await s.http.delete(`${P.collections}/${encodePathSegment(name)}/${encodePathSegment(id)}`) ?? { data: { deleted: true } };
2276
- } catch (err) {
2277
- if (isOfflineError(err)) {
2278
- await getOfflineQueue(s).enqueue({
2279
- kind: "collection.delete",
2280
- collection: name,
2281
- id
2282
- });
2283
- return { data: { deleted: true } };
2650
+ if (s.offlineEnabled) {
2651
+ const outboxTicket = s.offlineEnabled ? getOfflineQueue(s).ticket() : void 0;
2652
+ try {
2653
+ return await s.http.delete(`${P.collections}/${encodePathSegment(name)}/${encodePathSegment(id)}`) ?? { data: { deleted: true } };
2654
+ } catch (err) {
2655
+ if (isOfflineError(err)) {
2656
+ await getOfflineQueue(s).enqueue({
2657
+ kind: "collection.delete",
2658
+ collection: name,
2659
+ id
2660
+ }, outboxTicket);
2661
+ return { data: { deleted: true } };
2662
+ }
2663
+ throw err;
2284
2664
  }
2285
- throw err;
2286
2665
  }
2287
2666
  return await s.http.delete(`${P.collections}/${encodePathSegment(name)}/${encodePathSegment(id)}`) ?? { data: { deleted: true } };
2288
2667
  },