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