@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/index.cjs CHANGED
@@ -97,9 +97,11 @@ function typeMapForStatus(status) {
97
97
  return "internal_error";
98
98
  }
99
99
 
100
- // src/http.ts
100
+ // src/_version.ts
101
+ var SDK_VERSION = "1.4.2";
101
102
  var SDK_NAME = "@cross-deck/web";
102
- var SDK_VERSION = "1.1.0";
103
+
104
+ // src/http.ts
103
105
  var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
104
106
  var DEFAULT_TIMEOUT_MS = 15e3;
105
107
  var HttpClient = class {
@@ -346,29 +348,258 @@ function randomChars(count) {
346
348
  return out.join("");
347
349
  }
348
350
 
351
+ // src/hash.ts
352
+ var K = new Uint32Array([
353
+ 1116352408,
354
+ 1899447441,
355
+ 3049323471,
356
+ 3921009573,
357
+ 961987163,
358
+ 1508970993,
359
+ 2453635748,
360
+ 2870763221,
361
+ 3624381080,
362
+ 310598401,
363
+ 607225278,
364
+ 1426881987,
365
+ 1925078388,
366
+ 2162078206,
367
+ 2614888103,
368
+ 3248222580,
369
+ 3835390401,
370
+ 4022224774,
371
+ 264347078,
372
+ 604807628,
373
+ 770255983,
374
+ 1249150122,
375
+ 1555081692,
376
+ 1996064986,
377
+ 2554220882,
378
+ 2821834349,
379
+ 2952996808,
380
+ 3210313671,
381
+ 3336571891,
382
+ 3584528711,
383
+ 113926993,
384
+ 338241895,
385
+ 666307205,
386
+ 773529912,
387
+ 1294757372,
388
+ 1396182291,
389
+ 1695183700,
390
+ 1986661051,
391
+ 2177026350,
392
+ 2456956037,
393
+ 2730485921,
394
+ 2820302411,
395
+ 3259730800,
396
+ 3345764771,
397
+ 3516065817,
398
+ 3600352804,
399
+ 4094571909,
400
+ 275423344,
401
+ 430227734,
402
+ 506948616,
403
+ 659060556,
404
+ 883997877,
405
+ 958139571,
406
+ 1322822218,
407
+ 1537002063,
408
+ 1747873779,
409
+ 1955562222,
410
+ 2024104815,
411
+ 2227730452,
412
+ 2361852424,
413
+ 2428436474,
414
+ 2756734187,
415
+ 3204031479,
416
+ 3329325298
417
+ ]);
418
+ function utf8Bytes(input) {
419
+ if (typeof TextEncoder !== "undefined") {
420
+ return new TextEncoder().encode(input);
421
+ }
422
+ const out = [];
423
+ for (let i = 0; i < input.length; i++) {
424
+ let codePoint = input.charCodeAt(i);
425
+ if (codePoint >= 55296 && codePoint <= 56319 && i + 1 < input.length) {
426
+ const next = input.charCodeAt(i + 1);
427
+ if (next >= 56320 && next <= 57343) {
428
+ codePoint = 65536 + (codePoint - 55296 << 10) + (next - 56320);
429
+ i++;
430
+ }
431
+ }
432
+ if (codePoint < 128) {
433
+ out.push(codePoint);
434
+ } else if (codePoint < 2048) {
435
+ out.push(192 | codePoint >> 6);
436
+ out.push(128 | codePoint & 63);
437
+ } else if (codePoint < 65536) {
438
+ out.push(224 | codePoint >> 12);
439
+ out.push(128 | codePoint >> 6 & 63);
440
+ out.push(128 | codePoint & 63);
441
+ } else {
442
+ out.push(240 | codePoint >> 18);
443
+ out.push(128 | codePoint >> 12 & 63);
444
+ out.push(128 | codePoint >> 6 & 63);
445
+ out.push(128 | codePoint & 63);
446
+ }
447
+ }
448
+ return new Uint8Array(out);
449
+ }
450
+ function sha256Hex(input) {
451
+ const bytes = utf8Bytes(input);
452
+ const bitLength = bytes.length * 8;
453
+ const blockCount = Math.floor((bytes.length + 9 + 63) / 64);
454
+ const padded = new Uint8Array(blockCount * 64);
455
+ padded.set(bytes);
456
+ padded[bytes.length] = 128;
457
+ const high = Math.floor(bitLength / 4294967296);
458
+ const low = bitLength >>> 0;
459
+ const lenOffset = padded.length - 8;
460
+ padded[lenOffset + 0] = high >>> 24 & 255;
461
+ padded[lenOffset + 1] = high >>> 16 & 255;
462
+ padded[lenOffset + 2] = high >>> 8 & 255;
463
+ padded[lenOffset + 3] = high & 255;
464
+ padded[lenOffset + 4] = low >>> 24 & 255;
465
+ padded[lenOffset + 5] = low >>> 16 & 255;
466
+ padded[lenOffset + 6] = low >>> 8 & 255;
467
+ padded[lenOffset + 7] = low & 255;
468
+ const H = new Uint32Array([
469
+ 1779033703,
470
+ 3144134277,
471
+ 1013904242,
472
+ 2773480762,
473
+ 1359893119,
474
+ 2600822924,
475
+ 528734635,
476
+ 1541459225
477
+ ]);
478
+ const W = new Uint32Array(64);
479
+ for (let block = 0; block < blockCount; block++) {
480
+ const offset = block * 64;
481
+ for (let t = 0; t < 16; t++) {
482
+ 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;
483
+ }
484
+ for (let t = 16; t < 64; t++) {
485
+ const w15 = W[t - 15];
486
+ const w2 = W[t - 2];
487
+ const s0 = (w15 >>> 7 | w15 << 25) ^ (w15 >>> 18 | w15 << 14) ^ w15 >>> 3;
488
+ const s1 = (w2 >>> 17 | w2 << 15) ^ (w2 >>> 19 | w2 << 13) ^ w2 >>> 10;
489
+ W[t] = W[t - 16] + s0 + W[t - 7] + s1 >>> 0;
490
+ }
491
+ let a = H[0], b = H[1], c = H[2], d = H[3];
492
+ let e = H[4], f = H[5], g = H[6], h = H[7];
493
+ for (let t = 0; t < 64; t++) {
494
+ const S1 = (e >>> 6 | e << 26) ^ (e >>> 11 | e << 21) ^ (e >>> 25 | e << 7);
495
+ const ch = e & f ^ ~e & g;
496
+ const temp1 = h + S1 + ch + K[t] + W[t] >>> 0;
497
+ const S0 = (a >>> 2 | a << 30) ^ (a >>> 13 | a << 19) ^ (a >>> 22 | a << 10);
498
+ const maj = a & b ^ a & c ^ b & c;
499
+ const temp2 = S0 + maj >>> 0;
500
+ h = g;
501
+ g = f;
502
+ f = e;
503
+ e = d + temp1 >>> 0;
504
+ d = c;
505
+ c = b;
506
+ b = a;
507
+ a = temp1 + temp2 >>> 0;
508
+ }
509
+ H[0] = H[0] + a >>> 0;
510
+ H[1] = H[1] + b >>> 0;
511
+ H[2] = H[2] + c >>> 0;
512
+ H[3] = H[3] + d >>> 0;
513
+ H[4] = H[4] + e >>> 0;
514
+ H[5] = H[5] + f >>> 0;
515
+ H[6] = H[6] + g >>> 0;
516
+ H[7] = H[7] + h >>> 0;
517
+ }
518
+ let hex = "";
519
+ for (let i = 0; i < 8; i++) {
520
+ hex += H[i].toString(16).padStart(8, "0");
521
+ }
522
+ return hex;
523
+ }
524
+
349
525
  // src/entitlement-cache.ts
350
526
  var DEFAULT_STALE_AFTER_MS = 24 * 60 * 60 * 1e3;
351
- var EntitlementCache = class {
527
+ var ANON_SUFFIX = "_anon";
528
+ var INDEX_SUFFIX = "_index";
529
+ var EntitlementCache = class _EntitlementCache {
352
530
  /**
353
- * @param storage Device storage adapter. When omitted (tests) or
354
- * a MemoryStorage (strict-consent / no-persistence
355
- * mode) the cache is session-only — durability is
356
- * simply absent, never wrong.
357
- * @param storageKey Full key the persisted blob lives under.
358
- * @param staleAfterMs Age past which last-known-good is flagged stale
359
- * even without a failed refresh. Default 24h.
531
+ * @param storage Device storage adapter. When omitted (tests) or
532
+ * a MemoryStorage (strict-consent / no-persistence
533
+ * mode) the cache is session-only — durability is
534
+ * simply absent, never wrong.
535
+ * @param storageKeyPrefix Prefix used to derive per-user storage keys
536
+ * (`<prefix>:<sha256(userId)>`). Default
537
+ * `crossdeck:entitlements`. The trailing user
538
+ * suffix is filled at identify() / reset()
539
+ * time — see [[setUserKey]].
540
+ * @param staleAfterMs Age past which last-known-good is flagged stale
541
+ * even without a failed refresh. Default 24h.
360
542
  */
361
- constructor(storage, storageKey = "crossdeck:entitlements", staleAfterMs = DEFAULT_STALE_AFTER_MS) {
543
+ constructor(storage, storageKeyPrefix = "crossdeck:entitlements", staleAfterMs = DEFAULT_STALE_AFTER_MS) {
362
544
  this.all = [];
363
545
  this.lastUpdated = 0;
364
546
  this.lastRefreshFailedAt = 0;
365
547
  this.listeners = /* @__PURE__ */ new Set();
366
548
  this.listenerErrorCount = 0;
549
+ this.currentSuffix = ANON_SUFFIX;
367
550
  this.storage = storage;
368
- this.storageKey = storageKey;
551
+ this.storageKeyPrefix = storageKeyPrefix;
369
552
  this.staleAfterMs = staleAfterMs;
370
553
  this.hydrate();
371
554
  }
555
+ /** The full storage key the current-user blob is persisted under. */
556
+ get storageKey() {
557
+ return `${this.storageKeyPrefix}:${this.currentSuffix}`;
558
+ }
559
+ /** Key of the index blob — a JSON array of every suffix we've
560
+ * written. Used by clearAll() to scope a logout-wipe. */
561
+ get indexKey() {
562
+ return `${this.storageKeyPrefix}:${INDEX_SUFFIX}`;
563
+ }
564
+ /** Derive a stable suffix for a developerUserId via SHA-256. The
565
+ * raw userId never lands in the storage key — protects against
566
+ * accidentally leaking email-style identifiers through DevTools
567
+ * inspection. Pass `null` to switch back to the anonymous slot. */
568
+ static suffixForUserId(userId) {
569
+ if (userId == null || userId === "") return ANON_SUFFIX;
570
+ return sha256Hex(userId);
571
+ }
572
+ /**
573
+ * Switch the cache to a different user's storage slot. Bank-grade
574
+ * three-layer isolation:
575
+ * (a) Physical key separation — `<prefix>:<sha256(userId)>` so
576
+ * a user-switch can't physically read prior user's data
577
+ * even if the in-memory clear was skipped.
578
+ * (b) Unconditional in-memory clear — invoked whenever the
579
+ * active suffix changes, even on same-id re-identify.
580
+ * (c) Re-hydrate from the new slot — a returning user observes
581
+ * their last-known-good cache from storage immediately.
582
+ *
583
+ * Caller (identify() / reset()) MUST invoke this BEFORE the next
584
+ * setFromList() so the write lands under the right key.
585
+ */
586
+ setUserKey(userId) {
587
+ const nextSuffix = _EntitlementCache.suffixForUserId(userId);
588
+ if (nextSuffix === this.currentSuffix) {
589
+ this.all = [];
590
+ this.lastUpdated = 0;
591
+ this.lastRefreshFailedAt = 0;
592
+ this.notify();
593
+ this.hydrate();
594
+ return;
595
+ }
596
+ this.currentSuffix = nextSuffix;
597
+ this.all = [];
598
+ this.lastUpdated = 0;
599
+ this.lastRefreshFailedAt = 0;
600
+ this.hydrate();
601
+ this.notify();
602
+ }
372
603
  /**
373
604
  * Sync read — true iff the entitlement is currently granting access.
374
605
  *
@@ -438,12 +669,13 @@ var EntitlementCache = class {
438
669
  this.lastUpdated = Date.now();
439
670
  this.lastRefreshFailedAt = 0;
440
671
  this.persist();
672
+ this.recordSuffixInIndex(this.currentSuffix);
441
673
  this.notify();
442
674
  }
443
675
  /**
444
- * Wipe used on reset() (logout) and on an identity switch. Clears
445
- * BOTH memory and durable storage so a prior user's entitlements can
446
- * never leak to the next person on this device.
676
+ * Wipe the CURRENT user's slot. Used internally when a single
677
+ * user's cache needs to be invalidated without affecting other
678
+ * persisted slots. The full-logout path is [[clearAll]].
447
679
  */
448
680
  clear() {
449
681
  this.all = [];
@@ -455,6 +687,40 @@ var EntitlementCache = class {
455
687
  } catch {
456
688
  }
457
689
  }
690
+ this.removeSuffixFromIndex(this.currentSuffix);
691
+ this.notify();
692
+ }
693
+ /**
694
+ * Logout-grade wipe — bank-grade contract: removes EVERY per-user
695
+ * entitlement slot the SDK has ever written on this device, then
696
+ * clears the index. Used by `Crossdeck.reset()` so a logout on a
697
+ * shared device can never leave another user's entitlements
698
+ * readable (layer (c) of the v1.4.0 isolation fix).
699
+ *
700
+ * After clearAll(), the cache is back to anonymous + empty.
701
+ */
702
+ clearAll() {
703
+ this.all = [];
704
+ this.lastUpdated = 0;
705
+ this.lastRefreshFailedAt = 0;
706
+ this.currentSuffix = ANON_SUFFIX;
707
+ if (this.storage) {
708
+ const suffixes = this.readIndex();
709
+ for (const suffix of suffixes) {
710
+ try {
711
+ this.storage.removeItem(`${this.storageKeyPrefix}:${suffix}`);
712
+ } catch {
713
+ }
714
+ }
715
+ try {
716
+ this.storage.removeItem(`${this.storageKeyPrefix}:${ANON_SUFFIX}`);
717
+ } catch {
718
+ }
719
+ try {
720
+ this.storage.removeItem(this.indexKey);
721
+ } catch {
722
+ }
723
+ }
458
724
  this.notify();
459
725
  }
460
726
  /**
@@ -504,6 +770,47 @@ var EntitlementCache = class {
504
770
  } catch {
505
771
  }
506
772
  }
773
+ /** Read the index of all per-user suffixes the SDK has written. */
774
+ readIndex() {
775
+ if (!this.storage) return [];
776
+ try {
777
+ const raw = this.storage.getItem(this.indexKey);
778
+ if (!raw) return [];
779
+ const parsed = JSON.parse(raw);
780
+ if (Array.isArray(parsed)) {
781
+ return parsed.filter((x) => typeof x === "string");
782
+ }
783
+ return [];
784
+ } catch {
785
+ return [];
786
+ }
787
+ }
788
+ /** Add a suffix to the persisted index. Idempotent. */
789
+ recordSuffixInIndex(suffix) {
790
+ if (!this.storage) return;
791
+ const existing = this.readIndex();
792
+ if (existing.includes(suffix)) return;
793
+ existing.push(suffix);
794
+ try {
795
+ this.storage.setItem(this.indexKey, JSON.stringify(existing));
796
+ } catch {
797
+ }
798
+ }
799
+ /** Remove a suffix from the persisted index. No-op if absent. */
800
+ removeSuffixFromIndex(suffix) {
801
+ if (!this.storage) return;
802
+ const existing = this.readIndex();
803
+ const next = existing.filter((s) => s !== suffix);
804
+ if (next.length === existing.length) return;
805
+ try {
806
+ if (next.length === 0) {
807
+ this.storage.removeItem(this.indexKey);
808
+ } else {
809
+ this.storage.setItem(this.indexKey, JSON.stringify(next));
810
+ }
811
+ } catch {
812
+ }
813
+ }
507
814
  notify() {
508
815
  if (this.listeners.size === 0) return;
509
816
  const snapshot = this.all.slice();
@@ -518,6 +825,34 @@ var EntitlementCache = class {
518
825
  }
519
826
  };
520
827
 
828
+ // src/idempotency-key.ts
829
+ function formatAsUuid(hex) {
830
+ return [
831
+ hex.slice(0, 8),
832
+ hex.slice(8, 12),
833
+ hex.slice(12, 16),
834
+ hex.slice(16, 20),
835
+ hex.slice(20, 32)
836
+ ].join("-");
837
+ }
838
+ function deriveIdempotencyKeyForPurchase(body) {
839
+ let identifier;
840
+ if (body.rail === "apple") {
841
+ identifier = body.signedTransactionInfo ?? "";
842
+ } else if (body.rail === "google") {
843
+ identifier = body.purchaseToken ?? "";
844
+ } else {
845
+ identifier = "";
846
+ }
847
+ if (!identifier) {
848
+ throw new Error(
849
+ `deriveIdempotencyKeyForPurchase: no stable identifier in body (rail=${body.rail}). Apple needs signedTransactionInfo; Google needs purchaseToken.`
850
+ );
851
+ }
852
+ const namespaced = `crossdeck:purchases/sync:${body.rail}:${identifier}`;
853
+ return formatAsUuid(sha256Hex(namespaced));
854
+ }
855
+
521
856
  // src/retry-policy.ts
522
857
  var DEFAULT_BASE = 1e3;
523
858
  var DEFAULT_MAX = 6e4;
@@ -530,8 +865,10 @@ function computeNextDelay(attempts, retryAfterMs, options = {}, random = Math.ra
530
865
  const safeAttempts = Math.min(attempts, 30);
531
866
  const ceiling = Math.min(max, base * Math.pow(factor, safeAttempts));
532
867
  const jittered = ceiling * random();
533
- if (retryAfterMs !== void 0 && retryAfterMs > jittered) {
534
- return Math.min(max, retryAfterMs);
868
+ if (retryAfterMs !== void 0) {
869
+ const ABSOLUTE_MAX_MS = 24 * 60 * 60 * 1e3;
870
+ const honoured = Math.min(ABSOLUTE_MAX_MS, retryAfterMs);
871
+ if (honoured > jittered) return honoured;
535
872
  }
536
873
  return Math.max(0, Math.round(jittered));
537
874
  }
@@ -566,6 +903,27 @@ var EventQueue = class {
566
903
  constructor(cfg) {
567
904
  this.cfg = cfg;
568
905
  this.buffer = [];
906
+ /**
907
+ * In-flight events that have been spliced from `buffer` for the
908
+ * current batch but haven't yet been confirmed (success or permanent
909
+ * failure). On a retry-driven re-flush we re-use this slot alongside
910
+ * `pendingBatchId` so the Stripe-style Idempotency-Key is preserved
911
+ * across retries of the SAME logical batch.
912
+ *
913
+ * Pre-fix the splice cleared the buffer AND we immediately
914
+ * `persistent.save(empty)` BEFORE awaiting the network call — a
915
+ * crash mid-flight wiped the persisted blob and the batch was lost
916
+ * with no replay on next boot. Now the persisted blob always carries
917
+ * `[...pendingBatch, ...buffer]` so the in-flight events survive
918
+ * until the server confirms them.
919
+ *
920
+ * Pre-fix every retry attempt also minted a NEW batchId via
921
+ * `splice + mintBatchId`, defeating the backend's
922
+ * `Idempotency-Key` dedup. Reuse via this slot brings web in
923
+ * lockstep with node (which already had the field).
924
+ */
925
+ this.pendingBatch = null;
926
+ this.pendingBatchId = null;
569
927
  this.dropped = 0;
570
928
  this.inFlight = 0;
571
929
  this.lastFlushAt = 0;
@@ -598,7 +956,7 @@ var EventQueue = class {
598
956
  this.cfg.onDrop?.(overflow);
599
957
  }
600
958
  this.cfg.onBufferChange?.(this.buffer.length);
601
- this.persistent?.save(this.buffer);
959
+ this.persistAll();
602
960
  if (this.buffer.length >= this.cfg.batchSize) {
603
961
  void this.flush();
604
962
  } else {
@@ -607,22 +965,44 @@ var EventQueue = class {
607
965
  }
608
966
  /**
609
967
  * Flush the buffer to /v1/events. Resolves when the network call
610
- * completes (success or failure). On failure, events stay in the
611
- * buffer for the next scheduled retry.
968
+ * completes (success or failure). On a retryable failure the batch
969
+ * stays in `pendingBatch` for the next scheduled retry — the SAME
970
+ * batch with the SAME `Idempotency-Key` is re-sent (Stripe pattern).
971
+ *
972
+ * Three terminal states from one call:
973
+ * - 2xx success: pendingBatch cleared, persisted state collapses to
974
+ * just `buffer` (any new events that arrived during in-flight).
975
+ * - 4xx permanent (except 408/429): pendingBatch DROPPED, `dropped`
976
+ * counter increments, a `permanent_failure` signal fires. The
977
+ * server is telling us our request is malformed / our key is
978
+ * revoked / we lack permission — retrying with the same key
979
+ * forever just grows the queue while the customer thinks events
980
+ * are landing.
981
+ * - 5xx / network / 408 / 429: pendingBatch + batchId stay; backoff
982
+ * schedules a retry; the next `flush()` re-uses both.
612
983
  *
613
984
  * `options.keepalive` marks the underlying fetch as keepalive so the
614
- * browser keeps the request alive past page unload. Use this for
615
- * terminal flushes (pagehide / visibilitychange→hidden / beforeunload).
985
+ * browser keeps the request alive past page unload. Use for terminal
986
+ * flushes (pagehide / visibilitychange→hidden / beforeunload).
616
987
  */
617
988
  async flush(options = {}) {
618
- if (this.buffer.length === 0) return null;
989
+ let batch;
990
+ let batchId;
991
+ if (this.pendingBatch !== null && this.pendingBatchId !== null) {
992
+ batch = this.pendingBatch;
993
+ batchId = this.pendingBatchId;
994
+ } else {
995
+ if (this.buffer.length === 0) return null;
996
+ batch = this.buffer.splice(0);
997
+ batchId = this.mintBatchId();
998
+ this.pendingBatch = batch;
999
+ this.pendingBatchId = batchId;
1000
+ this.inFlight += batch.length;
1001
+ this.cfg.onBufferChange?.(this.buffer.length);
1002
+ this.persistAll();
1003
+ }
619
1004
  this.cancelTimerIfSet();
620
1005
  this.nextRetryAt = null;
621
- const batch = this.buffer.splice(0);
622
- const batchId = this.mintBatchId();
623
- this.inFlight += batch.length;
624
- this.persistent?.save(this.buffer);
625
- this.cfg.onBufferChange?.(this.buffer.length);
626
1006
  try {
627
1007
  const env = this.cfg.envelope();
628
1008
  const result = await this.cfg.http.request("POST", "/events", {
@@ -641,20 +1021,33 @@ var EventQueue = class {
641
1021
  this.lastFlushAt = Date.now();
642
1022
  this.lastError = null;
643
1023
  this.inFlight -= batch.length;
1024
+ this.pendingBatch = null;
1025
+ this.pendingBatchId = null;
644
1026
  this.retry.recordSuccess();
645
- this.persistent?.save(this.buffer);
1027
+ this.persistAll();
646
1028
  if (!this.firstFlushFired) {
647
1029
  this.firstFlushFired = true;
648
1030
  this.cfg.onFirstFlushSuccess?.();
649
1031
  }
650
1032
  return result;
651
1033
  } catch (err) {
652
- this.buffer.unshift(...batch);
653
- this.inFlight -= batch.length;
654
1034
  const message = err instanceof Error ? err.message : String(err);
655
1035
  this.lastError = message;
656
- this.persistent?.save(this.buffer);
657
- this.cfg.onBufferChange?.(this.buffer.length);
1036
+ if (isPermanent4xx(err)) {
1037
+ const droppedCount = batch.length;
1038
+ this.pendingBatch = null;
1039
+ this.pendingBatchId = null;
1040
+ this.inFlight -= droppedCount;
1041
+ this.dropped += droppedCount;
1042
+ this.persistAll();
1043
+ this.cfg.onDrop?.(droppedCount);
1044
+ this.cfg.onPermanentFailure?.({
1045
+ status: err.status ?? 0,
1046
+ droppedCount,
1047
+ lastError: message
1048
+ });
1049
+ return null;
1050
+ }
658
1051
  const retryAfterMs = extractRetryAfterMs(err);
659
1052
  const delay = this.retry.nextDelay(retryAfterMs);
660
1053
  this.scheduleRetry(delay);
@@ -672,6 +1065,8 @@ var EventQueue = class {
672
1065
  this.cancelTimerIfSet();
673
1066
  this.nextRetryAt = null;
674
1067
  this.buffer = [];
1068
+ this.pendingBatch = null;
1069
+ this.pendingBatchId = null;
675
1070
  this.dropped = 0;
676
1071
  this.inFlight = 0;
677
1072
  this.lastError = null;
@@ -681,6 +1076,10 @@ var EventQueue = class {
681
1076
  }
682
1077
  getStats() {
683
1078
  return {
1079
+ // `buffered` counts events waiting for their FIRST flush. The
1080
+ // in-flight pendingBatch (retrying) is tracked separately via
1081
+ // `inFlight` — surfacing both lets diagnostics show "we have
1082
+ // events stuck retrying" distinct from "new events arriving".
684
1083
  buffered: this.buffer.length,
685
1084
  dropped: this.dropped,
686
1085
  inFlight: this.inFlight,
@@ -690,6 +1089,29 @@ var EventQueue = class {
690
1089
  nextRetryAt: this.nextRetryAt
691
1090
  };
692
1091
  }
1092
+ /**
1093
+ * The Idempotency-Key of the in-flight pending batch (if any).
1094
+ * Exposed for testing the Stripe-style retry-reuse contract.
1095
+ * Production callers don't need this.
1096
+ */
1097
+ get pendingIdempotencyKey() {
1098
+ return this.pendingBatchId;
1099
+ }
1100
+ /**
1101
+ * Persist `[...pendingBatch, ...buffer]` — the full set of
1102
+ * not-yet-confirmed events. The next boot rehydrates this exact set
1103
+ * into `buffer` and replays. The server dedups via eventId
1104
+ * (ReplacingMergeTree on the warehouse side), so re-sending an event
1105
+ * that may have already landed is safe.
1106
+ */
1107
+ persistAll() {
1108
+ if (!this.persistent) return;
1109
+ if (this.pendingBatch === null) {
1110
+ this.persistent.save(this.buffer);
1111
+ return;
1112
+ }
1113
+ this.persistent.save([...this.pendingBatch, ...this.buffer]);
1114
+ }
693
1115
  // ---------- internal scheduling ----------
694
1116
  scheduleIdleFlush() {
695
1117
  this.cancelTimerIfSet();
@@ -723,6 +1145,14 @@ function extractRetryAfterMs(err) {
723
1145
  }
724
1146
  return void 0;
725
1147
  }
1148
+ function isPermanent4xx(err) {
1149
+ if (!err || typeof err !== "object") return false;
1150
+ const status = err.status;
1151
+ if (typeof status !== "number" || !Number.isFinite(status)) return false;
1152
+ if (status < 400 || status >= 500) return false;
1153
+ if (status === 408 || status === 429) return false;
1154
+ return true;
1155
+ }
726
1156
  function defaultScheduler(fn, ms) {
727
1157
  const id = setTimeout(fn, ms);
728
1158
  if (typeof id.unref === "function") {
@@ -1064,6 +1494,7 @@ var AutoTracker = class {
1064
1494
  /** Exposed for tests + consumers that want to reset the session manually. */
1065
1495
  resetSession() {
1066
1496
  if (this.session && !this.session.endedSent) this.emitSessionEnd();
1497
+ this.pageviewId = null;
1067
1498
  this.session = this.startNewSession();
1068
1499
  this.emitSessionStart();
1069
1500
  }
@@ -1100,6 +1531,7 @@ var AutoTracker = class {
1100
1531
  const hiddenFor = this.session.hiddenAt ? Date.now() - this.session.hiddenAt : 0;
1101
1532
  if (hiddenFor >= SESSION_RESUME_THRESHOLD_MS) {
1102
1533
  this.emitSessionEnd();
1534
+ this.pageviewId = null;
1103
1535
  this.session = this.startNewSession();
1104
1536
  this.emitSessionStart();
1105
1537
  } else {
@@ -1473,7 +1905,7 @@ function validateEventProperties(input, options = {}) {
1473
1905
  const maxStringLength = options.maxStringLength ?? DEFAULT_MAX_STRING;
1474
1906
  const maxBatchPropertyBytes = options.maxBatchPropertyBytes ?? DEFAULT_MAX_BYTES;
1475
1907
  const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
1476
- const seen = /* @__PURE__ */ new WeakSet();
1908
+ const seen = /* @__PURE__ */ new Set();
1477
1909
  const visit = (value, key, depth) => {
1478
1910
  if (depth > maxDepth) {
1479
1911
  warnings.push({ kind: "depth_exceeded", key });
@@ -1561,6 +1993,7 @@ function validateEventProperties(input, options = {}) {
1561
1993
  const result = visit(value[i], `${key}[${i}]`, depth + 1);
1562
1994
  if (result.keep) out.push(result.value);
1563
1995
  }
1996
+ seen.delete(value);
1564
1997
  return { keep: true, value: out };
1565
1998
  }
1566
1999
  if (t === "object") {
@@ -1575,6 +2008,7 @@ function validateEventProperties(input, options = {}) {
1575
2008
  const result = visit(obj[k], `${key}.${k}`, depth + 1);
1576
2009
  if (result.keep) out[k] = result.value;
1577
2010
  }
2011
+ seen.delete(obj);
1578
2012
  return { keep: true, value: out };
1579
2013
  }
1580
2014
  warnings.push({ kind: "non_serialisable", key });
@@ -1917,35 +2351,27 @@ var ConsentManager = class {
1917
2351
  };
1918
2352
  var EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
1919
2353
  var CARD_PATTERN = /\b\d(?:[ -]?\d){12,18}\b/g;
1920
- var REPLACEMENT_EMAIL = "[email]";
1921
- var REPLACEMENT_CARD = "[card]";
2354
+ var REPLACEMENT_EMAIL = "<email>";
2355
+ var REPLACEMENT_CARD = "<card>";
1922
2356
  function scrubPii(value) {
1923
2357
  if (!value) return value;
1924
- let out = value;
1925
- if (EMAIL_PATTERN.test(out)) {
1926
- out = out.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL);
1927
- }
1928
- EMAIL_PATTERN.lastIndex = 0;
1929
- if (CARD_PATTERN.test(out)) {
1930
- out = out.replace(CARD_PATTERN, REPLACEMENT_CARD);
1931
- }
1932
- CARD_PATTERN.lastIndex = 0;
1933
- return out;
2358
+ return value.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL).replace(CARD_PATTERN, REPLACEMENT_CARD);
1934
2359
  }
1935
2360
  function scrubPiiFromProperties(properties) {
1936
2361
  const out = {};
1937
2362
  for (const k of Object.keys(properties)) {
1938
- const v = properties[k];
1939
- if (typeof v === "string") {
1940
- out[k] = scrubPii(v);
1941
- } else if (Array.isArray(v)) {
1942
- out[k] = v.map((item) => typeof item === "string" ? scrubPii(item) : item);
1943
- } else {
1944
- out[k] = v;
1945
- }
2363
+ out[k] = scrubValue(properties[k]);
1946
2364
  }
1947
2365
  return out;
1948
2366
  }
2367
+ function scrubValue(v) {
2368
+ if (typeof v === "string") return scrubPii(v);
2369
+ if (Array.isArray(v)) return v.map(scrubValue);
2370
+ if (v && typeof v === "object" && v.constructor === Object) {
2371
+ return scrubPiiFromProperties(v);
2372
+ }
2373
+ return v;
2374
+ }
1949
2375
 
1950
2376
  // src/breadcrumbs.ts
1951
2377
  var BreadcrumbBuffer = class {
@@ -2235,16 +2661,18 @@ var ErrorTracker = class {
2235
2661
  const url = typeof input === "string" ? input : input?.url ?? "";
2236
2662
  const method = (init.method || "GET").toUpperCase();
2237
2663
  const start = Date.now();
2238
- this.opts.breadcrumbs.add({
2239
- timestamp: start,
2240
- category: "http",
2241
- message: `${method} ${url}`,
2242
- data: { url, method }
2243
- });
2664
+ if (!isSelfRequest(url, this.opts.selfHostname)) {
2665
+ this.opts.breadcrumbs.add({
2666
+ timestamp: start,
2667
+ category: "http",
2668
+ message: `${method} ${url}`,
2669
+ data: { url, method }
2670
+ });
2671
+ }
2244
2672
  try {
2245
2673
  const response = await origFetch(...args);
2246
2674
  if (response.status >= 500 && this.opts.isConsented()) {
2247
- if (!url.includes("api.cross-deck.com")) {
2675
+ if (!isSelfRequest(url, this.opts.selfHostname)) {
2248
2676
  this.captureHttp({
2249
2677
  url,
2250
2678
  method,
@@ -2293,7 +2721,7 @@ var ErrorTracker = class {
2293
2721
  try {
2294
2722
  if (xhr.status >= 500 && tracker.opts.isConsented()) {
2295
2723
  const url = xhr._cdUrl ?? "";
2296
- if (!url.includes("api.cross-deck.com")) {
2724
+ if (!isSelfRequest(url, tracker.opts.selfHostname)) {
2297
2725
  tracker.captureHttp({
2298
2726
  url,
2299
2727
  method: (xhr._cdMethod ?? "GET").toUpperCase(),
@@ -2453,9 +2881,10 @@ var ErrorTracker = class {
2453
2881
  if (!this.passesSample(err)) return;
2454
2882
  if (!this.passesRateLimit(err)) return;
2455
2883
  let finalErr = err;
2456
- if (this.opts.beforeSend) {
2884
+ const hook = this.opts.beforeSend?.();
2885
+ if (hook) {
2457
2886
  try {
2458
- finalErr = this.opts.beforeSend(err);
2887
+ finalErr = hook(err);
2459
2888
  } catch {
2460
2889
  finalErr = err;
2461
2890
  }
@@ -2637,6 +3066,22 @@ function safeClone(v) {
2637
3066
  function safeStringify2(v) {
2638
3067
  return coerceErrorPayload(v).message;
2639
3068
  }
3069
+ function extractSelfHostname(baseUrl) {
3070
+ if (!baseUrl || typeof baseUrl !== "string") return null;
3071
+ try {
3072
+ return new URL(baseUrl).hostname.toLowerCase();
3073
+ } catch {
3074
+ return null;
3075
+ }
3076
+ }
3077
+ function isSelfRequest(requestUrl, selfHostname) {
3078
+ if (!selfHostname || !requestUrl) return false;
3079
+ try {
3080
+ return new URL(requestUrl).hostname.toLowerCase() === selfHostname;
3081
+ } catch {
3082
+ return false;
3083
+ }
3084
+ }
2640
3085
 
2641
3086
  // src/crossdeck.ts
2642
3087
  var CrossdeckClient = class {
@@ -2653,6 +3098,28 @@ var CrossdeckClient = class {
2653
3098
  * mismatched env fails fast at boot rather than at first event-flush.
2654
3099
  */
2655
3100
  init(options) {
3101
+ if (this.state) {
3102
+ try {
3103
+ this.state.uninstallUnloadFlush?.();
3104
+ } catch {
3105
+ }
3106
+ try {
3107
+ this.state.autoTracker?.uninstall();
3108
+ } catch {
3109
+ }
3110
+ try {
3111
+ this.state.webVitals?.uninstall();
3112
+ } catch {
3113
+ }
3114
+ try {
3115
+ this.state.errors?.uninstall();
3116
+ } catch {
3117
+ }
3118
+ try {
3119
+ void this.state.events.flush({ keepalive: true });
3120
+ } catch {
3121
+ }
3122
+ }
2656
3123
  if (!options.publicKey || !options.publicKey.startsWith("cd_pub_")) {
2657
3124
  throw new CrossdeckError({
2658
3125
  type: "configuration_error",
@@ -2699,7 +3166,12 @@ var CrossdeckClient = class {
2699
3166
  // load still flushes if the user leaves quickly (the keepalive
2700
3167
  // pagehide handler picks up anything that doesn't); long enough
2701
3168
  // that bursts of clicks coalesce into one network round-trip.
2702
- eventFlushIntervalMs: options.eventFlushIntervalMs ?? 1500,
3169
+ // v1.4.0 Phase 3.3 — flush interval default parity. Pre-
3170
+ // v1.4.0: Web/Node 1500ms, RN/Swift/Android 5000ms. All
3171
+ // converged on 2000ms (the Stripe-adjacent industry norm)
3172
+ // so cross-platform funnels show events landing at the
3173
+ // same cadence on every SDK. Per-instance override stays.
3174
+ eventFlushIntervalMs: options.eventFlushIntervalMs ?? 2e3,
2703
3175
  sdkVersion: options.sdkVersion ?? SDK_VERSION,
2704
3176
  autoTrack,
2705
3177
  appVersion: options.appVersion ?? null
@@ -2760,6 +3232,15 @@ var CrossdeckClient = class {
2760
3232
  `Event flush failed (${info.lastError}). Retrying in ${info.delayMs}ms (attempt ${info.consecutiveFailures}).`,
2761
3233
  { ...info }
2762
3234
  );
3235
+ },
3236
+ onPermanentFailure: (info) => {
3237
+ const headline = `[crossdeck] Event batch DROPPED (status ${info.status}): ${info.lastError}. ${info.droppedCount} event(s) lost \u2014 check your publishable key + app config.`;
3238
+ console.error(headline);
3239
+ debug.emit(
3240
+ "sdk.flush_permanent_failure",
3241
+ headline,
3242
+ { ...info }
3243
+ );
2763
3244
  }
2764
3245
  });
2765
3246
  const deviceInfo = autoTrack.deviceInfo ? collectDeviceInfo({ appVersion: opts.appVersion ?? void 0 }) : opts.appVersion ? { appVersion: opts.appVersion } : {};
@@ -2826,8 +3307,20 @@ var CrossdeckClient = class {
2826
3307
  report: (err) => this.reportError(err),
2827
3308
  getContext: () => ({ ...this.state.errorContext }),
2828
3309
  getTags: () => ({ ...this.state.errorTags }),
2829
- beforeSend: this.state.errorBeforeSend,
2830
- isConsented: () => this.state.consent.errors
3310
+ // GETTER, not a captured value — `setErrorBeforeSend()` mutates
3311
+ // `state.errorBeforeSend` after init() and the tracker MUST
3312
+ // pick up the new hook on the next error. The pre-fix shape
3313
+ // (`beforeSend: this.state!.errorBeforeSend`) snapshotted
3314
+ // `null` at construction and made the customer's PII scrubber
3315
+ // silently inert. See error-capture.ts:ErrorTrackerOptions.beforeSend.
3316
+ beforeSend: () => this.state.errorBeforeSend,
3317
+ isConsented: () => this.state.consent.errors,
3318
+ // Derived from the configured baseUrl at init() time. Used by
3319
+ // the fetch / XHR wrappers to skip captureHttp on Crossdeck's
3320
+ // own requests — pre-fix the skip was hardcoded to
3321
+ // `api.cross-deck.com` and broke for customers on staging /
3322
+ // regional / self-hosted base URLs (recursive capture loop).
3323
+ selfHostname: extractSelfHostname(opts.baseUrl)
2831
3324
  });
2832
3325
  this.state.errors = tracker;
2833
3326
  tracker.install();
@@ -2906,13 +3399,10 @@ var CrossdeckClient = class {
2906
3399
  };
2907
3400
  if (options?.email) body.email = options.email;
2908
3401
  if (traits) body.traits = traits;
3402
+ s.entitlements.setUserKey(userId);
2909
3403
  const result = await s.http.request("POST", "/identity/alias", {
2910
3404
  body
2911
3405
  });
2912
- const priorCdcust = s.identity.crossdeckCustomerId;
2913
- if (priorCdcust && result.crossdeckCustomerId && priorCdcust !== result.crossdeckCustomerId) {
2914
- s.entitlements.clear();
2915
- }
2916
3406
  s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
2917
3407
  s.developerUserId = userId;
2918
3408
  return result;
@@ -3352,11 +3842,25 @@ var CrossdeckClient = class {
3352
3842
  message: "syncPurchases requires a signedTransactionInfo string from StoreKit 2."
3353
3843
  });
3354
3844
  }
3845
+ const rail = input.rail ?? "apple";
3846
+ const body = { ...input, rail };
3847
+ const idempotencyKey = deriveIdempotencyKeyForPurchase(body);
3355
3848
  const result = await s.http.request("POST", "/purchases/sync", {
3356
- body: { rail: input.rail ?? "apple", ...input }
3849
+ body,
3850
+ idempotencyKey
3357
3851
  });
3358
3852
  s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
3359
3853
  s.entitlements.setFromList(result.entitlements);
3854
+ try {
3855
+ const sourceProductId = result.entitlements[0]?.source.productId;
3856
+ const sourceSubscriptionId = result.entitlements[0]?.source.subscriptionId;
3857
+ const props = { rail };
3858
+ if (sourceProductId) props.productId = sourceProductId;
3859
+ if (sourceSubscriptionId) props.subscriptionId = sourceSubscriptionId;
3860
+ if (result.idempotent_replay) props.idempotent_replay = true;
3861
+ this.track("purchase.completed", props);
3862
+ } catch {
3863
+ }
3360
3864
  s.debug.emit(
3361
3865
  "sdk.purchase_evidence_sent",
3362
3866
  "StoreKit transaction forwarded. Waiting for backend verification.",
@@ -3412,13 +3916,15 @@ var CrossdeckClient = class {
3412
3916
  }
3413
3917
  this.state.autoTracker?.uninstall();
3414
3918
  this.state.identity.reset();
3415
- this.state.entitlements.clear();
3919
+ this.state.entitlements.clearAll();
3416
3920
  this.state.events.reset();
3417
3921
  this.state.superProps.clear();
3418
3922
  this.state.breadcrumbs.clear();
3419
3923
  this.state.errorContext = {};
3420
3924
  this.state.errorTags = {};
3421
3925
  this.state.developerUserId = null;
3926
+ this.state.lastServerTime = null;
3927
+ this.state.lastClientTime = null;
3422
3928
  if (this.state.autoTracker) {
3423
3929
  const tracker = new AutoTracker(
3424
3930
  this.state.options.autoTrack,
@@ -3547,7 +4053,9 @@ function isLocalHostname() {
3547
4053
  const hostname = w?.location?.hostname;
3548
4054
  if (!hostname) return false;
3549
4055
  if (hostname === "localhost" || hostname === "127.0.0.1") return true;
4056
+ if (hostname === "0.0.0.0") return true;
3550
4057
  if (hostname === "::1" || hostname === "[::1]") return true;
4058
+ if (/^\[?fe80::/i.test(hostname)) return true;
3551
4059
  if (hostname.endsWith(".local")) return true;
3552
4060
  if (/^10\./.test(hostname)) return true;
3553
4061
  if (/^192\.168\./.test(hostname)) return true;
@@ -3683,6 +4191,116 @@ var CROSSDECK_ERROR_CODES = Object.freeze([
3683
4191
  description: "The server returned a 2xx with an unparseable body.",
3684
4192
  resolution: "Likely a transient backend bug. Retry; if it persists, contact support with the requestId.",
3685
4193
  retryable: true
4194
+ },
4195
+ // ----- Backend-emitted codes (v1.4.0 Phase 6.2 backfill) -----
4196
+ // Mirror of backend/src/api/v1-errors.ts ApiErrorCode. A developer
4197
+ // hitting any of these on the wire can look them up via
4198
+ // getErrorCode(code) for a canonical remediation step instead of
4199
+ // hunting through Slack history.
4200
+ {
4201
+ code: "missing_api_key",
4202
+ type: "authentication_error",
4203
+ description: "No Authorization header (or Crossdeck-Api-Key header) on the request.",
4204
+ resolution: "Make sure Crossdeck.init({ publicKey }) was called with a cd_pub_\u2026 key before the request fired. Re-check your env-vars in CI / Docker if the key is empty at runtime.",
4205
+ retryable: false
4206
+ },
4207
+ {
4208
+ code: "invalid_api_key",
4209
+ type: "authentication_error",
4210
+ description: "The API key is malformed, unknown, or doesn't resolve to a project.",
4211
+ resolution: "Copy the key from your Crossdeck dashboard \u2192 API keys. Confirm prefix is cd_pub_test_ / cd_pub_live_ for client SDKs and cd_sk_test_ / cd_sk_live_ for the Node server SDK.",
4212
+ retryable: false
4213
+ },
4214
+ {
4215
+ code: "key_revoked",
4216
+ type: "authentication_error",
4217
+ description: "The API key was revoked in the Crossdeck dashboard.",
4218
+ resolution: "Mint a fresh key in the dashboard \u2192 API keys \u2192 Create new, swap it in, and redeploy. The revoked key cannot be reactivated.",
4219
+ retryable: false
4220
+ },
4221
+ {
4222
+ code: "identity_token_invalid",
4223
+ type: "authentication_error",
4224
+ description: "The Firebase / Apple / Google ID token supplied with the request didn't verify against the dashboard's configured signers.",
4225
+ resolution: "Refresh the token client-side (Firebase auth.currentUser.getIdToken(true)) and retry. If the failure persists, confirm the signer is registered under dashboard \u2192 Authentication \u2192 Identity sources.",
4226
+ retryable: true
4227
+ },
4228
+ {
4229
+ code: "origin_not_allowed",
4230
+ type: "permission_error",
4231
+ description: "The Origin header isn't in the project's Allowed origins list.",
4232
+ resolution: "Add the origin (e.g. https://app.example.com) under dashboard \u2192 Settings \u2192 Allowed origins. Wildcards like https://*.example.com are supported.",
4233
+ retryable: false
4234
+ },
4235
+ {
4236
+ code: "bundle_id_not_allowed",
4237
+ type: "permission_error",
4238
+ description: "The iOS bundle ID sent via X-Crossdeck-Bundle-Id isn't registered under this app's Apple identity lock.",
4239
+ resolution: "Add the bundle ID under dashboard \u2192 Apps \u2192 <your app> \u2192 iOS bundle IDs.",
4240
+ retryable: false
4241
+ },
4242
+ {
4243
+ code: "package_name_not_allowed",
4244
+ type: "permission_error",
4245
+ description: "The Android package name sent via X-Crossdeck-Package-Name isn't registered under this app's Android identity lock.",
4246
+ resolution: "Add the package name under dashboard \u2192 Apps \u2192 <your app> \u2192 Android package names.",
4247
+ retryable: false
4248
+ },
4249
+ {
4250
+ code: "env_mismatch",
4251
+ type: "permission_error",
4252
+ description: "The request env (inferred from key prefix) doesn't match the resolved app's configured env.",
4253
+ resolution: "Use a cd_pub_live_ / cd_sk_live_ key with a production app, cd_pub_test_ / cd_sk_test_ with a sandbox app. The two cannot cross.",
4254
+ retryable: false
4255
+ },
4256
+ {
4257
+ code: "idempotency_key_in_use",
4258
+ type: "invalid_request_error",
4259
+ description: "An Idempotency-Key was reused for a request with a different body (Stripe-grade contract).",
4260
+ resolution: "Generate a fresh key for a different transaction, or reuse the key only with the EXACT same body. The v1.4.0 SDKs derive keys deterministically from the body so this should never fire on SDK-managed calls.",
4261
+ retryable: false
4262
+ },
4263
+ {
4264
+ code: "rate_limited",
4265
+ type: "rate_limit_error",
4266
+ description: "Request rate exceeded the project's per-second cap.",
4267
+ resolution: "Honour the Retry-After header \u2014 the SDK does this automatically on managed retries. If you're hitting it from a custom code path, throttle to <100 req/s/key.",
4268
+ retryable: true
4269
+ },
4270
+ {
4271
+ code: "internal_error",
4272
+ type: "internal_error",
4273
+ description: "Server-side issue. Safe to retry with backoff.",
4274
+ resolution: "The SDK retries automatically. If your code paths through to this error, contact support with the requestId from the response envelope.",
4275
+ retryable: true
4276
+ },
4277
+ {
4278
+ code: "google_not_supported",
4279
+ type: "invalid_request_error",
4280
+ description: "POST /purchases/sync with rail=google is gated until the Play Developer API reconciliation worker ships.",
4281
+ resolution: "Until v1.5+, Google Play purchases verify via Real-time Developer Notifications. The SDK auto-track path handles this transparently for Android consumers.",
4282
+ retryable: false
4283
+ },
4284
+ {
4285
+ code: "stripe_not_supported",
4286
+ type: "invalid_request_error",
4287
+ description: "POST /purchases/sync with rail=stripe is unsupported \u2014 Stripe Checkout's redirect flow uses platform webhooks instead.",
4288
+ resolution: "Wire Stripe via the standard Checkout / Customer Portal flow; Crossdeck reconciles via the platform webhook automatically. No SDK call needed.",
4289
+ retryable: false
4290
+ },
4291
+ {
4292
+ code: "missing_required_param",
4293
+ type: "invalid_request_error",
4294
+ description: "A required field is absent from the request body.",
4295
+ resolution: "Inspect the error.message \u2014 the missing field name is included verbatim. Refer to the SDK's TypeScript types for the canonical request shape.",
4296
+ retryable: false
4297
+ },
4298
+ {
4299
+ code: "invalid_param_value",
4300
+ type: "invalid_request_error",
4301
+ description: "A field is present but the value failed validation (wrong shape, wrong length, wrong enum value).",
4302
+ resolution: "Read error.message for the specific field + reason. SDK-managed call sites should never emit this \u2014 file a bug if you do.",
4303
+ retryable: false
3686
4304
  }
3687
4305
  ]);
3688
4306
  function getErrorCode(code) {