@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.mjs CHANGED
@@ -66,9 +66,11 @@ function typeMapForStatus(status) {
66
66
  return "internal_error";
67
67
  }
68
68
 
69
- // src/http.ts
69
+ // src/_version.ts
70
+ var SDK_VERSION = "1.4.2";
70
71
  var SDK_NAME = "@cross-deck/web";
71
- var SDK_VERSION = "1.1.0";
72
+
73
+ // src/http.ts
72
74
  var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
73
75
  var DEFAULT_TIMEOUT_MS = 15e3;
74
76
  var HttpClient = class {
@@ -315,29 +317,258 @@ function randomChars(count) {
315
317
  return out.join("");
316
318
  }
317
319
 
320
+ // src/hash.ts
321
+ var K = new Uint32Array([
322
+ 1116352408,
323
+ 1899447441,
324
+ 3049323471,
325
+ 3921009573,
326
+ 961987163,
327
+ 1508970993,
328
+ 2453635748,
329
+ 2870763221,
330
+ 3624381080,
331
+ 310598401,
332
+ 607225278,
333
+ 1426881987,
334
+ 1925078388,
335
+ 2162078206,
336
+ 2614888103,
337
+ 3248222580,
338
+ 3835390401,
339
+ 4022224774,
340
+ 264347078,
341
+ 604807628,
342
+ 770255983,
343
+ 1249150122,
344
+ 1555081692,
345
+ 1996064986,
346
+ 2554220882,
347
+ 2821834349,
348
+ 2952996808,
349
+ 3210313671,
350
+ 3336571891,
351
+ 3584528711,
352
+ 113926993,
353
+ 338241895,
354
+ 666307205,
355
+ 773529912,
356
+ 1294757372,
357
+ 1396182291,
358
+ 1695183700,
359
+ 1986661051,
360
+ 2177026350,
361
+ 2456956037,
362
+ 2730485921,
363
+ 2820302411,
364
+ 3259730800,
365
+ 3345764771,
366
+ 3516065817,
367
+ 3600352804,
368
+ 4094571909,
369
+ 275423344,
370
+ 430227734,
371
+ 506948616,
372
+ 659060556,
373
+ 883997877,
374
+ 958139571,
375
+ 1322822218,
376
+ 1537002063,
377
+ 1747873779,
378
+ 1955562222,
379
+ 2024104815,
380
+ 2227730452,
381
+ 2361852424,
382
+ 2428436474,
383
+ 2756734187,
384
+ 3204031479,
385
+ 3329325298
386
+ ]);
387
+ function utf8Bytes(input) {
388
+ if (typeof TextEncoder !== "undefined") {
389
+ return new TextEncoder().encode(input);
390
+ }
391
+ const out = [];
392
+ for (let i = 0; i < input.length; i++) {
393
+ let codePoint = input.charCodeAt(i);
394
+ if (codePoint >= 55296 && codePoint <= 56319 && i + 1 < input.length) {
395
+ const next = input.charCodeAt(i + 1);
396
+ if (next >= 56320 && next <= 57343) {
397
+ codePoint = 65536 + (codePoint - 55296 << 10) + (next - 56320);
398
+ i++;
399
+ }
400
+ }
401
+ if (codePoint < 128) {
402
+ out.push(codePoint);
403
+ } else if (codePoint < 2048) {
404
+ out.push(192 | codePoint >> 6);
405
+ out.push(128 | codePoint & 63);
406
+ } else if (codePoint < 65536) {
407
+ out.push(224 | codePoint >> 12);
408
+ out.push(128 | codePoint >> 6 & 63);
409
+ out.push(128 | codePoint & 63);
410
+ } else {
411
+ out.push(240 | codePoint >> 18);
412
+ out.push(128 | codePoint >> 12 & 63);
413
+ out.push(128 | codePoint >> 6 & 63);
414
+ out.push(128 | codePoint & 63);
415
+ }
416
+ }
417
+ return new Uint8Array(out);
418
+ }
419
+ function sha256Hex(input) {
420
+ const bytes = utf8Bytes(input);
421
+ const bitLength = bytes.length * 8;
422
+ const blockCount = Math.floor((bytes.length + 9 + 63) / 64);
423
+ const padded = new Uint8Array(blockCount * 64);
424
+ padded.set(bytes);
425
+ padded[bytes.length] = 128;
426
+ const high = Math.floor(bitLength / 4294967296);
427
+ const low = bitLength >>> 0;
428
+ const lenOffset = padded.length - 8;
429
+ padded[lenOffset + 0] = high >>> 24 & 255;
430
+ padded[lenOffset + 1] = high >>> 16 & 255;
431
+ padded[lenOffset + 2] = high >>> 8 & 255;
432
+ padded[lenOffset + 3] = high & 255;
433
+ padded[lenOffset + 4] = low >>> 24 & 255;
434
+ padded[lenOffset + 5] = low >>> 16 & 255;
435
+ padded[lenOffset + 6] = low >>> 8 & 255;
436
+ padded[lenOffset + 7] = low & 255;
437
+ const H = new Uint32Array([
438
+ 1779033703,
439
+ 3144134277,
440
+ 1013904242,
441
+ 2773480762,
442
+ 1359893119,
443
+ 2600822924,
444
+ 528734635,
445
+ 1541459225
446
+ ]);
447
+ const W = new Uint32Array(64);
448
+ for (let block = 0; block < blockCount; block++) {
449
+ const offset = block * 64;
450
+ for (let t = 0; t < 16; t++) {
451
+ 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;
452
+ }
453
+ for (let t = 16; t < 64; t++) {
454
+ const w15 = W[t - 15];
455
+ const w2 = W[t - 2];
456
+ const s0 = (w15 >>> 7 | w15 << 25) ^ (w15 >>> 18 | w15 << 14) ^ w15 >>> 3;
457
+ const s1 = (w2 >>> 17 | w2 << 15) ^ (w2 >>> 19 | w2 << 13) ^ w2 >>> 10;
458
+ W[t] = W[t - 16] + s0 + W[t - 7] + s1 >>> 0;
459
+ }
460
+ let a = H[0], b = H[1], c = H[2], d = H[3];
461
+ let e = H[4], f = H[5], g = H[6], h = H[7];
462
+ for (let t = 0; t < 64; t++) {
463
+ const S1 = (e >>> 6 | e << 26) ^ (e >>> 11 | e << 21) ^ (e >>> 25 | e << 7);
464
+ const ch = e & f ^ ~e & g;
465
+ const temp1 = h + S1 + ch + K[t] + W[t] >>> 0;
466
+ const S0 = (a >>> 2 | a << 30) ^ (a >>> 13 | a << 19) ^ (a >>> 22 | a << 10);
467
+ const maj = a & b ^ a & c ^ b & c;
468
+ const temp2 = S0 + maj >>> 0;
469
+ h = g;
470
+ g = f;
471
+ f = e;
472
+ e = d + temp1 >>> 0;
473
+ d = c;
474
+ c = b;
475
+ b = a;
476
+ a = temp1 + temp2 >>> 0;
477
+ }
478
+ H[0] = H[0] + a >>> 0;
479
+ H[1] = H[1] + b >>> 0;
480
+ H[2] = H[2] + c >>> 0;
481
+ H[3] = H[3] + d >>> 0;
482
+ H[4] = H[4] + e >>> 0;
483
+ H[5] = H[5] + f >>> 0;
484
+ H[6] = H[6] + g >>> 0;
485
+ H[7] = H[7] + h >>> 0;
486
+ }
487
+ let hex = "";
488
+ for (let i = 0; i < 8; i++) {
489
+ hex += H[i].toString(16).padStart(8, "0");
490
+ }
491
+ return hex;
492
+ }
493
+
318
494
  // src/entitlement-cache.ts
319
495
  var DEFAULT_STALE_AFTER_MS = 24 * 60 * 60 * 1e3;
320
- var EntitlementCache = class {
496
+ var ANON_SUFFIX = "_anon";
497
+ var INDEX_SUFFIX = "_index";
498
+ var EntitlementCache = class _EntitlementCache {
321
499
  /**
322
- * @param storage Device storage adapter. When omitted (tests) or
323
- * a MemoryStorage (strict-consent / no-persistence
324
- * mode) the cache is session-only — durability is
325
- * simply absent, never wrong.
326
- * @param storageKey Full key the persisted blob lives under.
327
- * @param staleAfterMs Age past which last-known-good is flagged stale
328
- * even without a failed refresh. Default 24h.
500
+ * @param storage Device storage adapter. When omitted (tests) or
501
+ * a MemoryStorage (strict-consent / no-persistence
502
+ * mode) the cache is session-only — durability is
503
+ * simply absent, never wrong.
504
+ * @param storageKeyPrefix Prefix used to derive per-user storage keys
505
+ * (`<prefix>:<sha256(userId)>`). Default
506
+ * `crossdeck:entitlements`. The trailing user
507
+ * suffix is filled at identify() / reset()
508
+ * time — see [[setUserKey]].
509
+ * @param staleAfterMs Age past which last-known-good is flagged stale
510
+ * even without a failed refresh. Default 24h.
329
511
  */
330
- constructor(storage, storageKey = "crossdeck:entitlements", staleAfterMs = DEFAULT_STALE_AFTER_MS) {
512
+ constructor(storage, storageKeyPrefix = "crossdeck:entitlements", staleAfterMs = DEFAULT_STALE_AFTER_MS) {
331
513
  this.all = [];
332
514
  this.lastUpdated = 0;
333
515
  this.lastRefreshFailedAt = 0;
334
516
  this.listeners = /* @__PURE__ */ new Set();
335
517
  this.listenerErrorCount = 0;
518
+ this.currentSuffix = ANON_SUFFIX;
336
519
  this.storage = storage;
337
- this.storageKey = storageKey;
520
+ this.storageKeyPrefix = storageKeyPrefix;
338
521
  this.staleAfterMs = staleAfterMs;
339
522
  this.hydrate();
340
523
  }
524
+ /** The full storage key the current-user blob is persisted under. */
525
+ get storageKey() {
526
+ return `${this.storageKeyPrefix}:${this.currentSuffix}`;
527
+ }
528
+ /** Key of the index blob — a JSON array of every suffix we've
529
+ * written. Used by clearAll() to scope a logout-wipe. */
530
+ get indexKey() {
531
+ return `${this.storageKeyPrefix}:${INDEX_SUFFIX}`;
532
+ }
533
+ /** Derive a stable suffix for a developerUserId via SHA-256. The
534
+ * raw userId never lands in the storage key — protects against
535
+ * accidentally leaking email-style identifiers through DevTools
536
+ * inspection. Pass `null` to switch back to the anonymous slot. */
537
+ static suffixForUserId(userId) {
538
+ if (userId == null || userId === "") return ANON_SUFFIX;
539
+ return sha256Hex(userId);
540
+ }
541
+ /**
542
+ * Switch the cache to a different user's storage slot. Bank-grade
543
+ * three-layer isolation:
544
+ * (a) Physical key separation — `<prefix>:<sha256(userId)>` so
545
+ * a user-switch can't physically read prior user's data
546
+ * even if the in-memory clear was skipped.
547
+ * (b) Unconditional in-memory clear — invoked whenever the
548
+ * active suffix changes, even on same-id re-identify.
549
+ * (c) Re-hydrate from the new slot — a returning user observes
550
+ * their last-known-good cache from storage immediately.
551
+ *
552
+ * Caller (identify() / reset()) MUST invoke this BEFORE the next
553
+ * setFromList() so the write lands under the right key.
554
+ */
555
+ setUserKey(userId) {
556
+ const nextSuffix = _EntitlementCache.suffixForUserId(userId);
557
+ if (nextSuffix === this.currentSuffix) {
558
+ this.all = [];
559
+ this.lastUpdated = 0;
560
+ this.lastRefreshFailedAt = 0;
561
+ this.notify();
562
+ this.hydrate();
563
+ return;
564
+ }
565
+ this.currentSuffix = nextSuffix;
566
+ this.all = [];
567
+ this.lastUpdated = 0;
568
+ this.lastRefreshFailedAt = 0;
569
+ this.hydrate();
570
+ this.notify();
571
+ }
341
572
  /**
342
573
  * Sync read — true iff the entitlement is currently granting access.
343
574
  *
@@ -407,12 +638,13 @@ var EntitlementCache = class {
407
638
  this.lastUpdated = Date.now();
408
639
  this.lastRefreshFailedAt = 0;
409
640
  this.persist();
641
+ this.recordSuffixInIndex(this.currentSuffix);
410
642
  this.notify();
411
643
  }
412
644
  /**
413
- * Wipe used on reset() (logout) and on an identity switch. Clears
414
- * BOTH memory and durable storage so a prior user's entitlements can
415
- * never leak to the next person on this device.
645
+ * Wipe the CURRENT user's slot. Used internally when a single
646
+ * user's cache needs to be invalidated without affecting other
647
+ * persisted slots. The full-logout path is [[clearAll]].
416
648
  */
417
649
  clear() {
418
650
  this.all = [];
@@ -424,6 +656,40 @@ var EntitlementCache = class {
424
656
  } catch {
425
657
  }
426
658
  }
659
+ this.removeSuffixFromIndex(this.currentSuffix);
660
+ this.notify();
661
+ }
662
+ /**
663
+ * Logout-grade wipe — bank-grade contract: removes EVERY per-user
664
+ * entitlement slot the SDK has ever written on this device, then
665
+ * clears the index. Used by `Crossdeck.reset()` so a logout on a
666
+ * shared device can never leave another user's entitlements
667
+ * readable (layer (c) of the v1.4.0 isolation fix).
668
+ *
669
+ * After clearAll(), the cache is back to anonymous + empty.
670
+ */
671
+ clearAll() {
672
+ this.all = [];
673
+ this.lastUpdated = 0;
674
+ this.lastRefreshFailedAt = 0;
675
+ this.currentSuffix = ANON_SUFFIX;
676
+ if (this.storage) {
677
+ const suffixes = this.readIndex();
678
+ for (const suffix of suffixes) {
679
+ try {
680
+ this.storage.removeItem(`${this.storageKeyPrefix}:${suffix}`);
681
+ } catch {
682
+ }
683
+ }
684
+ try {
685
+ this.storage.removeItem(`${this.storageKeyPrefix}:${ANON_SUFFIX}`);
686
+ } catch {
687
+ }
688
+ try {
689
+ this.storage.removeItem(this.indexKey);
690
+ } catch {
691
+ }
692
+ }
427
693
  this.notify();
428
694
  }
429
695
  /**
@@ -473,6 +739,47 @@ var EntitlementCache = class {
473
739
  } catch {
474
740
  }
475
741
  }
742
+ /** Read the index of all per-user suffixes the SDK has written. */
743
+ readIndex() {
744
+ if (!this.storage) return [];
745
+ try {
746
+ const raw = this.storage.getItem(this.indexKey);
747
+ if (!raw) return [];
748
+ const parsed = JSON.parse(raw);
749
+ if (Array.isArray(parsed)) {
750
+ return parsed.filter((x) => typeof x === "string");
751
+ }
752
+ return [];
753
+ } catch {
754
+ return [];
755
+ }
756
+ }
757
+ /** Add a suffix to the persisted index. Idempotent. */
758
+ recordSuffixInIndex(suffix) {
759
+ if (!this.storage) return;
760
+ const existing = this.readIndex();
761
+ if (existing.includes(suffix)) return;
762
+ existing.push(suffix);
763
+ try {
764
+ this.storage.setItem(this.indexKey, JSON.stringify(existing));
765
+ } catch {
766
+ }
767
+ }
768
+ /** Remove a suffix from the persisted index. No-op if absent. */
769
+ removeSuffixFromIndex(suffix) {
770
+ if (!this.storage) return;
771
+ const existing = this.readIndex();
772
+ const next = existing.filter((s) => s !== suffix);
773
+ if (next.length === existing.length) return;
774
+ try {
775
+ if (next.length === 0) {
776
+ this.storage.removeItem(this.indexKey);
777
+ } else {
778
+ this.storage.setItem(this.indexKey, JSON.stringify(next));
779
+ }
780
+ } catch {
781
+ }
782
+ }
476
783
  notify() {
477
784
  if (this.listeners.size === 0) return;
478
785
  const snapshot = this.all.slice();
@@ -487,6 +794,34 @@ var EntitlementCache = class {
487
794
  }
488
795
  };
489
796
 
797
+ // src/idempotency-key.ts
798
+ function formatAsUuid(hex) {
799
+ return [
800
+ hex.slice(0, 8),
801
+ hex.slice(8, 12),
802
+ hex.slice(12, 16),
803
+ hex.slice(16, 20),
804
+ hex.slice(20, 32)
805
+ ].join("-");
806
+ }
807
+ function deriveIdempotencyKeyForPurchase(body) {
808
+ let identifier;
809
+ if (body.rail === "apple") {
810
+ identifier = body.signedTransactionInfo ?? "";
811
+ } else if (body.rail === "google") {
812
+ identifier = body.purchaseToken ?? "";
813
+ } else {
814
+ identifier = "";
815
+ }
816
+ if (!identifier) {
817
+ throw new Error(
818
+ `deriveIdempotencyKeyForPurchase: no stable identifier in body (rail=${body.rail}). Apple needs signedTransactionInfo; Google needs purchaseToken.`
819
+ );
820
+ }
821
+ const namespaced = `crossdeck:purchases/sync:${body.rail}:${identifier}`;
822
+ return formatAsUuid(sha256Hex(namespaced));
823
+ }
824
+
490
825
  // src/retry-policy.ts
491
826
  var DEFAULT_BASE = 1e3;
492
827
  var DEFAULT_MAX = 6e4;
@@ -499,8 +834,10 @@ function computeNextDelay(attempts, retryAfterMs, options = {}, random = Math.ra
499
834
  const safeAttempts = Math.min(attempts, 30);
500
835
  const ceiling = Math.min(max, base * Math.pow(factor, safeAttempts));
501
836
  const jittered = ceiling * random();
502
- if (retryAfterMs !== void 0 && retryAfterMs > jittered) {
503
- return Math.min(max, retryAfterMs);
837
+ if (retryAfterMs !== void 0) {
838
+ const ABSOLUTE_MAX_MS = 24 * 60 * 60 * 1e3;
839
+ const honoured = Math.min(ABSOLUTE_MAX_MS, retryAfterMs);
840
+ if (honoured > jittered) return honoured;
504
841
  }
505
842
  return Math.max(0, Math.round(jittered));
506
843
  }
@@ -535,6 +872,27 @@ var EventQueue = class {
535
872
  constructor(cfg) {
536
873
  this.cfg = cfg;
537
874
  this.buffer = [];
875
+ /**
876
+ * In-flight events that have been spliced from `buffer` for the
877
+ * current batch but haven't yet been confirmed (success or permanent
878
+ * failure). On a retry-driven re-flush we re-use this slot alongside
879
+ * `pendingBatchId` so the Stripe-style Idempotency-Key is preserved
880
+ * across retries of the SAME logical batch.
881
+ *
882
+ * Pre-fix the splice cleared the buffer AND we immediately
883
+ * `persistent.save(empty)` BEFORE awaiting the network call — a
884
+ * crash mid-flight wiped the persisted blob and the batch was lost
885
+ * with no replay on next boot. Now the persisted blob always carries
886
+ * `[...pendingBatch, ...buffer]` so the in-flight events survive
887
+ * until the server confirms them.
888
+ *
889
+ * Pre-fix every retry attempt also minted a NEW batchId via
890
+ * `splice + mintBatchId`, defeating the backend's
891
+ * `Idempotency-Key` dedup. Reuse via this slot brings web in
892
+ * lockstep with node (which already had the field).
893
+ */
894
+ this.pendingBatch = null;
895
+ this.pendingBatchId = null;
538
896
  this.dropped = 0;
539
897
  this.inFlight = 0;
540
898
  this.lastFlushAt = 0;
@@ -567,7 +925,7 @@ var EventQueue = class {
567
925
  this.cfg.onDrop?.(overflow);
568
926
  }
569
927
  this.cfg.onBufferChange?.(this.buffer.length);
570
- this.persistent?.save(this.buffer);
928
+ this.persistAll();
571
929
  if (this.buffer.length >= this.cfg.batchSize) {
572
930
  void this.flush();
573
931
  } else {
@@ -576,22 +934,44 @@ var EventQueue = class {
576
934
  }
577
935
  /**
578
936
  * Flush the buffer to /v1/events. Resolves when the network call
579
- * completes (success or failure). On failure, events stay in the
580
- * buffer for the next scheduled retry.
937
+ * completes (success or failure). On a retryable failure the batch
938
+ * stays in `pendingBatch` for the next scheduled retry — the SAME
939
+ * batch with the SAME `Idempotency-Key` is re-sent (Stripe pattern).
940
+ *
941
+ * Three terminal states from one call:
942
+ * - 2xx success: pendingBatch cleared, persisted state collapses to
943
+ * just `buffer` (any new events that arrived during in-flight).
944
+ * - 4xx permanent (except 408/429): pendingBatch DROPPED, `dropped`
945
+ * counter increments, a `permanent_failure` signal fires. The
946
+ * server is telling us our request is malformed / our key is
947
+ * revoked / we lack permission — retrying with the same key
948
+ * forever just grows the queue while the customer thinks events
949
+ * are landing.
950
+ * - 5xx / network / 408 / 429: pendingBatch + batchId stay; backoff
951
+ * schedules a retry; the next `flush()` re-uses both.
581
952
  *
582
953
  * `options.keepalive` marks the underlying fetch as keepalive so the
583
- * browser keeps the request alive past page unload. Use this for
584
- * terminal flushes (pagehide / visibilitychange→hidden / beforeunload).
954
+ * browser keeps the request alive past page unload. Use for terminal
955
+ * flushes (pagehide / visibilitychange→hidden / beforeunload).
585
956
  */
586
957
  async flush(options = {}) {
587
- if (this.buffer.length === 0) return null;
958
+ let batch;
959
+ let batchId;
960
+ if (this.pendingBatch !== null && this.pendingBatchId !== null) {
961
+ batch = this.pendingBatch;
962
+ batchId = this.pendingBatchId;
963
+ } else {
964
+ if (this.buffer.length === 0) return null;
965
+ batch = this.buffer.splice(0);
966
+ batchId = this.mintBatchId();
967
+ this.pendingBatch = batch;
968
+ this.pendingBatchId = batchId;
969
+ this.inFlight += batch.length;
970
+ this.cfg.onBufferChange?.(this.buffer.length);
971
+ this.persistAll();
972
+ }
588
973
  this.cancelTimerIfSet();
589
974
  this.nextRetryAt = null;
590
- const batch = this.buffer.splice(0);
591
- const batchId = this.mintBatchId();
592
- this.inFlight += batch.length;
593
- this.persistent?.save(this.buffer);
594
- this.cfg.onBufferChange?.(this.buffer.length);
595
975
  try {
596
976
  const env = this.cfg.envelope();
597
977
  const result = await this.cfg.http.request("POST", "/events", {
@@ -610,20 +990,33 @@ var EventQueue = class {
610
990
  this.lastFlushAt = Date.now();
611
991
  this.lastError = null;
612
992
  this.inFlight -= batch.length;
993
+ this.pendingBatch = null;
994
+ this.pendingBatchId = null;
613
995
  this.retry.recordSuccess();
614
- this.persistent?.save(this.buffer);
996
+ this.persistAll();
615
997
  if (!this.firstFlushFired) {
616
998
  this.firstFlushFired = true;
617
999
  this.cfg.onFirstFlushSuccess?.();
618
1000
  }
619
1001
  return result;
620
1002
  } catch (err) {
621
- this.buffer.unshift(...batch);
622
- this.inFlight -= batch.length;
623
1003
  const message = err instanceof Error ? err.message : String(err);
624
1004
  this.lastError = message;
625
- this.persistent?.save(this.buffer);
626
- this.cfg.onBufferChange?.(this.buffer.length);
1005
+ if (isPermanent4xx(err)) {
1006
+ const droppedCount = batch.length;
1007
+ this.pendingBatch = null;
1008
+ this.pendingBatchId = null;
1009
+ this.inFlight -= droppedCount;
1010
+ this.dropped += droppedCount;
1011
+ this.persistAll();
1012
+ this.cfg.onDrop?.(droppedCount);
1013
+ this.cfg.onPermanentFailure?.({
1014
+ status: err.status ?? 0,
1015
+ droppedCount,
1016
+ lastError: message
1017
+ });
1018
+ return null;
1019
+ }
627
1020
  const retryAfterMs = extractRetryAfterMs(err);
628
1021
  const delay = this.retry.nextDelay(retryAfterMs);
629
1022
  this.scheduleRetry(delay);
@@ -641,6 +1034,8 @@ var EventQueue = class {
641
1034
  this.cancelTimerIfSet();
642
1035
  this.nextRetryAt = null;
643
1036
  this.buffer = [];
1037
+ this.pendingBatch = null;
1038
+ this.pendingBatchId = null;
644
1039
  this.dropped = 0;
645
1040
  this.inFlight = 0;
646
1041
  this.lastError = null;
@@ -650,6 +1045,10 @@ var EventQueue = class {
650
1045
  }
651
1046
  getStats() {
652
1047
  return {
1048
+ // `buffered` counts events waiting for their FIRST flush. The
1049
+ // in-flight pendingBatch (retrying) is tracked separately via
1050
+ // `inFlight` — surfacing both lets diagnostics show "we have
1051
+ // events stuck retrying" distinct from "new events arriving".
653
1052
  buffered: this.buffer.length,
654
1053
  dropped: this.dropped,
655
1054
  inFlight: this.inFlight,
@@ -659,6 +1058,29 @@ var EventQueue = class {
659
1058
  nextRetryAt: this.nextRetryAt
660
1059
  };
661
1060
  }
1061
+ /**
1062
+ * The Idempotency-Key of the in-flight pending batch (if any).
1063
+ * Exposed for testing the Stripe-style retry-reuse contract.
1064
+ * Production callers don't need this.
1065
+ */
1066
+ get pendingIdempotencyKey() {
1067
+ return this.pendingBatchId;
1068
+ }
1069
+ /**
1070
+ * Persist `[...pendingBatch, ...buffer]` — the full set of
1071
+ * not-yet-confirmed events. The next boot rehydrates this exact set
1072
+ * into `buffer` and replays. The server dedups via eventId
1073
+ * (ReplacingMergeTree on the warehouse side), so re-sending an event
1074
+ * that may have already landed is safe.
1075
+ */
1076
+ persistAll() {
1077
+ if (!this.persistent) return;
1078
+ if (this.pendingBatch === null) {
1079
+ this.persistent.save(this.buffer);
1080
+ return;
1081
+ }
1082
+ this.persistent.save([...this.pendingBatch, ...this.buffer]);
1083
+ }
662
1084
  // ---------- internal scheduling ----------
663
1085
  scheduleIdleFlush() {
664
1086
  this.cancelTimerIfSet();
@@ -692,6 +1114,14 @@ function extractRetryAfterMs(err) {
692
1114
  }
693
1115
  return void 0;
694
1116
  }
1117
+ function isPermanent4xx(err) {
1118
+ if (!err || typeof err !== "object") return false;
1119
+ const status = err.status;
1120
+ if (typeof status !== "number" || !Number.isFinite(status)) return false;
1121
+ if (status < 400 || status >= 500) return false;
1122
+ if (status === 408 || status === 429) return false;
1123
+ return true;
1124
+ }
695
1125
  function defaultScheduler(fn, ms) {
696
1126
  const id = setTimeout(fn, ms);
697
1127
  if (typeof id.unref === "function") {
@@ -1033,6 +1463,7 @@ var AutoTracker = class {
1033
1463
  /** Exposed for tests + consumers that want to reset the session manually. */
1034
1464
  resetSession() {
1035
1465
  if (this.session && !this.session.endedSent) this.emitSessionEnd();
1466
+ this.pageviewId = null;
1036
1467
  this.session = this.startNewSession();
1037
1468
  this.emitSessionStart();
1038
1469
  }
@@ -1069,6 +1500,7 @@ var AutoTracker = class {
1069
1500
  const hiddenFor = this.session.hiddenAt ? Date.now() - this.session.hiddenAt : 0;
1070
1501
  if (hiddenFor >= SESSION_RESUME_THRESHOLD_MS) {
1071
1502
  this.emitSessionEnd();
1503
+ this.pageviewId = null;
1072
1504
  this.session = this.startNewSession();
1073
1505
  this.emitSessionStart();
1074
1506
  } else {
@@ -1442,7 +1874,7 @@ function validateEventProperties(input, options = {}) {
1442
1874
  const maxStringLength = options.maxStringLength ?? DEFAULT_MAX_STRING;
1443
1875
  const maxBatchPropertyBytes = options.maxBatchPropertyBytes ?? DEFAULT_MAX_BYTES;
1444
1876
  const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
1445
- const seen = /* @__PURE__ */ new WeakSet();
1877
+ const seen = /* @__PURE__ */ new Set();
1446
1878
  const visit = (value, key, depth) => {
1447
1879
  if (depth > maxDepth) {
1448
1880
  warnings.push({ kind: "depth_exceeded", key });
@@ -1530,6 +1962,7 @@ function validateEventProperties(input, options = {}) {
1530
1962
  const result = visit(value[i], `${key}[${i}]`, depth + 1);
1531
1963
  if (result.keep) out.push(result.value);
1532
1964
  }
1965
+ seen.delete(value);
1533
1966
  return { keep: true, value: out };
1534
1967
  }
1535
1968
  if (t === "object") {
@@ -1544,6 +1977,7 @@ function validateEventProperties(input, options = {}) {
1544
1977
  const result = visit(obj[k], `${key}.${k}`, depth + 1);
1545
1978
  if (result.keep) out[k] = result.value;
1546
1979
  }
1980
+ seen.delete(obj);
1547
1981
  return { keep: true, value: out };
1548
1982
  }
1549
1983
  warnings.push({ kind: "non_serialisable", key });
@@ -1886,35 +2320,27 @@ var ConsentManager = class {
1886
2320
  };
1887
2321
  var EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
1888
2322
  var CARD_PATTERN = /\b\d(?:[ -]?\d){12,18}\b/g;
1889
- var REPLACEMENT_EMAIL = "[email]";
1890
- var REPLACEMENT_CARD = "[card]";
2323
+ var REPLACEMENT_EMAIL = "<email>";
2324
+ var REPLACEMENT_CARD = "<card>";
1891
2325
  function scrubPii(value) {
1892
2326
  if (!value) return value;
1893
- let out = value;
1894
- if (EMAIL_PATTERN.test(out)) {
1895
- out = out.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL);
1896
- }
1897
- EMAIL_PATTERN.lastIndex = 0;
1898
- if (CARD_PATTERN.test(out)) {
1899
- out = out.replace(CARD_PATTERN, REPLACEMENT_CARD);
1900
- }
1901
- CARD_PATTERN.lastIndex = 0;
1902
- return out;
2327
+ return value.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL).replace(CARD_PATTERN, REPLACEMENT_CARD);
1903
2328
  }
1904
2329
  function scrubPiiFromProperties(properties) {
1905
2330
  const out = {};
1906
2331
  for (const k of Object.keys(properties)) {
1907
- const v = properties[k];
1908
- if (typeof v === "string") {
1909
- out[k] = scrubPii(v);
1910
- } else if (Array.isArray(v)) {
1911
- out[k] = v.map((item) => typeof item === "string" ? scrubPii(item) : item);
1912
- } else {
1913
- out[k] = v;
1914
- }
2332
+ out[k] = scrubValue(properties[k]);
1915
2333
  }
1916
2334
  return out;
1917
2335
  }
2336
+ function scrubValue(v) {
2337
+ if (typeof v === "string") return scrubPii(v);
2338
+ if (Array.isArray(v)) return v.map(scrubValue);
2339
+ if (v && typeof v === "object" && v.constructor === Object) {
2340
+ return scrubPiiFromProperties(v);
2341
+ }
2342
+ return v;
2343
+ }
1918
2344
 
1919
2345
  // src/breadcrumbs.ts
1920
2346
  var BreadcrumbBuffer = class {
@@ -2204,16 +2630,18 @@ var ErrorTracker = class {
2204
2630
  const url = typeof input === "string" ? input : input?.url ?? "";
2205
2631
  const method = (init.method || "GET").toUpperCase();
2206
2632
  const start = Date.now();
2207
- this.opts.breadcrumbs.add({
2208
- timestamp: start,
2209
- category: "http",
2210
- message: `${method} ${url}`,
2211
- data: { url, method }
2212
- });
2633
+ if (!isSelfRequest(url, this.opts.selfHostname)) {
2634
+ this.opts.breadcrumbs.add({
2635
+ timestamp: start,
2636
+ category: "http",
2637
+ message: `${method} ${url}`,
2638
+ data: { url, method }
2639
+ });
2640
+ }
2213
2641
  try {
2214
2642
  const response = await origFetch(...args);
2215
2643
  if (response.status >= 500 && this.opts.isConsented()) {
2216
- if (!url.includes("api.cross-deck.com")) {
2644
+ if (!isSelfRequest(url, this.opts.selfHostname)) {
2217
2645
  this.captureHttp({
2218
2646
  url,
2219
2647
  method,
@@ -2262,7 +2690,7 @@ var ErrorTracker = class {
2262
2690
  try {
2263
2691
  if (xhr.status >= 500 && tracker.opts.isConsented()) {
2264
2692
  const url = xhr._cdUrl ?? "";
2265
- if (!url.includes("api.cross-deck.com")) {
2693
+ if (!isSelfRequest(url, tracker.opts.selfHostname)) {
2266
2694
  tracker.captureHttp({
2267
2695
  url,
2268
2696
  method: (xhr._cdMethod ?? "GET").toUpperCase(),
@@ -2422,9 +2850,10 @@ var ErrorTracker = class {
2422
2850
  if (!this.passesSample(err)) return;
2423
2851
  if (!this.passesRateLimit(err)) return;
2424
2852
  let finalErr = err;
2425
- if (this.opts.beforeSend) {
2853
+ const hook = this.opts.beforeSend?.();
2854
+ if (hook) {
2426
2855
  try {
2427
- finalErr = this.opts.beforeSend(err);
2856
+ finalErr = hook(err);
2428
2857
  } catch {
2429
2858
  finalErr = err;
2430
2859
  }
@@ -2606,6 +3035,22 @@ function safeClone(v) {
2606
3035
  function safeStringify2(v) {
2607
3036
  return coerceErrorPayload(v).message;
2608
3037
  }
3038
+ function extractSelfHostname(baseUrl) {
3039
+ if (!baseUrl || typeof baseUrl !== "string") return null;
3040
+ try {
3041
+ return new URL(baseUrl).hostname.toLowerCase();
3042
+ } catch {
3043
+ return null;
3044
+ }
3045
+ }
3046
+ function isSelfRequest(requestUrl, selfHostname) {
3047
+ if (!selfHostname || !requestUrl) return false;
3048
+ try {
3049
+ return new URL(requestUrl).hostname.toLowerCase() === selfHostname;
3050
+ } catch {
3051
+ return false;
3052
+ }
3053
+ }
2609
3054
 
2610
3055
  // src/crossdeck.ts
2611
3056
  var CrossdeckClient = class {
@@ -2622,6 +3067,28 @@ var CrossdeckClient = class {
2622
3067
  * mismatched env fails fast at boot rather than at first event-flush.
2623
3068
  */
2624
3069
  init(options) {
3070
+ if (this.state) {
3071
+ try {
3072
+ this.state.uninstallUnloadFlush?.();
3073
+ } catch {
3074
+ }
3075
+ try {
3076
+ this.state.autoTracker?.uninstall();
3077
+ } catch {
3078
+ }
3079
+ try {
3080
+ this.state.webVitals?.uninstall();
3081
+ } catch {
3082
+ }
3083
+ try {
3084
+ this.state.errors?.uninstall();
3085
+ } catch {
3086
+ }
3087
+ try {
3088
+ void this.state.events.flush({ keepalive: true });
3089
+ } catch {
3090
+ }
3091
+ }
2625
3092
  if (!options.publicKey || !options.publicKey.startsWith("cd_pub_")) {
2626
3093
  throw new CrossdeckError({
2627
3094
  type: "configuration_error",
@@ -2668,7 +3135,12 @@ var CrossdeckClient = class {
2668
3135
  // load still flushes if the user leaves quickly (the keepalive
2669
3136
  // pagehide handler picks up anything that doesn't); long enough
2670
3137
  // that bursts of clicks coalesce into one network round-trip.
2671
- eventFlushIntervalMs: options.eventFlushIntervalMs ?? 1500,
3138
+ // v1.4.0 Phase 3.3 — flush interval default parity. Pre-
3139
+ // v1.4.0: Web/Node 1500ms, RN/Swift/Android 5000ms. All
3140
+ // converged on 2000ms (the Stripe-adjacent industry norm)
3141
+ // so cross-platform funnels show events landing at the
3142
+ // same cadence on every SDK. Per-instance override stays.
3143
+ eventFlushIntervalMs: options.eventFlushIntervalMs ?? 2e3,
2672
3144
  sdkVersion: options.sdkVersion ?? SDK_VERSION,
2673
3145
  autoTrack,
2674
3146
  appVersion: options.appVersion ?? null
@@ -2729,6 +3201,15 @@ var CrossdeckClient = class {
2729
3201
  `Event flush failed (${info.lastError}). Retrying in ${info.delayMs}ms (attempt ${info.consecutiveFailures}).`,
2730
3202
  { ...info }
2731
3203
  );
3204
+ },
3205
+ onPermanentFailure: (info) => {
3206
+ const headline = `[crossdeck] Event batch DROPPED (status ${info.status}): ${info.lastError}. ${info.droppedCount} event(s) lost \u2014 check your publishable key + app config.`;
3207
+ console.error(headline);
3208
+ debug.emit(
3209
+ "sdk.flush_permanent_failure",
3210
+ headline,
3211
+ { ...info }
3212
+ );
2732
3213
  }
2733
3214
  });
2734
3215
  const deviceInfo = autoTrack.deviceInfo ? collectDeviceInfo({ appVersion: opts.appVersion ?? void 0 }) : opts.appVersion ? { appVersion: opts.appVersion } : {};
@@ -2795,8 +3276,20 @@ var CrossdeckClient = class {
2795
3276
  report: (err) => this.reportError(err),
2796
3277
  getContext: () => ({ ...this.state.errorContext }),
2797
3278
  getTags: () => ({ ...this.state.errorTags }),
2798
- beforeSend: this.state.errorBeforeSend,
2799
- isConsented: () => this.state.consent.errors
3279
+ // GETTER, not a captured value — `setErrorBeforeSend()` mutates
3280
+ // `state.errorBeforeSend` after init() and the tracker MUST
3281
+ // pick up the new hook on the next error. The pre-fix shape
3282
+ // (`beforeSend: this.state!.errorBeforeSend`) snapshotted
3283
+ // `null` at construction and made the customer's PII scrubber
3284
+ // silently inert. See error-capture.ts:ErrorTrackerOptions.beforeSend.
3285
+ beforeSend: () => this.state.errorBeforeSend,
3286
+ isConsented: () => this.state.consent.errors,
3287
+ // Derived from the configured baseUrl at init() time. Used by
3288
+ // the fetch / XHR wrappers to skip captureHttp on Crossdeck's
3289
+ // own requests — pre-fix the skip was hardcoded to
3290
+ // `api.cross-deck.com` and broke for customers on staging /
3291
+ // regional / self-hosted base URLs (recursive capture loop).
3292
+ selfHostname: extractSelfHostname(opts.baseUrl)
2800
3293
  });
2801
3294
  this.state.errors = tracker;
2802
3295
  tracker.install();
@@ -2875,13 +3368,10 @@ var CrossdeckClient = class {
2875
3368
  };
2876
3369
  if (options?.email) body.email = options.email;
2877
3370
  if (traits) body.traits = traits;
3371
+ s.entitlements.setUserKey(userId);
2878
3372
  const result = await s.http.request("POST", "/identity/alias", {
2879
3373
  body
2880
3374
  });
2881
- const priorCdcust = s.identity.crossdeckCustomerId;
2882
- if (priorCdcust && result.crossdeckCustomerId && priorCdcust !== result.crossdeckCustomerId) {
2883
- s.entitlements.clear();
2884
- }
2885
3375
  s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
2886
3376
  s.developerUserId = userId;
2887
3377
  return result;
@@ -3321,11 +3811,25 @@ var CrossdeckClient = class {
3321
3811
  message: "syncPurchases requires a signedTransactionInfo string from StoreKit 2."
3322
3812
  });
3323
3813
  }
3814
+ const rail = input.rail ?? "apple";
3815
+ const body = { ...input, rail };
3816
+ const idempotencyKey = deriveIdempotencyKeyForPurchase(body);
3324
3817
  const result = await s.http.request("POST", "/purchases/sync", {
3325
- body: { rail: input.rail ?? "apple", ...input }
3818
+ body,
3819
+ idempotencyKey
3326
3820
  });
3327
3821
  s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
3328
3822
  s.entitlements.setFromList(result.entitlements);
3823
+ try {
3824
+ const sourceProductId = result.entitlements[0]?.source.productId;
3825
+ const sourceSubscriptionId = result.entitlements[0]?.source.subscriptionId;
3826
+ const props = { rail };
3827
+ if (sourceProductId) props.productId = sourceProductId;
3828
+ if (sourceSubscriptionId) props.subscriptionId = sourceSubscriptionId;
3829
+ if (result.idempotent_replay) props.idempotent_replay = true;
3830
+ this.track("purchase.completed", props);
3831
+ } catch {
3832
+ }
3329
3833
  s.debug.emit(
3330
3834
  "sdk.purchase_evidence_sent",
3331
3835
  "StoreKit transaction forwarded. Waiting for backend verification.",
@@ -3381,13 +3885,15 @@ var CrossdeckClient = class {
3381
3885
  }
3382
3886
  this.state.autoTracker?.uninstall();
3383
3887
  this.state.identity.reset();
3384
- this.state.entitlements.clear();
3888
+ this.state.entitlements.clearAll();
3385
3889
  this.state.events.reset();
3386
3890
  this.state.superProps.clear();
3387
3891
  this.state.breadcrumbs.clear();
3388
3892
  this.state.errorContext = {};
3389
3893
  this.state.errorTags = {};
3390
3894
  this.state.developerUserId = null;
3895
+ this.state.lastServerTime = null;
3896
+ this.state.lastClientTime = null;
3391
3897
  if (this.state.autoTracker) {
3392
3898
  const tracker = new AutoTracker(
3393
3899
  this.state.options.autoTrack,
@@ -3516,7 +4022,9 @@ function isLocalHostname() {
3516
4022
  const hostname = w?.location?.hostname;
3517
4023
  if (!hostname) return false;
3518
4024
  if (hostname === "localhost" || hostname === "127.0.0.1") return true;
4025
+ if (hostname === "0.0.0.0") return true;
3519
4026
  if (hostname === "::1" || hostname === "[::1]") return true;
4027
+ if (/^\[?fe80::/i.test(hostname)) return true;
3520
4028
  if (hostname.endsWith(".local")) return true;
3521
4029
  if (/^10\./.test(hostname)) return true;
3522
4030
  if (/^192\.168\./.test(hostname)) return true;