@cross-deck/web 1.2.0 → 1.4.2

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/react.cjs CHANGED
@@ -92,9 +92,11 @@ function typeMapForStatus(status) {
92
92
  return "internal_error";
93
93
  }
94
94
 
95
- // src/http.ts
95
+ // src/_version.ts
96
+ var SDK_VERSION = "1.4.2";
96
97
  var SDK_NAME = "@cross-deck/web";
97
- var SDK_VERSION = "1.1.0";
98
+
99
+ // src/http.ts
98
100
  var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
99
101
  var DEFAULT_TIMEOUT_MS = 15e3;
100
102
  var HttpClient = class {
@@ -341,29 +343,258 @@ function randomChars(count) {
341
343
  return out.join("");
342
344
  }
343
345
 
346
+ // src/hash.ts
347
+ var K = new Uint32Array([
348
+ 1116352408,
349
+ 1899447441,
350
+ 3049323471,
351
+ 3921009573,
352
+ 961987163,
353
+ 1508970993,
354
+ 2453635748,
355
+ 2870763221,
356
+ 3624381080,
357
+ 310598401,
358
+ 607225278,
359
+ 1426881987,
360
+ 1925078388,
361
+ 2162078206,
362
+ 2614888103,
363
+ 3248222580,
364
+ 3835390401,
365
+ 4022224774,
366
+ 264347078,
367
+ 604807628,
368
+ 770255983,
369
+ 1249150122,
370
+ 1555081692,
371
+ 1996064986,
372
+ 2554220882,
373
+ 2821834349,
374
+ 2952996808,
375
+ 3210313671,
376
+ 3336571891,
377
+ 3584528711,
378
+ 113926993,
379
+ 338241895,
380
+ 666307205,
381
+ 773529912,
382
+ 1294757372,
383
+ 1396182291,
384
+ 1695183700,
385
+ 1986661051,
386
+ 2177026350,
387
+ 2456956037,
388
+ 2730485921,
389
+ 2820302411,
390
+ 3259730800,
391
+ 3345764771,
392
+ 3516065817,
393
+ 3600352804,
394
+ 4094571909,
395
+ 275423344,
396
+ 430227734,
397
+ 506948616,
398
+ 659060556,
399
+ 883997877,
400
+ 958139571,
401
+ 1322822218,
402
+ 1537002063,
403
+ 1747873779,
404
+ 1955562222,
405
+ 2024104815,
406
+ 2227730452,
407
+ 2361852424,
408
+ 2428436474,
409
+ 2756734187,
410
+ 3204031479,
411
+ 3329325298
412
+ ]);
413
+ function utf8Bytes(input) {
414
+ if (typeof TextEncoder !== "undefined") {
415
+ return new TextEncoder().encode(input);
416
+ }
417
+ const out = [];
418
+ for (let i = 0; i < input.length; i++) {
419
+ let codePoint = input.charCodeAt(i);
420
+ if (codePoint >= 55296 && codePoint <= 56319 && i + 1 < input.length) {
421
+ const next = input.charCodeAt(i + 1);
422
+ if (next >= 56320 && next <= 57343) {
423
+ codePoint = 65536 + (codePoint - 55296 << 10) + (next - 56320);
424
+ i++;
425
+ }
426
+ }
427
+ if (codePoint < 128) {
428
+ out.push(codePoint);
429
+ } else if (codePoint < 2048) {
430
+ out.push(192 | codePoint >> 6);
431
+ out.push(128 | codePoint & 63);
432
+ } else if (codePoint < 65536) {
433
+ out.push(224 | codePoint >> 12);
434
+ out.push(128 | codePoint >> 6 & 63);
435
+ out.push(128 | codePoint & 63);
436
+ } else {
437
+ out.push(240 | codePoint >> 18);
438
+ out.push(128 | codePoint >> 12 & 63);
439
+ out.push(128 | codePoint >> 6 & 63);
440
+ out.push(128 | codePoint & 63);
441
+ }
442
+ }
443
+ return new Uint8Array(out);
444
+ }
445
+ function sha256Hex(input) {
446
+ const bytes = utf8Bytes(input);
447
+ const bitLength = bytes.length * 8;
448
+ const blockCount = Math.floor((bytes.length + 9 + 63) / 64);
449
+ const padded = new Uint8Array(blockCount * 64);
450
+ padded.set(bytes);
451
+ padded[bytes.length] = 128;
452
+ const high = Math.floor(bitLength / 4294967296);
453
+ const low = bitLength >>> 0;
454
+ const lenOffset = padded.length - 8;
455
+ padded[lenOffset + 0] = high >>> 24 & 255;
456
+ padded[lenOffset + 1] = high >>> 16 & 255;
457
+ padded[lenOffset + 2] = high >>> 8 & 255;
458
+ padded[lenOffset + 3] = high & 255;
459
+ padded[lenOffset + 4] = low >>> 24 & 255;
460
+ padded[lenOffset + 5] = low >>> 16 & 255;
461
+ padded[lenOffset + 6] = low >>> 8 & 255;
462
+ padded[lenOffset + 7] = low & 255;
463
+ const H = new Uint32Array([
464
+ 1779033703,
465
+ 3144134277,
466
+ 1013904242,
467
+ 2773480762,
468
+ 1359893119,
469
+ 2600822924,
470
+ 528734635,
471
+ 1541459225
472
+ ]);
473
+ const W = new Uint32Array(64);
474
+ for (let block = 0; block < blockCount; block++) {
475
+ const offset = block * 64;
476
+ for (let t = 0; t < 16; t++) {
477
+ W[t] = (padded[offset + t * 4] << 24 | padded[offset + t * 4 + 1] << 16 | padded[offset + t * 4 + 2] << 8 | padded[offset + t * 4 + 3]) >>> 0;
478
+ }
479
+ for (let t = 16; t < 64; t++) {
480
+ const w15 = W[t - 15];
481
+ const w2 = W[t - 2];
482
+ const s0 = (w15 >>> 7 | w15 << 25) ^ (w15 >>> 18 | w15 << 14) ^ w15 >>> 3;
483
+ const s1 = (w2 >>> 17 | w2 << 15) ^ (w2 >>> 19 | w2 << 13) ^ w2 >>> 10;
484
+ W[t] = W[t - 16] + s0 + W[t - 7] + s1 >>> 0;
485
+ }
486
+ let a = H[0], b = H[1], c = H[2], d = H[3];
487
+ let e = H[4], f = H[5], g = H[6], h = H[7];
488
+ for (let t = 0; t < 64; t++) {
489
+ const S1 = (e >>> 6 | e << 26) ^ (e >>> 11 | e << 21) ^ (e >>> 25 | e << 7);
490
+ const ch = e & f ^ ~e & g;
491
+ const temp1 = h + S1 + ch + K[t] + W[t] >>> 0;
492
+ const S0 = (a >>> 2 | a << 30) ^ (a >>> 13 | a << 19) ^ (a >>> 22 | a << 10);
493
+ const maj = a & b ^ a & c ^ b & c;
494
+ const temp2 = S0 + maj >>> 0;
495
+ h = g;
496
+ g = f;
497
+ f = e;
498
+ e = d + temp1 >>> 0;
499
+ d = c;
500
+ c = b;
501
+ b = a;
502
+ a = temp1 + temp2 >>> 0;
503
+ }
504
+ H[0] = H[0] + a >>> 0;
505
+ H[1] = H[1] + b >>> 0;
506
+ H[2] = H[2] + c >>> 0;
507
+ H[3] = H[3] + d >>> 0;
508
+ H[4] = H[4] + e >>> 0;
509
+ H[5] = H[5] + f >>> 0;
510
+ H[6] = H[6] + g >>> 0;
511
+ H[7] = H[7] + h >>> 0;
512
+ }
513
+ let hex = "";
514
+ for (let i = 0; i < 8; i++) {
515
+ hex += H[i].toString(16).padStart(8, "0");
516
+ }
517
+ return hex;
518
+ }
519
+
344
520
  // src/entitlement-cache.ts
345
521
  var DEFAULT_STALE_AFTER_MS = 24 * 60 * 60 * 1e3;
346
- var EntitlementCache = class {
522
+ var ANON_SUFFIX = "_anon";
523
+ var INDEX_SUFFIX = "_index";
524
+ var EntitlementCache = class _EntitlementCache {
347
525
  /**
348
- * @param storage Device storage adapter. When omitted (tests) or
349
- * a MemoryStorage (strict-consent / no-persistence
350
- * mode) the cache is session-only — durability is
351
- * simply absent, never wrong.
352
- * @param storageKey Full key the persisted blob lives under.
353
- * @param staleAfterMs Age past which last-known-good is flagged stale
354
- * even without a failed refresh. Default 24h.
526
+ * @param storage Device storage adapter. When omitted (tests) or
527
+ * a MemoryStorage (strict-consent / no-persistence
528
+ * mode) the cache is session-only — durability is
529
+ * simply absent, never wrong.
530
+ * @param storageKeyPrefix Prefix used to derive per-user storage keys
531
+ * (`<prefix>:<sha256(userId)>`). Default
532
+ * `crossdeck:entitlements`. The trailing user
533
+ * suffix is filled at identify() / reset()
534
+ * time — see [[setUserKey]].
535
+ * @param staleAfterMs Age past which last-known-good is flagged stale
536
+ * even without a failed refresh. Default 24h.
355
537
  */
356
- constructor(storage, storageKey = "crossdeck:entitlements", staleAfterMs = DEFAULT_STALE_AFTER_MS) {
538
+ constructor(storage, storageKeyPrefix = "crossdeck:entitlements", staleAfterMs = DEFAULT_STALE_AFTER_MS) {
357
539
  this.all = [];
358
540
  this.lastUpdated = 0;
359
541
  this.lastRefreshFailedAt = 0;
360
542
  this.listeners = /* @__PURE__ */ new Set();
361
543
  this.listenerErrorCount = 0;
544
+ this.currentSuffix = ANON_SUFFIX;
362
545
  this.storage = storage;
363
- this.storageKey = storageKey;
546
+ this.storageKeyPrefix = storageKeyPrefix;
364
547
  this.staleAfterMs = staleAfterMs;
365
548
  this.hydrate();
366
549
  }
550
+ /** The full storage key the current-user blob is persisted under. */
551
+ get storageKey() {
552
+ return `${this.storageKeyPrefix}:${this.currentSuffix}`;
553
+ }
554
+ /** Key of the index blob — a JSON array of every suffix we've
555
+ * written. Used by clearAll() to scope a logout-wipe. */
556
+ get indexKey() {
557
+ return `${this.storageKeyPrefix}:${INDEX_SUFFIX}`;
558
+ }
559
+ /** Derive a stable suffix for a developerUserId via SHA-256. The
560
+ * raw userId never lands in the storage key — protects against
561
+ * accidentally leaking email-style identifiers through DevTools
562
+ * inspection. Pass `null` to switch back to the anonymous slot. */
563
+ static suffixForUserId(userId) {
564
+ if (userId == null || userId === "") return ANON_SUFFIX;
565
+ return sha256Hex(userId);
566
+ }
567
+ /**
568
+ * Switch the cache to a different user's storage slot. Bank-grade
569
+ * three-layer isolation:
570
+ * (a) Physical key separation — `<prefix>:<sha256(userId)>` so
571
+ * a user-switch can't physically read prior user's data
572
+ * even if the in-memory clear was skipped.
573
+ * (b) Unconditional in-memory clear — invoked whenever the
574
+ * active suffix changes, even on same-id re-identify.
575
+ * (c) Re-hydrate from the new slot — a returning user observes
576
+ * their last-known-good cache from storage immediately.
577
+ *
578
+ * Caller (identify() / reset()) MUST invoke this BEFORE the next
579
+ * setFromList() so the write lands under the right key.
580
+ */
581
+ setUserKey(userId) {
582
+ const nextSuffix = _EntitlementCache.suffixForUserId(userId);
583
+ if (nextSuffix === this.currentSuffix) {
584
+ this.all = [];
585
+ this.lastUpdated = 0;
586
+ this.lastRefreshFailedAt = 0;
587
+ this.notify();
588
+ this.hydrate();
589
+ return;
590
+ }
591
+ this.currentSuffix = nextSuffix;
592
+ this.all = [];
593
+ this.lastUpdated = 0;
594
+ this.lastRefreshFailedAt = 0;
595
+ this.hydrate();
596
+ this.notify();
597
+ }
367
598
  /**
368
599
  * Sync read — true iff the entitlement is currently granting access.
369
600
  *
@@ -433,12 +664,13 @@ var EntitlementCache = class {
433
664
  this.lastUpdated = Date.now();
434
665
  this.lastRefreshFailedAt = 0;
435
666
  this.persist();
667
+ this.recordSuffixInIndex(this.currentSuffix);
436
668
  this.notify();
437
669
  }
438
670
  /**
439
- * Wipe used on reset() (logout) and on an identity switch. Clears
440
- * BOTH memory and durable storage so a prior user's entitlements can
441
- * never leak to the next person on this device.
671
+ * Wipe the CURRENT user's slot. Used internally when a single
672
+ * user's cache needs to be invalidated without affecting other
673
+ * persisted slots. The full-logout path is [[clearAll]].
442
674
  */
443
675
  clear() {
444
676
  this.all = [];
@@ -450,6 +682,40 @@ var EntitlementCache = class {
450
682
  } catch {
451
683
  }
452
684
  }
685
+ this.removeSuffixFromIndex(this.currentSuffix);
686
+ this.notify();
687
+ }
688
+ /**
689
+ * Logout-grade wipe — bank-grade contract: removes EVERY per-user
690
+ * entitlement slot the SDK has ever written on this device, then
691
+ * clears the index. Used by `Crossdeck.reset()` so a logout on a
692
+ * shared device can never leave another user's entitlements
693
+ * readable (layer (c) of the v1.4.0 isolation fix).
694
+ *
695
+ * After clearAll(), the cache is back to anonymous + empty.
696
+ */
697
+ clearAll() {
698
+ this.all = [];
699
+ this.lastUpdated = 0;
700
+ this.lastRefreshFailedAt = 0;
701
+ this.currentSuffix = ANON_SUFFIX;
702
+ if (this.storage) {
703
+ const suffixes = this.readIndex();
704
+ for (const suffix of suffixes) {
705
+ try {
706
+ this.storage.removeItem(`${this.storageKeyPrefix}:${suffix}`);
707
+ } catch {
708
+ }
709
+ }
710
+ try {
711
+ this.storage.removeItem(`${this.storageKeyPrefix}:${ANON_SUFFIX}`);
712
+ } catch {
713
+ }
714
+ try {
715
+ this.storage.removeItem(this.indexKey);
716
+ } catch {
717
+ }
718
+ }
453
719
  this.notify();
454
720
  }
455
721
  /**
@@ -499,6 +765,47 @@ var EntitlementCache = class {
499
765
  } catch {
500
766
  }
501
767
  }
768
+ /** Read the index of all per-user suffixes the SDK has written. */
769
+ readIndex() {
770
+ if (!this.storage) return [];
771
+ try {
772
+ const raw = this.storage.getItem(this.indexKey);
773
+ if (!raw) return [];
774
+ const parsed = JSON.parse(raw);
775
+ if (Array.isArray(parsed)) {
776
+ return parsed.filter((x) => typeof x === "string");
777
+ }
778
+ return [];
779
+ } catch {
780
+ return [];
781
+ }
782
+ }
783
+ /** Add a suffix to the persisted index. Idempotent. */
784
+ recordSuffixInIndex(suffix) {
785
+ if (!this.storage) return;
786
+ const existing = this.readIndex();
787
+ if (existing.includes(suffix)) return;
788
+ existing.push(suffix);
789
+ try {
790
+ this.storage.setItem(this.indexKey, JSON.stringify(existing));
791
+ } catch {
792
+ }
793
+ }
794
+ /** Remove a suffix from the persisted index. No-op if absent. */
795
+ removeSuffixFromIndex(suffix) {
796
+ if (!this.storage) return;
797
+ const existing = this.readIndex();
798
+ const next = existing.filter((s) => s !== suffix);
799
+ if (next.length === existing.length) return;
800
+ try {
801
+ if (next.length === 0) {
802
+ this.storage.removeItem(this.indexKey);
803
+ } else {
804
+ this.storage.setItem(this.indexKey, JSON.stringify(next));
805
+ }
806
+ } catch {
807
+ }
808
+ }
502
809
  notify() {
503
810
  if (this.listeners.size === 0) return;
504
811
  const snapshot = this.all.slice();
@@ -513,6 +820,34 @@ var EntitlementCache = class {
513
820
  }
514
821
  };
515
822
 
823
+ // src/idempotency-key.ts
824
+ function formatAsUuid(hex) {
825
+ return [
826
+ hex.slice(0, 8),
827
+ hex.slice(8, 12),
828
+ hex.slice(12, 16),
829
+ hex.slice(16, 20),
830
+ hex.slice(20, 32)
831
+ ].join("-");
832
+ }
833
+ function deriveIdempotencyKeyForPurchase(body) {
834
+ let identifier;
835
+ if (body.rail === "apple") {
836
+ identifier = body.signedTransactionInfo ?? "";
837
+ } else if (body.rail === "google") {
838
+ identifier = body.purchaseToken ?? "";
839
+ } else {
840
+ identifier = "";
841
+ }
842
+ if (!identifier) {
843
+ throw new Error(
844
+ `deriveIdempotencyKeyForPurchase: no stable identifier in body (rail=${body.rail}). Apple needs signedTransactionInfo; Google needs purchaseToken.`
845
+ );
846
+ }
847
+ const namespaced = `crossdeck:purchases/sync:${body.rail}:${identifier}`;
848
+ return formatAsUuid(sha256Hex(namespaced));
849
+ }
850
+
516
851
  // src/retry-policy.ts
517
852
  var DEFAULT_BASE = 1e3;
518
853
  var DEFAULT_MAX = 6e4;
@@ -525,8 +860,10 @@ function computeNextDelay(attempts, retryAfterMs, options = {}, random = Math.ra
525
860
  const safeAttempts = Math.min(attempts, 30);
526
861
  const ceiling = Math.min(max, base * Math.pow(factor, safeAttempts));
527
862
  const jittered = ceiling * random();
528
- if (retryAfterMs !== void 0 && retryAfterMs > jittered) {
529
- return Math.min(max, retryAfterMs);
863
+ if (retryAfterMs !== void 0) {
864
+ const ABSOLUTE_MAX_MS = 24 * 60 * 60 * 1e3;
865
+ const honoured = Math.min(ABSOLUTE_MAX_MS, retryAfterMs);
866
+ if (honoured > jittered) return honoured;
530
867
  }
531
868
  return Math.max(0, Math.round(jittered));
532
869
  }
@@ -561,6 +898,27 @@ var EventQueue = class {
561
898
  constructor(cfg) {
562
899
  this.cfg = cfg;
563
900
  this.buffer = [];
901
+ /**
902
+ * In-flight events that have been spliced from `buffer` for the
903
+ * current batch but haven't yet been confirmed (success or permanent
904
+ * failure). On a retry-driven re-flush we re-use this slot alongside
905
+ * `pendingBatchId` so the Stripe-style Idempotency-Key is preserved
906
+ * across retries of the SAME logical batch.
907
+ *
908
+ * Pre-fix the splice cleared the buffer AND we immediately
909
+ * `persistent.save(empty)` BEFORE awaiting the network call — a
910
+ * crash mid-flight wiped the persisted blob and the batch was lost
911
+ * with no replay on next boot. Now the persisted blob always carries
912
+ * `[...pendingBatch, ...buffer]` so the in-flight events survive
913
+ * until the server confirms them.
914
+ *
915
+ * Pre-fix every retry attempt also minted a NEW batchId via
916
+ * `splice + mintBatchId`, defeating the backend's
917
+ * `Idempotency-Key` dedup. Reuse via this slot brings web in
918
+ * lockstep with node (which already had the field).
919
+ */
920
+ this.pendingBatch = null;
921
+ this.pendingBatchId = null;
564
922
  this.dropped = 0;
565
923
  this.inFlight = 0;
566
924
  this.lastFlushAt = 0;
@@ -593,7 +951,7 @@ var EventQueue = class {
593
951
  this.cfg.onDrop?.(overflow);
594
952
  }
595
953
  this.cfg.onBufferChange?.(this.buffer.length);
596
- this.persistent?.save(this.buffer);
954
+ this.persistAll();
597
955
  if (this.buffer.length >= this.cfg.batchSize) {
598
956
  void this.flush();
599
957
  } else {
@@ -602,22 +960,44 @@ var EventQueue = class {
602
960
  }
603
961
  /**
604
962
  * Flush the buffer to /v1/events. Resolves when the network call
605
- * completes (success or failure). On failure, events stay in the
606
- * buffer for the next scheduled retry.
963
+ * completes (success or failure). On a retryable failure the batch
964
+ * stays in `pendingBatch` for the next scheduled retry — the SAME
965
+ * batch with the SAME `Idempotency-Key` is re-sent (Stripe pattern).
966
+ *
967
+ * Three terminal states from one call:
968
+ * - 2xx success: pendingBatch cleared, persisted state collapses to
969
+ * just `buffer` (any new events that arrived during in-flight).
970
+ * - 4xx permanent (except 408/429): pendingBatch DROPPED, `dropped`
971
+ * counter increments, a `permanent_failure` signal fires. The
972
+ * server is telling us our request is malformed / our key is
973
+ * revoked / we lack permission — retrying with the same key
974
+ * forever just grows the queue while the customer thinks events
975
+ * are landing.
976
+ * - 5xx / network / 408 / 429: pendingBatch + batchId stay; backoff
977
+ * schedules a retry; the next `flush()` re-uses both.
607
978
  *
608
979
  * `options.keepalive` marks the underlying fetch as keepalive so the
609
- * browser keeps the request alive past page unload. Use this for
610
- * terminal flushes (pagehide / visibilitychange→hidden / beforeunload).
980
+ * browser keeps the request alive past page unload. Use for terminal
981
+ * flushes (pagehide / visibilitychange→hidden / beforeunload).
611
982
  */
612
983
  async flush(options = {}) {
613
- if (this.buffer.length === 0) return null;
984
+ let batch;
985
+ let batchId;
986
+ if (this.pendingBatch !== null && this.pendingBatchId !== null) {
987
+ batch = this.pendingBatch;
988
+ batchId = this.pendingBatchId;
989
+ } else {
990
+ if (this.buffer.length === 0) return null;
991
+ batch = this.buffer.splice(0);
992
+ batchId = this.mintBatchId();
993
+ this.pendingBatch = batch;
994
+ this.pendingBatchId = batchId;
995
+ this.inFlight += batch.length;
996
+ this.cfg.onBufferChange?.(this.buffer.length);
997
+ this.persistAll();
998
+ }
614
999
  this.cancelTimerIfSet();
615
1000
  this.nextRetryAt = null;
616
- const batch = this.buffer.splice(0);
617
- const batchId = this.mintBatchId();
618
- this.inFlight += batch.length;
619
- this.persistent?.save(this.buffer);
620
- this.cfg.onBufferChange?.(this.buffer.length);
621
1001
  try {
622
1002
  const env = this.cfg.envelope();
623
1003
  const result = await this.cfg.http.request("POST", "/events", {
@@ -636,20 +1016,33 @@ var EventQueue = class {
636
1016
  this.lastFlushAt = Date.now();
637
1017
  this.lastError = null;
638
1018
  this.inFlight -= batch.length;
1019
+ this.pendingBatch = null;
1020
+ this.pendingBatchId = null;
639
1021
  this.retry.recordSuccess();
640
- this.persistent?.save(this.buffer);
1022
+ this.persistAll();
641
1023
  if (!this.firstFlushFired) {
642
1024
  this.firstFlushFired = true;
643
1025
  this.cfg.onFirstFlushSuccess?.();
644
1026
  }
645
1027
  return result;
646
1028
  } catch (err) {
647
- this.buffer.unshift(...batch);
648
- this.inFlight -= batch.length;
649
1029
  const message = err instanceof Error ? err.message : String(err);
650
1030
  this.lastError = message;
651
- this.persistent?.save(this.buffer);
652
- this.cfg.onBufferChange?.(this.buffer.length);
1031
+ if (isPermanent4xx(err)) {
1032
+ const droppedCount = batch.length;
1033
+ this.pendingBatch = null;
1034
+ this.pendingBatchId = null;
1035
+ this.inFlight -= droppedCount;
1036
+ this.dropped += droppedCount;
1037
+ this.persistAll();
1038
+ this.cfg.onDrop?.(droppedCount);
1039
+ this.cfg.onPermanentFailure?.({
1040
+ status: err.status ?? 0,
1041
+ droppedCount,
1042
+ lastError: message
1043
+ });
1044
+ return null;
1045
+ }
653
1046
  const retryAfterMs = extractRetryAfterMs(err);
654
1047
  const delay = this.retry.nextDelay(retryAfterMs);
655
1048
  this.scheduleRetry(delay);
@@ -667,6 +1060,8 @@ var EventQueue = class {
667
1060
  this.cancelTimerIfSet();
668
1061
  this.nextRetryAt = null;
669
1062
  this.buffer = [];
1063
+ this.pendingBatch = null;
1064
+ this.pendingBatchId = null;
670
1065
  this.dropped = 0;
671
1066
  this.inFlight = 0;
672
1067
  this.lastError = null;
@@ -676,6 +1071,10 @@ var EventQueue = class {
676
1071
  }
677
1072
  getStats() {
678
1073
  return {
1074
+ // `buffered` counts events waiting for their FIRST flush. The
1075
+ // in-flight pendingBatch (retrying) is tracked separately via
1076
+ // `inFlight` — surfacing both lets diagnostics show "we have
1077
+ // events stuck retrying" distinct from "new events arriving".
679
1078
  buffered: this.buffer.length,
680
1079
  dropped: this.dropped,
681
1080
  inFlight: this.inFlight,
@@ -685,6 +1084,29 @@ var EventQueue = class {
685
1084
  nextRetryAt: this.nextRetryAt
686
1085
  };
687
1086
  }
1087
+ /**
1088
+ * The Idempotency-Key of the in-flight pending batch (if any).
1089
+ * Exposed for testing the Stripe-style retry-reuse contract.
1090
+ * Production callers don't need this.
1091
+ */
1092
+ get pendingIdempotencyKey() {
1093
+ return this.pendingBatchId;
1094
+ }
1095
+ /**
1096
+ * Persist `[...pendingBatch, ...buffer]` — the full set of
1097
+ * not-yet-confirmed events. The next boot rehydrates this exact set
1098
+ * into `buffer` and replays. The server dedups via eventId
1099
+ * (ReplacingMergeTree on the warehouse side), so re-sending an event
1100
+ * that may have already landed is safe.
1101
+ */
1102
+ persistAll() {
1103
+ if (!this.persistent) return;
1104
+ if (this.pendingBatch === null) {
1105
+ this.persistent.save(this.buffer);
1106
+ return;
1107
+ }
1108
+ this.persistent.save([...this.pendingBatch, ...this.buffer]);
1109
+ }
688
1110
  // ---------- internal scheduling ----------
689
1111
  scheduleIdleFlush() {
690
1112
  this.cancelTimerIfSet();
@@ -718,6 +1140,14 @@ function extractRetryAfterMs(err) {
718
1140
  }
719
1141
  return void 0;
720
1142
  }
1143
+ function isPermanent4xx(err) {
1144
+ if (!err || typeof err !== "object") return false;
1145
+ const status = err.status;
1146
+ if (typeof status !== "number" || !Number.isFinite(status)) return false;
1147
+ if (status < 400 || status >= 500) return false;
1148
+ if (status === 408 || status === 429) return false;
1149
+ return true;
1150
+ }
721
1151
  function defaultScheduler(fn, ms) {
722
1152
  const id = setTimeout(fn, ms);
723
1153
  if (typeof id.unref === "function") {
@@ -1059,6 +1489,7 @@ var AutoTracker = class {
1059
1489
  /** Exposed for tests + consumers that want to reset the session manually. */
1060
1490
  resetSession() {
1061
1491
  if (this.session && !this.session.endedSent) this.emitSessionEnd();
1492
+ this.pageviewId = null;
1062
1493
  this.session = this.startNewSession();
1063
1494
  this.emitSessionStart();
1064
1495
  }
@@ -1095,6 +1526,7 @@ var AutoTracker = class {
1095
1526
  const hiddenFor = this.session.hiddenAt ? Date.now() - this.session.hiddenAt : 0;
1096
1527
  if (hiddenFor >= SESSION_RESUME_THRESHOLD_MS) {
1097
1528
  this.emitSessionEnd();
1529
+ this.pageviewId = null;
1098
1530
  this.session = this.startNewSession();
1099
1531
  this.emitSessionStart();
1100
1532
  } else {
@@ -1468,7 +1900,7 @@ function validateEventProperties(input, options = {}) {
1468
1900
  const maxStringLength = options.maxStringLength ?? DEFAULT_MAX_STRING;
1469
1901
  const maxBatchPropertyBytes = options.maxBatchPropertyBytes ?? DEFAULT_MAX_BYTES;
1470
1902
  const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
1471
- const seen = /* @__PURE__ */ new WeakSet();
1903
+ const seen = /* @__PURE__ */ new Set();
1472
1904
  const visit = (value, key, depth) => {
1473
1905
  if (depth > maxDepth) {
1474
1906
  warnings.push({ kind: "depth_exceeded", key });
@@ -1556,6 +1988,7 @@ function validateEventProperties(input, options = {}) {
1556
1988
  const result = visit(value[i], `${key}[${i}]`, depth + 1);
1557
1989
  if (result.keep) out.push(result.value);
1558
1990
  }
1991
+ seen.delete(value);
1559
1992
  return { keep: true, value: out };
1560
1993
  }
1561
1994
  if (t === "object") {
@@ -1570,6 +2003,7 @@ function validateEventProperties(input, options = {}) {
1570
2003
  const result = visit(obj[k], `${key}.${k}`, depth + 1);
1571
2004
  if (result.keep) out[k] = result.value;
1572
2005
  }
2006
+ seen.delete(obj);
1573
2007
  return { keep: true, value: out };
1574
2008
  }
1575
2009
  warnings.push({ kind: "non_serialisable", key });
@@ -1912,35 +2346,27 @@ var ConsentManager = class {
1912
2346
  };
1913
2347
  var EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
1914
2348
  var CARD_PATTERN = /\b\d(?:[ -]?\d){12,18}\b/g;
1915
- var REPLACEMENT_EMAIL = "[email]";
1916
- var REPLACEMENT_CARD = "[card]";
2349
+ var REPLACEMENT_EMAIL = "<email>";
2350
+ var REPLACEMENT_CARD = "<card>";
1917
2351
  function scrubPii(value) {
1918
2352
  if (!value) return value;
1919
- let out = value;
1920
- if (EMAIL_PATTERN.test(out)) {
1921
- out = out.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL);
1922
- }
1923
- EMAIL_PATTERN.lastIndex = 0;
1924
- if (CARD_PATTERN.test(out)) {
1925
- out = out.replace(CARD_PATTERN, REPLACEMENT_CARD);
1926
- }
1927
- CARD_PATTERN.lastIndex = 0;
1928
- return out;
2353
+ return value.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL).replace(CARD_PATTERN, REPLACEMENT_CARD);
1929
2354
  }
1930
2355
  function scrubPiiFromProperties(properties) {
1931
2356
  const out = {};
1932
2357
  for (const k of Object.keys(properties)) {
1933
- const v = properties[k];
1934
- if (typeof v === "string") {
1935
- out[k] = scrubPii(v);
1936
- } else if (Array.isArray(v)) {
1937
- out[k] = v.map((item) => typeof item === "string" ? scrubPii(item) : item);
1938
- } else {
1939
- out[k] = v;
1940
- }
2358
+ out[k] = scrubValue(properties[k]);
1941
2359
  }
1942
2360
  return out;
1943
2361
  }
2362
+ function scrubValue(v) {
2363
+ if (typeof v === "string") return scrubPii(v);
2364
+ if (Array.isArray(v)) return v.map(scrubValue);
2365
+ if (v && typeof v === "object" && v.constructor === Object) {
2366
+ return scrubPiiFromProperties(v);
2367
+ }
2368
+ return v;
2369
+ }
1944
2370
 
1945
2371
  // src/breadcrumbs.ts
1946
2372
  var BreadcrumbBuffer = class {
@@ -2230,16 +2656,18 @@ var ErrorTracker = class {
2230
2656
  const url = typeof input === "string" ? input : input?.url ?? "";
2231
2657
  const method = (init.method || "GET").toUpperCase();
2232
2658
  const start = Date.now();
2233
- this.opts.breadcrumbs.add({
2234
- timestamp: start,
2235
- category: "http",
2236
- message: `${method} ${url}`,
2237
- data: { url, method }
2238
- });
2659
+ if (!isSelfRequest(url, this.opts.selfHostname)) {
2660
+ this.opts.breadcrumbs.add({
2661
+ timestamp: start,
2662
+ category: "http",
2663
+ message: `${method} ${url}`,
2664
+ data: { url, method }
2665
+ });
2666
+ }
2239
2667
  try {
2240
2668
  const response = await origFetch(...args);
2241
2669
  if (response.status >= 500 && this.opts.isConsented()) {
2242
- if (!url.includes("api.cross-deck.com")) {
2670
+ if (!isSelfRequest(url, this.opts.selfHostname)) {
2243
2671
  this.captureHttp({
2244
2672
  url,
2245
2673
  method,
@@ -2288,7 +2716,7 @@ var ErrorTracker = class {
2288
2716
  try {
2289
2717
  if (xhr.status >= 500 && tracker.opts.isConsented()) {
2290
2718
  const url = xhr._cdUrl ?? "";
2291
- if (!url.includes("api.cross-deck.com")) {
2719
+ if (!isSelfRequest(url, tracker.opts.selfHostname)) {
2292
2720
  tracker.captureHttp({
2293
2721
  url,
2294
2722
  method: (xhr._cdMethod ?? "GET").toUpperCase(),
@@ -2448,9 +2876,10 @@ var ErrorTracker = class {
2448
2876
  if (!this.passesSample(err)) return;
2449
2877
  if (!this.passesRateLimit(err)) return;
2450
2878
  let finalErr = err;
2451
- if (this.opts.beforeSend) {
2879
+ const hook = this.opts.beforeSend?.();
2880
+ if (hook) {
2452
2881
  try {
2453
- finalErr = this.opts.beforeSend(err);
2882
+ finalErr = hook(err);
2454
2883
  } catch {
2455
2884
  finalErr = err;
2456
2885
  }
@@ -2632,6 +3061,22 @@ function safeClone(v) {
2632
3061
  function safeStringify2(v) {
2633
3062
  return coerceErrorPayload(v).message;
2634
3063
  }
3064
+ function extractSelfHostname(baseUrl) {
3065
+ if (!baseUrl || typeof baseUrl !== "string") return null;
3066
+ try {
3067
+ return new URL(baseUrl).hostname.toLowerCase();
3068
+ } catch {
3069
+ return null;
3070
+ }
3071
+ }
3072
+ function isSelfRequest(requestUrl, selfHostname) {
3073
+ if (!selfHostname || !requestUrl) return false;
3074
+ try {
3075
+ return new URL(requestUrl).hostname.toLowerCase() === selfHostname;
3076
+ } catch {
3077
+ return false;
3078
+ }
3079
+ }
2635
3080
 
2636
3081
  // src/crossdeck.ts
2637
3082
  var CrossdeckClient = class {
@@ -2648,6 +3093,28 @@ var CrossdeckClient = class {
2648
3093
  * mismatched env fails fast at boot rather than at first event-flush.
2649
3094
  */
2650
3095
  init(options) {
3096
+ if (this.state) {
3097
+ try {
3098
+ this.state.uninstallUnloadFlush?.();
3099
+ } catch {
3100
+ }
3101
+ try {
3102
+ this.state.autoTracker?.uninstall();
3103
+ } catch {
3104
+ }
3105
+ try {
3106
+ this.state.webVitals?.uninstall();
3107
+ } catch {
3108
+ }
3109
+ try {
3110
+ this.state.errors?.uninstall();
3111
+ } catch {
3112
+ }
3113
+ try {
3114
+ void this.state.events.flush({ keepalive: true });
3115
+ } catch {
3116
+ }
3117
+ }
2651
3118
  if (!options.publicKey || !options.publicKey.startsWith("cd_pub_")) {
2652
3119
  throw new CrossdeckError({
2653
3120
  type: "configuration_error",
@@ -2694,7 +3161,12 @@ var CrossdeckClient = class {
2694
3161
  // load still flushes if the user leaves quickly (the keepalive
2695
3162
  // pagehide handler picks up anything that doesn't); long enough
2696
3163
  // that bursts of clicks coalesce into one network round-trip.
2697
- eventFlushIntervalMs: options.eventFlushIntervalMs ?? 1500,
3164
+ // v1.4.0 Phase 3.3 — flush interval default parity. Pre-
3165
+ // v1.4.0: Web/Node 1500ms, RN/Swift/Android 5000ms. All
3166
+ // converged on 2000ms (the Stripe-adjacent industry norm)
3167
+ // so cross-platform funnels show events landing at the
3168
+ // same cadence on every SDK. Per-instance override stays.
3169
+ eventFlushIntervalMs: options.eventFlushIntervalMs ?? 2e3,
2698
3170
  sdkVersion: options.sdkVersion ?? SDK_VERSION,
2699
3171
  autoTrack,
2700
3172
  appVersion: options.appVersion ?? null
@@ -2755,6 +3227,15 @@ var CrossdeckClient = class {
2755
3227
  `Event flush failed (${info.lastError}). Retrying in ${info.delayMs}ms (attempt ${info.consecutiveFailures}).`,
2756
3228
  { ...info }
2757
3229
  );
3230
+ },
3231
+ onPermanentFailure: (info) => {
3232
+ const headline = `[crossdeck] Event batch DROPPED (status ${info.status}): ${info.lastError}. ${info.droppedCount} event(s) lost \u2014 check your publishable key + app config.`;
3233
+ console.error(headline);
3234
+ debug.emit(
3235
+ "sdk.flush_permanent_failure",
3236
+ headline,
3237
+ { ...info }
3238
+ );
2758
3239
  }
2759
3240
  });
2760
3241
  const deviceInfo = autoTrack.deviceInfo ? collectDeviceInfo({ appVersion: opts.appVersion ?? void 0 }) : opts.appVersion ? { appVersion: opts.appVersion } : {};
@@ -2821,8 +3302,20 @@ var CrossdeckClient = class {
2821
3302
  report: (err) => this.reportError(err),
2822
3303
  getContext: () => ({ ...this.state.errorContext }),
2823
3304
  getTags: () => ({ ...this.state.errorTags }),
2824
- beforeSend: this.state.errorBeforeSend,
2825
- isConsented: () => this.state.consent.errors
3305
+ // GETTER, not a captured value — `setErrorBeforeSend()` mutates
3306
+ // `state.errorBeforeSend` after init() and the tracker MUST
3307
+ // pick up the new hook on the next error. The pre-fix shape
3308
+ // (`beforeSend: this.state!.errorBeforeSend`) snapshotted
3309
+ // `null` at construction and made the customer's PII scrubber
3310
+ // silently inert. See error-capture.ts:ErrorTrackerOptions.beforeSend.
3311
+ beforeSend: () => this.state.errorBeforeSend,
3312
+ isConsented: () => this.state.consent.errors,
3313
+ // Derived from the configured baseUrl at init() time. Used by
3314
+ // the fetch / XHR wrappers to skip captureHttp on Crossdeck's
3315
+ // own requests — pre-fix the skip was hardcoded to
3316
+ // `api.cross-deck.com` and broke for customers on staging /
3317
+ // regional / self-hosted base URLs (recursive capture loop).
3318
+ selfHostname: extractSelfHostname(opts.baseUrl)
2826
3319
  });
2827
3320
  this.state.errors = tracker;
2828
3321
  tracker.install();
@@ -2901,13 +3394,10 @@ var CrossdeckClient = class {
2901
3394
  };
2902
3395
  if (options?.email) body.email = options.email;
2903
3396
  if (traits) body.traits = traits;
3397
+ s.entitlements.setUserKey(userId);
2904
3398
  const result = await s.http.request("POST", "/identity/alias", {
2905
3399
  body
2906
3400
  });
2907
- const priorCdcust = s.identity.crossdeckCustomerId;
2908
- if (priorCdcust && result.crossdeckCustomerId && priorCdcust !== result.crossdeckCustomerId) {
2909
- s.entitlements.clear();
2910
- }
2911
3401
  s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
2912
3402
  s.developerUserId = userId;
2913
3403
  return result;
@@ -3347,11 +3837,25 @@ var CrossdeckClient = class {
3347
3837
  message: "syncPurchases requires a signedTransactionInfo string from StoreKit 2."
3348
3838
  });
3349
3839
  }
3840
+ const rail = input.rail ?? "apple";
3841
+ const body = { ...input, rail };
3842
+ const idempotencyKey = deriveIdempotencyKeyForPurchase(body);
3350
3843
  const result = await s.http.request("POST", "/purchases/sync", {
3351
- body: { rail: input.rail ?? "apple", ...input }
3844
+ body,
3845
+ idempotencyKey
3352
3846
  });
3353
3847
  s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
3354
3848
  s.entitlements.setFromList(result.entitlements);
3849
+ try {
3850
+ const sourceProductId = result.entitlements[0]?.source.productId;
3851
+ const sourceSubscriptionId = result.entitlements[0]?.source.subscriptionId;
3852
+ const props = { rail };
3853
+ if (sourceProductId) props.productId = sourceProductId;
3854
+ if (sourceSubscriptionId) props.subscriptionId = sourceSubscriptionId;
3855
+ if (result.idempotent_replay) props.idempotent_replay = true;
3856
+ this.track("purchase.completed", props);
3857
+ } catch {
3858
+ }
3355
3859
  s.debug.emit(
3356
3860
  "sdk.purchase_evidence_sent",
3357
3861
  "StoreKit transaction forwarded. Waiting for backend verification.",
@@ -3407,13 +3911,15 @@ var CrossdeckClient = class {
3407
3911
  }
3408
3912
  this.state.autoTracker?.uninstall();
3409
3913
  this.state.identity.reset();
3410
- this.state.entitlements.clear();
3914
+ this.state.entitlements.clearAll();
3411
3915
  this.state.events.reset();
3412
3916
  this.state.superProps.clear();
3413
3917
  this.state.breadcrumbs.clear();
3414
3918
  this.state.errorContext = {};
3415
3919
  this.state.errorTags = {};
3416
3920
  this.state.developerUserId = null;
3921
+ this.state.lastServerTime = null;
3922
+ this.state.lastClientTime = null;
3417
3923
  if (this.state.autoTracker) {
3418
3924
  const tracker = new AutoTracker(
3419
3925
  this.state.options.autoTrack,
@@ -3542,7 +4048,9 @@ function isLocalHostname() {
3542
4048
  const hostname = w?.location?.hostname;
3543
4049
  if (!hostname) return false;
3544
4050
  if (hostname === "localhost" || hostname === "127.0.0.1") return true;
4051
+ if (hostname === "0.0.0.0") return true;
3545
4052
  if (hostname === "::1" || hostname === "[::1]") return true;
4053
+ if (/^\[?fe80::/i.test(hostname)) return true;
3546
4054
  if (hostname.endsWith(".local")) return true;
3547
4055
  if (/^10\./.test(hostname)) return true;
3548
4056
  if (/^192\.168\./.test(hostname)) return true;