@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.mjs CHANGED
@@ -63,9 +63,11 @@ function typeMapForStatus(status) {
63
63
  return "internal_error";
64
64
  }
65
65
 
66
- // src/http.ts
66
+ // src/_version.ts
67
+ var SDK_VERSION = "1.4.2";
67
68
  var SDK_NAME = "@cross-deck/web";
68
- var SDK_VERSION = "1.1.0";
69
+
70
+ // src/http.ts
69
71
  var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
70
72
  var DEFAULT_TIMEOUT_MS = 15e3;
71
73
  var HttpClient = class {
@@ -312,29 +314,258 @@ function randomChars(count) {
312
314
  return out.join("");
313
315
  }
314
316
 
317
+ // src/hash.ts
318
+ var K = new Uint32Array([
319
+ 1116352408,
320
+ 1899447441,
321
+ 3049323471,
322
+ 3921009573,
323
+ 961987163,
324
+ 1508970993,
325
+ 2453635748,
326
+ 2870763221,
327
+ 3624381080,
328
+ 310598401,
329
+ 607225278,
330
+ 1426881987,
331
+ 1925078388,
332
+ 2162078206,
333
+ 2614888103,
334
+ 3248222580,
335
+ 3835390401,
336
+ 4022224774,
337
+ 264347078,
338
+ 604807628,
339
+ 770255983,
340
+ 1249150122,
341
+ 1555081692,
342
+ 1996064986,
343
+ 2554220882,
344
+ 2821834349,
345
+ 2952996808,
346
+ 3210313671,
347
+ 3336571891,
348
+ 3584528711,
349
+ 113926993,
350
+ 338241895,
351
+ 666307205,
352
+ 773529912,
353
+ 1294757372,
354
+ 1396182291,
355
+ 1695183700,
356
+ 1986661051,
357
+ 2177026350,
358
+ 2456956037,
359
+ 2730485921,
360
+ 2820302411,
361
+ 3259730800,
362
+ 3345764771,
363
+ 3516065817,
364
+ 3600352804,
365
+ 4094571909,
366
+ 275423344,
367
+ 430227734,
368
+ 506948616,
369
+ 659060556,
370
+ 883997877,
371
+ 958139571,
372
+ 1322822218,
373
+ 1537002063,
374
+ 1747873779,
375
+ 1955562222,
376
+ 2024104815,
377
+ 2227730452,
378
+ 2361852424,
379
+ 2428436474,
380
+ 2756734187,
381
+ 3204031479,
382
+ 3329325298
383
+ ]);
384
+ function utf8Bytes(input) {
385
+ if (typeof TextEncoder !== "undefined") {
386
+ return new TextEncoder().encode(input);
387
+ }
388
+ const out = [];
389
+ for (let i = 0; i < input.length; i++) {
390
+ let codePoint = input.charCodeAt(i);
391
+ if (codePoint >= 55296 && codePoint <= 56319 && i + 1 < input.length) {
392
+ const next = input.charCodeAt(i + 1);
393
+ if (next >= 56320 && next <= 57343) {
394
+ codePoint = 65536 + (codePoint - 55296 << 10) + (next - 56320);
395
+ i++;
396
+ }
397
+ }
398
+ if (codePoint < 128) {
399
+ out.push(codePoint);
400
+ } else if (codePoint < 2048) {
401
+ out.push(192 | codePoint >> 6);
402
+ out.push(128 | codePoint & 63);
403
+ } else if (codePoint < 65536) {
404
+ out.push(224 | codePoint >> 12);
405
+ out.push(128 | codePoint >> 6 & 63);
406
+ out.push(128 | codePoint & 63);
407
+ } else {
408
+ out.push(240 | codePoint >> 18);
409
+ out.push(128 | codePoint >> 12 & 63);
410
+ out.push(128 | codePoint >> 6 & 63);
411
+ out.push(128 | codePoint & 63);
412
+ }
413
+ }
414
+ return new Uint8Array(out);
415
+ }
416
+ function sha256Hex(input) {
417
+ const bytes = utf8Bytes(input);
418
+ const bitLength = bytes.length * 8;
419
+ const blockCount = Math.floor((bytes.length + 9 + 63) / 64);
420
+ const padded = new Uint8Array(blockCount * 64);
421
+ padded.set(bytes);
422
+ padded[bytes.length] = 128;
423
+ const high = Math.floor(bitLength / 4294967296);
424
+ const low = bitLength >>> 0;
425
+ const lenOffset = padded.length - 8;
426
+ padded[lenOffset + 0] = high >>> 24 & 255;
427
+ padded[lenOffset + 1] = high >>> 16 & 255;
428
+ padded[lenOffset + 2] = high >>> 8 & 255;
429
+ padded[lenOffset + 3] = high & 255;
430
+ padded[lenOffset + 4] = low >>> 24 & 255;
431
+ padded[lenOffset + 5] = low >>> 16 & 255;
432
+ padded[lenOffset + 6] = low >>> 8 & 255;
433
+ padded[lenOffset + 7] = low & 255;
434
+ const H = new Uint32Array([
435
+ 1779033703,
436
+ 3144134277,
437
+ 1013904242,
438
+ 2773480762,
439
+ 1359893119,
440
+ 2600822924,
441
+ 528734635,
442
+ 1541459225
443
+ ]);
444
+ const W = new Uint32Array(64);
445
+ for (let block = 0; block < blockCount; block++) {
446
+ const offset = block * 64;
447
+ for (let t = 0; t < 16; t++) {
448
+ 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;
449
+ }
450
+ for (let t = 16; t < 64; t++) {
451
+ const w15 = W[t - 15];
452
+ const w2 = W[t - 2];
453
+ const s0 = (w15 >>> 7 | w15 << 25) ^ (w15 >>> 18 | w15 << 14) ^ w15 >>> 3;
454
+ const s1 = (w2 >>> 17 | w2 << 15) ^ (w2 >>> 19 | w2 << 13) ^ w2 >>> 10;
455
+ W[t] = W[t - 16] + s0 + W[t - 7] + s1 >>> 0;
456
+ }
457
+ let a = H[0], b = H[1], c = H[2], d = H[3];
458
+ let e = H[4], f = H[5], g = H[6], h = H[7];
459
+ for (let t = 0; t < 64; t++) {
460
+ const S1 = (e >>> 6 | e << 26) ^ (e >>> 11 | e << 21) ^ (e >>> 25 | e << 7);
461
+ const ch = e & f ^ ~e & g;
462
+ const temp1 = h + S1 + ch + K[t] + W[t] >>> 0;
463
+ const S0 = (a >>> 2 | a << 30) ^ (a >>> 13 | a << 19) ^ (a >>> 22 | a << 10);
464
+ const maj = a & b ^ a & c ^ b & c;
465
+ const temp2 = S0 + maj >>> 0;
466
+ h = g;
467
+ g = f;
468
+ f = e;
469
+ e = d + temp1 >>> 0;
470
+ d = c;
471
+ c = b;
472
+ b = a;
473
+ a = temp1 + temp2 >>> 0;
474
+ }
475
+ H[0] = H[0] + a >>> 0;
476
+ H[1] = H[1] + b >>> 0;
477
+ H[2] = H[2] + c >>> 0;
478
+ H[3] = H[3] + d >>> 0;
479
+ H[4] = H[4] + e >>> 0;
480
+ H[5] = H[5] + f >>> 0;
481
+ H[6] = H[6] + g >>> 0;
482
+ H[7] = H[7] + h >>> 0;
483
+ }
484
+ let hex = "";
485
+ for (let i = 0; i < 8; i++) {
486
+ hex += H[i].toString(16).padStart(8, "0");
487
+ }
488
+ return hex;
489
+ }
490
+
315
491
  // src/entitlement-cache.ts
316
492
  var DEFAULT_STALE_AFTER_MS = 24 * 60 * 60 * 1e3;
317
- var EntitlementCache = class {
493
+ var ANON_SUFFIX = "_anon";
494
+ var INDEX_SUFFIX = "_index";
495
+ var EntitlementCache = class _EntitlementCache {
318
496
  /**
319
- * @param storage Device storage adapter. When omitted (tests) or
320
- * a MemoryStorage (strict-consent / no-persistence
321
- * mode) the cache is session-only — durability is
322
- * simply absent, never wrong.
323
- * @param storageKey Full key the persisted blob lives under.
324
- * @param staleAfterMs Age past which last-known-good is flagged stale
325
- * even without a failed refresh. Default 24h.
497
+ * @param storage Device storage adapter. When omitted (tests) or
498
+ * a MemoryStorage (strict-consent / no-persistence
499
+ * mode) the cache is session-only — durability is
500
+ * simply absent, never wrong.
501
+ * @param storageKeyPrefix Prefix used to derive per-user storage keys
502
+ * (`<prefix>:<sha256(userId)>`). Default
503
+ * `crossdeck:entitlements`. The trailing user
504
+ * suffix is filled at identify() / reset()
505
+ * time — see [[setUserKey]].
506
+ * @param staleAfterMs Age past which last-known-good is flagged stale
507
+ * even without a failed refresh. Default 24h.
326
508
  */
327
- constructor(storage, storageKey = "crossdeck:entitlements", staleAfterMs = DEFAULT_STALE_AFTER_MS) {
509
+ constructor(storage, storageKeyPrefix = "crossdeck:entitlements", staleAfterMs = DEFAULT_STALE_AFTER_MS) {
328
510
  this.all = [];
329
511
  this.lastUpdated = 0;
330
512
  this.lastRefreshFailedAt = 0;
331
513
  this.listeners = /* @__PURE__ */ new Set();
332
514
  this.listenerErrorCount = 0;
515
+ this.currentSuffix = ANON_SUFFIX;
333
516
  this.storage = storage;
334
- this.storageKey = storageKey;
517
+ this.storageKeyPrefix = storageKeyPrefix;
335
518
  this.staleAfterMs = staleAfterMs;
336
519
  this.hydrate();
337
520
  }
521
+ /** The full storage key the current-user blob is persisted under. */
522
+ get storageKey() {
523
+ return `${this.storageKeyPrefix}:${this.currentSuffix}`;
524
+ }
525
+ /** Key of the index blob — a JSON array of every suffix we've
526
+ * written. Used by clearAll() to scope a logout-wipe. */
527
+ get indexKey() {
528
+ return `${this.storageKeyPrefix}:${INDEX_SUFFIX}`;
529
+ }
530
+ /** Derive a stable suffix for a developerUserId via SHA-256. The
531
+ * raw userId never lands in the storage key — protects against
532
+ * accidentally leaking email-style identifiers through DevTools
533
+ * inspection. Pass `null` to switch back to the anonymous slot. */
534
+ static suffixForUserId(userId) {
535
+ if (userId == null || userId === "") return ANON_SUFFIX;
536
+ return sha256Hex(userId);
537
+ }
538
+ /**
539
+ * Switch the cache to a different user's storage slot. Bank-grade
540
+ * three-layer isolation:
541
+ * (a) Physical key separation — `<prefix>:<sha256(userId)>` so
542
+ * a user-switch can't physically read prior user's data
543
+ * even if the in-memory clear was skipped.
544
+ * (b) Unconditional in-memory clear — invoked whenever the
545
+ * active suffix changes, even on same-id re-identify.
546
+ * (c) Re-hydrate from the new slot — a returning user observes
547
+ * their last-known-good cache from storage immediately.
548
+ *
549
+ * Caller (identify() / reset()) MUST invoke this BEFORE the next
550
+ * setFromList() so the write lands under the right key.
551
+ */
552
+ setUserKey(userId) {
553
+ const nextSuffix = _EntitlementCache.suffixForUserId(userId);
554
+ if (nextSuffix === this.currentSuffix) {
555
+ this.all = [];
556
+ this.lastUpdated = 0;
557
+ this.lastRefreshFailedAt = 0;
558
+ this.notify();
559
+ this.hydrate();
560
+ return;
561
+ }
562
+ this.currentSuffix = nextSuffix;
563
+ this.all = [];
564
+ this.lastUpdated = 0;
565
+ this.lastRefreshFailedAt = 0;
566
+ this.hydrate();
567
+ this.notify();
568
+ }
338
569
  /**
339
570
  * Sync read — true iff the entitlement is currently granting access.
340
571
  *
@@ -404,12 +635,13 @@ var EntitlementCache = class {
404
635
  this.lastUpdated = Date.now();
405
636
  this.lastRefreshFailedAt = 0;
406
637
  this.persist();
638
+ this.recordSuffixInIndex(this.currentSuffix);
407
639
  this.notify();
408
640
  }
409
641
  /**
410
- * Wipe used on reset() (logout) and on an identity switch. Clears
411
- * BOTH memory and durable storage so a prior user's entitlements can
412
- * never leak to the next person on this device.
642
+ * Wipe the CURRENT user's slot. Used internally when a single
643
+ * user's cache needs to be invalidated without affecting other
644
+ * persisted slots. The full-logout path is [[clearAll]].
413
645
  */
414
646
  clear() {
415
647
  this.all = [];
@@ -421,6 +653,40 @@ var EntitlementCache = class {
421
653
  } catch {
422
654
  }
423
655
  }
656
+ this.removeSuffixFromIndex(this.currentSuffix);
657
+ this.notify();
658
+ }
659
+ /**
660
+ * Logout-grade wipe — bank-grade contract: removes EVERY per-user
661
+ * entitlement slot the SDK has ever written on this device, then
662
+ * clears the index. Used by `Crossdeck.reset()` so a logout on a
663
+ * shared device can never leave another user's entitlements
664
+ * readable (layer (c) of the v1.4.0 isolation fix).
665
+ *
666
+ * After clearAll(), the cache is back to anonymous + empty.
667
+ */
668
+ clearAll() {
669
+ this.all = [];
670
+ this.lastUpdated = 0;
671
+ this.lastRefreshFailedAt = 0;
672
+ this.currentSuffix = ANON_SUFFIX;
673
+ if (this.storage) {
674
+ const suffixes = this.readIndex();
675
+ for (const suffix of suffixes) {
676
+ try {
677
+ this.storage.removeItem(`${this.storageKeyPrefix}:${suffix}`);
678
+ } catch {
679
+ }
680
+ }
681
+ try {
682
+ this.storage.removeItem(`${this.storageKeyPrefix}:${ANON_SUFFIX}`);
683
+ } catch {
684
+ }
685
+ try {
686
+ this.storage.removeItem(this.indexKey);
687
+ } catch {
688
+ }
689
+ }
424
690
  this.notify();
425
691
  }
426
692
  /**
@@ -470,6 +736,47 @@ var EntitlementCache = class {
470
736
  } catch {
471
737
  }
472
738
  }
739
+ /** Read the index of all per-user suffixes the SDK has written. */
740
+ readIndex() {
741
+ if (!this.storage) return [];
742
+ try {
743
+ const raw = this.storage.getItem(this.indexKey);
744
+ if (!raw) return [];
745
+ const parsed = JSON.parse(raw);
746
+ if (Array.isArray(parsed)) {
747
+ return parsed.filter((x) => typeof x === "string");
748
+ }
749
+ return [];
750
+ } catch {
751
+ return [];
752
+ }
753
+ }
754
+ /** Add a suffix to the persisted index. Idempotent. */
755
+ recordSuffixInIndex(suffix) {
756
+ if (!this.storage) return;
757
+ const existing = this.readIndex();
758
+ if (existing.includes(suffix)) return;
759
+ existing.push(suffix);
760
+ try {
761
+ this.storage.setItem(this.indexKey, JSON.stringify(existing));
762
+ } catch {
763
+ }
764
+ }
765
+ /** Remove a suffix from the persisted index. No-op if absent. */
766
+ removeSuffixFromIndex(suffix) {
767
+ if (!this.storage) return;
768
+ const existing = this.readIndex();
769
+ const next = existing.filter((s) => s !== suffix);
770
+ if (next.length === existing.length) return;
771
+ try {
772
+ if (next.length === 0) {
773
+ this.storage.removeItem(this.indexKey);
774
+ } else {
775
+ this.storage.setItem(this.indexKey, JSON.stringify(next));
776
+ }
777
+ } catch {
778
+ }
779
+ }
473
780
  notify() {
474
781
  if (this.listeners.size === 0) return;
475
782
  const snapshot = this.all.slice();
@@ -484,6 +791,34 @@ var EntitlementCache = class {
484
791
  }
485
792
  };
486
793
 
794
+ // src/idempotency-key.ts
795
+ function formatAsUuid(hex) {
796
+ return [
797
+ hex.slice(0, 8),
798
+ hex.slice(8, 12),
799
+ hex.slice(12, 16),
800
+ hex.slice(16, 20),
801
+ hex.slice(20, 32)
802
+ ].join("-");
803
+ }
804
+ function deriveIdempotencyKeyForPurchase(body) {
805
+ let identifier;
806
+ if (body.rail === "apple") {
807
+ identifier = body.signedTransactionInfo ?? "";
808
+ } else if (body.rail === "google") {
809
+ identifier = body.purchaseToken ?? "";
810
+ } else {
811
+ identifier = "";
812
+ }
813
+ if (!identifier) {
814
+ throw new Error(
815
+ `deriveIdempotencyKeyForPurchase: no stable identifier in body (rail=${body.rail}). Apple needs signedTransactionInfo; Google needs purchaseToken.`
816
+ );
817
+ }
818
+ const namespaced = `crossdeck:purchases/sync:${body.rail}:${identifier}`;
819
+ return formatAsUuid(sha256Hex(namespaced));
820
+ }
821
+
487
822
  // src/retry-policy.ts
488
823
  var DEFAULT_BASE = 1e3;
489
824
  var DEFAULT_MAX = 6e4;
@@ -496,8 +831,10 @@ function computeNextDelay(attempts, retryAfterMs, options = {}, random = Math.ra
496
831
  const safeAttempts = Math.min(attempts, 30);
497
832
  const ceiling = Math.min(max, base * Math.pow(factor, safeAttempts));
498
833
  const jittered = ceiling * random();
499
- if (retryAfterMs !== void 0 && retryAfterMs > jittered) {
500
- return Math.min(max, retryAfterMs);
834
+ if (retryAfterMs !== void 0) {
835
+ const ABSOLUTE_MAX_MS = 24 * 60 * 60 * 1e3;
836
+ const honoured = Math.min(ABSOLUTE_MAX_MS, retryAfterMs);
837
+ if (honoured > jittered) return honoured;
501
838
  }
502
839
  return Math.max(0, Math.round(jittered));
503
840
  }
@@ -532,6 +869,27 @@ var EventQueue = class {
532
869
  constructor(cfg) {
533
870
  this.cfg = cfg;
534
871
  this.buffer = [];
872
+ /**
873
+ * In-flight events that have been spliced from `buffer` for the
874
+ * current batch but haven't yet been confirmed (success or permanent
875
+ * failure). On a retry-driven re-flush we re-use this slot alongside
876
+ * `pendingBatchId` so the Stripe-style Idempotency-Key is preserved
877
+ * across retries of the SAME logical batch.
878
+ *
879
+ * Pre-fix the splice cleared the buffer AND we immediately
880
+ * `persistent.save(empty)` BEFORE awaiting the network call — a
881
+ * crash mid-flight wiped the persisted blob and the batch was lost
882
+ * with no replay on next boot. Now the persisted blob always carries
883
+ * `[...pendingBatch, ...buffer]` so the in-flight events survive
884
+ * until the server confirms them.
885
+ *
886
+ * Pre-fix every retry attempt also minted a NEW batchId via
887
+ * `splice + mintBatchId`, defeating the backend's
888
+ * `Idempotency-Key` dedup. Reuse via this slot brings web in
889
+ * lockstep with node (which already had the field).
890
+ */
891
+ this.pendingBatch = null;
892
+ this.pendingBatchId = null;
535
893
  this.dropped = 0;
536
894
  this.inFlight = 0;
537
895
  this.lastFlushAt = 0;
@@ -564,7 +922,7 @@ var EventQueue = class {
564
922
  this.cfg.onDrop?.(overflow);
565
923
  }
566
924
  this.cfg.onBufferChange?.(this.buffer.length);
567
- this.persistent?.save(this.buffer);
925
+ this.persistAll();
568
926
  if (this.buffer.length >= this.cfg.batchSize) {
569
927
  void this.flush();
570
928
  } else {
@@ -573,22 +931,44 @@ var EventQueue = class {
573
931
  }
574
932
  /**
575
933
  * Flush the buffer to /v1/events. Resolves when the network call
576
- * completes (success or failure). On failure, events stay in the
577
- * buffer for the next scheduled retry.
934
+ * completes (success or failure). On a retryable failure the batch
935
+ * stays in `pendingBatch` for the next scheduled retry — the SAME
936
+ * batch with the SAME `Idempotency-Key` is re-sent (Stripe pattern).
937
+ *
938
+ * Three terminal states from one call:
939
+ * - 2xx success: pendingBatch cleared, persisted state collapses to
940
+ * just `buffer` (any new events that arrived during in-flight).
941
+ * - 4xx permanent (except 408/429): pendingBatch DROPPED, `dropped`
942
+ * counter increments, a `permanent_failure` signal fires. The
943
+ * server is telling us our request is malformed / our key is
944
+ * revoked / we lack permission — retrying with the same key
945
+ * forever just grows the queue while the customer thinks events
946
+ * are landing.
947
+ * - 5xx / network / 408 / 429: pendingBatch + batchId stay; backoff
948
+ * schedules a retry; the next `flush()` re-uses both.
578
949
  *
579
950
  * `options.keepalive` marks the underlying fetch as keepalive so the
580
- * browser keeps the request alive past page unload. Use this for
581
- * terminal flushes (pagehide / visibilitychange→hidden / beforeunload).
951
+ * browser keeps the request alive past page unload. Use for terminal
952
+ * flushes (pagehide / visibilitychange→hidden / beforeunload).
582
953
  */
583
954
  async flush(options = {}) {
584
- if (this.buffer.length === 0) return null;
955
+ let batch;
956
+ let batchId;
957
+ if (this.pendingBatch !== null && this.pendingBatchId !== null) {
958
+ batch = this.pendingBatch;
959
+ batchId = this.pendingBatchId;
960
+ } else {
961
+ if (this.buffer.length === 0) return null;
962
+ batch = this.buffer.splice(0);
963
+ batchId = this.mintBatchId();
964
+ this.pendingBatch = batch;
965
+ this.pendingBatchId = batchId;
966
+ this.inFlight += batch.length;
967
+ this.cfg.onBufferChange?.(this.buffer.length);
968
+ this.persistAll();
969
+ }
585
970
  this.cancelTimerIfSet();
586
971
  this.nextRetryAt = null;
587
- const batch = this.buffer.splice(0);
588
- const batchId = this.mintBatchId();
589
- this.inFlight += batch.length;
590
- this.persistent?.save(this.buffer);
591
- this.cfg.onBufferChange?.(this.buffer.length);
592
972
  try {
593
973
  const env = this.cfg.envelope();
594
974
  const result = await this.cfg.http.request("POST", "/events", {
@@ -607,20 +987,33 @@ var EventQueue = class {
607
987
  this.lastFlushAt = Date.now();
608
988
  this.lastError = null;
609
989
  this.inFlight -= batch.length;
990
+ this.pendingBatch = null;
991
+ this.pendingBatchId = null;
610
992
  this.retry.recordSuccess();
611
- this.persistent?.save(this.buffer);
993
+ this.persistAll();
612
994
  if (!this.firstFlushFired) {
613
995
  this.firstFlushFired = true;
614
996
  this.cfg.onFirstFlushSuccess?.();
615
997
  }
616
998
  return result;
617
999
  } catch (err) {
618
- this.buffer.unshift(...batch);
619
- this.inFlight -= batch.length;
620
1000
  const message = err instanceof Error ? err.message : String(err);
621
1001
  this.lastError = message;
622
- this.persistent?.save(this.buffer);
623
- this.cfg.onBufferChange?.(this.buffer.length);
1002
+ if (isPermanent4xx(err)) {
1003
+ const droppedCount = batch.length;
1004
+ this.pendingBatch = null;
1005
+ this.pendingBatchId = null;
1006
+ this.inFlight -= droppedCount;
1007
+ this.dropped += droppedCount;
1008
+ this.persistAll();
1009
+ this.cfg.onDrop?.(droppedCount);
1010
+ this.cfg.onPermanentFailure?.({
1011
+ status: err.status ?? 0,
1012
+ droppedCount,
1013
+ lastError: message
1014
+ });
1015
+ return null;
1016
+ }
624
1017
  const retryAfterMs = extractRetryAfterMs(err);
625
1018
  const delay = this.retry.nextDelay(retryAfterMs);
626
1019
  this.scheduleRetry(delay);
@@ -638,6 +1031,8 @@ var EventQueue = class {
638
1031
  this.cancelTimerIfSet();
639
1032
  this.nextRetryAt = null;
640
1033
  this.buffer = [];
1034
+ this.pendingBatch = null;
1035
+ this.pendingBatchId = null;
641
1036
  this.dropped = 0;
642
1037
  this.inFlight = 0;
643
1038
  this.lastError = null;
@@ -647,6 +1042,10 @@ var EventQueue = class {
647
1042
  }
648
1043
  getStats() {
649
1044
  return {
1045
+ // `buffered` counts events waiting for their FIRST flush. The
1046
+ // in-flight pendingBatch (retrying) is tracked separately via
1047
+ // `inFlight` — surfacing both lets diagnostics show "we have
1048
+ // events stuck retrying" distinct from "new events arriving".
650
1049
  buffered: this.buffer.length,
651
1050
  dropped: this.dropped,
652
1051
  inFlight: this.inFlight,
@@ -656,6 +1055,29 @@ var EventQueue = class {
656
1055
  nextRetryAt: this.nextRetryAt
657
1056
  };
658
1057
  }
1058
+ /**
1059
+ * The Idempotency-Key of the in-flight pending batch (if any).
1060
+ * Exposed for testing the Stripe-style retry-reuse contract.
1061
+ * Production callers don't need this.
1062
+ */
1063
+ get pendingIdempotencyKey() {
1064
+ return this.pendingBatchId;
1065
+ }
1066
+ /**
1067
+ * Persist `[...pendingBatch, ...buffer]` — the full set of
1068
+ * not-yet-confirmed events. The next boot rehydrates this exact set
1069
+ * into `buffer` and replays. The server dedups via eventId
1070
+ * (ReplacingMergeTree on the warehouse side), so re-sending an event
1071
+ * that may have already landed is safe.
1072
+ */
1073
+ persistAll() {
1074
+ if (!this.persistent) return;
1075
+ if (this.pendingBatch === null) {
1076
+ this.persistent.save(this.buffer);
1077
+ return;
1078
+ }
1079
+ this.persistent.save([...this.pendingBatch, ...this.buffer]);
1080
+ }
659
1081
  // ---------- internal scheduling ----------
660
1082
  scheduleIdleFlush() {
661
1083
  this.cancelTimerIfSet();
@@ -689,6 +1111,14 @@ function extractRetryAfterMs(err) {
689
1111
  }
690
1112
  return void 0;
691
1113
  }
1114
+ function isPermanent4xx(err) {
1115
+ if (!err || typeof err !== "object") return false;
1116
+ const status = err.status;
1117
+ if (typeof status !== "number" || !Number.isFinite(status)) return false;
1118
+ if (status < 400 || status >= 500) return false;
1119
+ if (status === 408 || status === 429) return false;
1120
+ return true;
1121
+ }
692
1122
  function defaultScheduler(fn, ms) {
693
1123
  const id = setTimeout(fn, ms);
694
1124
  if (typeof id.unref === "function") {
@@ -1030,6 +1460,7 @@ var AutoTracker = class {
1030
1460
  /** Exposed for tests + consumers that want to reset the session manually. */
1031
1461
  resetSession() {
1032
1462
  if (this.session && !this.session.endedSent) this.emitSessionEnd();
1463
+ this.pageviewId = null;
1033
1464
  this.session = this.startNewSession();
1034
1465
  this.emitSessionStart();
1035
1466
  }
@@ -1066,6 +1497,7 @@ var AutoTracker = class {
1066
1497
  const hiddenFor = this.session.hiddenAt ? Date.now() - this.session.hiddenAt : 0;
1067
1498
  if (hiddenFor >= SESSION_RESUME_THRESHOLD_MS) {
1068
1499
  this.emitSessionEnd();
1500
+ this.pageviewId = null;
1069
1501
  this.session = this.startNewSession();
1070
1502
  this.emitSessionStart();
1071
1503
  } else {
@@ -1439,7 +1871,7 @@ function validateEventProperties(input, options = {}) {
1439
1871
  const maxStringLength = options.maxStringLength ?? DEFAULT_MAX_STRING;
1440
1872
  const maxBatchPropertyBytes = options.maxBatchPropertyBytes ?? DEFAULT_MAX_BYTES;
1441
1873
  const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
1442
- const seen = /* @__PURE__ */ new WeakSet();
1874
+ const seen = /* @__PURE__ */ new Set();
1443
1875
  const visit = (value, key, depth) => {
1444
1876
  if (depth > maxDepth) {
1445
1877
  warnings.push({ kind: "depth_exceeded", key });
@@ -1527,6 +1959,7 @@ function validateEventProperties(input, options = {}) {
1527
1959
  const result = visit(value[i], `${key}[${i}]`, depth + 1);
1528
1960
  if (result.keep) out.push(result.value);
1529
1961
  }
1962
+ seen.delete(value);
1530
1963
  return { keep: true, value: out };
1531
1964
  }
1532
1965
  if (t === "object") {
@@ -1541,6 +1974,7 @@ function validateEventProperties(input, options = {}) {
1541
1974
  const result = visit(obj[k], `${key}.${k}`, depth + 1);
1542
1975
  if (result.keep) out[k] = result.value;
1543
1976
  }
1977
+ seen.delete(obj);
1544
1978
  return { keep: true, value: out };
1545
1979
  }
1546
1980
  warnings.push({ kind: "non_serialisable", key });
@@ -1883,35 +2317,27 @@ var ConsentManager = class {
1883
2317
  };
1884
2318
  var EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
1885
2319
  var CARD_PATTERN = /\b\d(?:[ -]?\d){12,18}\b/g;
1886
- var REPLACEMENT_EMAIL = "[email]";
1887
- var REPLACEMENT_CARD = "[card]";
2320
+ var REPLACEMENT_EMAIL = "<email>";
2321
+ var REPLACEMENT_CARD = "<card>";
1888
2322
  function scrubPii(value) {
1889
2323
  if (!value) return value;
1890
- let out = value;
1891
- if (EMAIL_PATTERN.test(out)) {
1892
- out = out.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL);
1893
- }
1894
- EMAIL_PATTERN.lastIndex = 0;
1895
- if (CARD_PATTERN.test(out)) {
1896
- out = out.replace(CARD_PATTERN, REPLACEMENT_CARD);
1897
- }
1898
- CARD_PATTERN.lastIndex = 0;
1899
- return out;
2324
+ return value.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL).replace(CARD_PATTERN, REPLACEMENT_CARD);
1900
2325
  }
1901
2326
  function scrubPiiFromProperties(properties) {
1902
2327
  const out = {};
1903
2328
  for (const k of Object.keys(properties)) {
1904
- const v = properties[k];
1905
- if (typeof v === "string") {
1906
- out[k] = scrubPii(v);
1907
- } else if (Array.isArray(v)) {
1908
- out[k] = v.map((item) => typeof item === "string" ? scrubPii(item) : item);
1909
- } else {
1910
- out[k] = v;
1911
- }
2329
+ out[k] = scrubValue(properties[k]);
1912
2330
  }
1913
2331
  return out;
1914
2332
  }
2333
+ function scrubValue(v) {
2334
+ if (typeof v === "string") return scrubPii(v);
2335
+ if (Array.isArray(v)) return v.map(scrubValue);
2336
+ if (v && typeof v === "object" && v.constructor === Object) {
2337
+ return scrubPiiFromProperties(v);
2338
+ }
2339
+ return v;
2340
+ }
1915
2341
 
1916
2342
  // src/breadcrumbs.ts
1917
2343
  var BreadcrumbBuffer = class {
@@ -2201,16 +2627,18 @@ var ErrorTracker = class {
2201
2627
  const url = typeof input === "string" ? input : input?.url ?? "";
2202
2628
  const method = (init.method || "GET").toUpperCase();
2203
2629
  const start = Date.now();
2204
- this.opts.breadcrumbs.add({
2205
- timestamp: start,
2206
- category: "http",
2207
- message: `${method} ${url}`,
2208
- data: { url, method }
2209
- });
2630
+ if (!isSelfRequest(url, this.opts.selfHostname)) {
2631
+ this.opts.breadcrumbs.add({
2632
+ timestamp: start,
2633
+ category: "http",
2634
+ message: `${method} ${url}`,
2635
+ data: { url, method }
2636
+ });
2637
+ }
2210
2638
  try {
2211
2639
  const response = await origFetch(...args);
2212
2640
  if (response.status >= 500 && this.opts.isConsented()) {
2213
- if (!url.includes("api.cross-deck.com")) {
2641
+ if (!isSelfRequest(url, this.opts.selfHostname)) {
2214
2642
  this.captureHttp({
2215
2643
  url,
2216
2644
  method,
@@ -2259,7 +2687,7 @@ var ErrorTracker = class {
2259
2687
  try {
2260
2688
  if (xhr.status >= 500 && tracker.opts.isConsented()) {
2261
2689
  const url = xhr._cdUrl ?? "";
2262
- if (!url.includes("api.cross-deck.com")) {
2690
+ if (!isSelfRequest(url, tracker.opts.selfHostname)) {
2263
2691
  tracker.captureHttp({
2264
2692
  url,
2265
2693
  method: (xhr._cdMethod ?? "GET").toUpperCase(),
@@ -2419,9 +2847,10 @@ var ErrorTracker = class {
2419
2847
  if (!this.passesSample(err)) return;
2420
2848
  if (!this.passesRateLimit(err)) return;
2421
2849
  let finalErr = err;
2422
- if (this.opts.beforeSend) {
2850
+ const hook = this.opts.beforeSend?.();
2851
+ if (hook) {
2423
2852
  try {
2424
- finalErr = this.opts.beforeSend(err);
2853
+ finalErr = hook(err);
2425
2854
  } catch {
2426
2855
  finalErr = err;
2427
2856
  }
@@ -2603,6 +3032,22 @@ function safeClone(v) {
2603
3032
  function safeStringify2(v) {
2604
3033
  return coerceErrorPayload(v).message;
2605
3034
  }
3035
+ function extractSelfHostname(baseUrl) {
3036
+ if (!baseUrl || typeof baseUrl !== "string") return null;
3037
+ try {
3038
+ return new URL(baseUrl).hostname.toLowerCase();
3039
+ } catch {
3040
+ return null;
3041
+ }
3042
+ }
3043
+ function isSelfRequest(requestUrl, selfHostname) {
3044
+ if (!selfHostname || !requestUrl) return false;
3045
+ try {
3046
+ return new URL(requestUrl).hostname.toLowerCase() === selfHostname;
3047
+ } catch {
3048
+ return false;
3049
+ }
3050
+ }
2606
3051
 
2607
3052
  // src/crossdeck.ts
2608
3053
  var CrossdeckClient = class {
@@ -2619,6 +3064,28 @@ var CrossdeckClient = class {
2619
3064
  * mismatched env fails fast at boot rather than at first event-flush.
2620
3065
  */
2621
3066
  init(options) {
3067
+ if (this.state) {
3068
+ try {
3069
+ this.state.uninstallUnloadFlush?.();
3070
+ } catch {
3071
+ }
3072
+ try {
3073
+ this.state.autoTracker?.uninstall();
3074
+ } catch {
3075
+ }
3076
+ try {
3077
+ this.state.webVitals?.uninstall();
3078
+ } catch {
3079
+ }
3080
+ try {
3081
+ this.state.errors?.uninstall();
3082
+ } catch {
3083
+ }
3084
+ try {
3085
+ void this.state.events.flush({ keepalive: true });
3086
+ } catch {
3087
+ }
3088
+ }
2622
3089
  if (!options.publicKey || !options.publicKey.startsWith("cd_pub_")) {
2623
3090
  throw new CrossdeckError({
2624
3091
  type: "configuration_error",
@@ -2665,7 +3132,12 @@ var CrossdeckClient = class {
2665
3132
  // load still flushes if the user leaves quickly (the keepalive
2666
3133
  // pagehide handler picks up anything that doesn't); long enough
2667
3134
  // that bursts of clicks coalesce into one network round-trip.
2668
- eventFlushIntervalMs: options.eventFlushIntervalMs ?? 1500,
3135
+ // v1.4.0 Phase 3.3 — flush interval default parity. Pre-
3136
+ // v1.4.0: Web/Node 1500ms, RN/Swift/Android 5000ms. All
3137
+ // converged on 2000ms (the Stripe-adjacent industry norm)
3138
+ // so cross-platform funnels show events landing at the
3139
+ // same cadence on every SDK. Per-instance override stays.
3140
+ eventFlushIntervalMs: options.eventFlushIntervalMs ?? 2e3,
2669
3141
  sdkVersion: options.sdkVersion ?? SDK_VERSION,
2670
3142
  autoTrack,
2671
3143
  appVersion: options.appVersion ?? null
@@ -2726,6 +3198,15 @@ var CrossdeckClient = class {
2726
3198
  `Event flush failed (${info.lastError}). Retrying in ${info.delayMs}ms (attempt ${info.consecutiveFailures}).`,
2727
3199
  { ...info }
2728
3200
  );
3201
+ },
3202
+ onPermanentFailure: (info) => {
3203
+ const headline = `[crossdeck] Event batch DROPPED (status ${info.status}): ${info.lastError}. ${info.droppedCount} event(s) lost \u2014 check your publishable key + app config.`;
3204
+ console.error(headline);
3205
+ debug.emit(
3206
+ "sdk.flush_permanent_failure",
3207
+ headline,
3208
+ { ...info }
3209
+ );
2729
3210
  }
2730
3211
  });
2731
3212
  const deviceInfo = autoTrack.deviceInfo ? collectDeviceInfo({ appVersion: opts.appVersion ?? void 0 }) : opts.appVersion ? { appVersion: opts.appVersion } : {};
@@ -2792,8 +3273,20 @@ var CrossdeckClient = class {
2792
3273
  report: (err) => this.reportError(err),
2793
3274
  getContext: () => ({ ...this.state.errorContext }),
2794
3275
  getTags: () => ({ ...this.state.errorTags }),
2795
- beforeSend: this.state.errorBeforeSend,
2796
- isConsented: () => this.state.consent.errors
3276
+ // GETTER, not a captured value — `setErrorBeforeSend()` mutates
3277
+ // `state.errorBeforeSend` after init() and the tracker MUST
3278
+ // pick up the new hook on the next error. The pre-fix shape
3279
+ // (`beforeSend: this.state!.errorBeforeSend`) snapshotted
3280
+ // `null` at construction and made the customer's PII scrubber
3281
+ // silently inert. See error-capture.ts:ErrorTrackerOptions.beforeSend.
3282
+ beforeSend: () => this.state.errorBeforeSend,
3283
+ isConsented: () => this.state.consent.errors,
3284
+ // Derived from the configured baseUrl at init() time. Used by
3285
+ // the fetch / XHR wrappers to skip captureHttp on Crossdeck's
3286
+ // own requests — pre-fix the skip was hardcoded to
3287
+ // `api.cross-deck.com` and broke for customers on staging /
3288
+ // regional / self-hosted base URLs (recursive capture loop).
3289
+ selfHostname: extractSelfHostname(opts.baseUrl)
2797
3290
  });
2798
3291
  this.state.errors = tracker;
2799
3292
  tracker.install();
@@ -2872,13 +3365,10 @@ var CrossdeckClient = class {
2872
3365
  };
2873
3366
  if (options?.email) body.email = options.email;
2874
3367
  if (traits) body.traits = traits;
3368
+ s.entitlements.setUserKey(userId);
2875
3369
  const result = await s.http.request("POST", "/identity/alias", {
2876
3370
  body
2877
3371
  });
2878
- const priorCdcust = s.identity.crossdeckCustomerId;
2879
- if (priorCdcust && result.crossdeckCustomerId && priorCdcust !== result.crossdeckCustomerId) {
2880
- s.entitlements.clear();
2881
- }
2882
3372
  s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
2883
3373
  s.developerUserId = userId;
2884
3374
  return result;
@@ -3318,11 +3808,25 @@ var CrossdeckClient = class {
3318
3808
  message: "syncPurchases requires a signedTransactionInfo string from StoreKit 2."
3319
3809
  });
3320
3810
  }
3811
+ const rail = input.rail ?? "apple";
3812
+ const body = { ...input, rail };
3813
+ const idempotencyKey = deriveIdempotencyKeyForPurchase(body);
3321
3814
  const result = await s.http.request("POST", "/purchases/sync", {
3322
- body: { rail: input.rail ?? "apple", ...input }
3815
+ body,
3816
+ idempotencyKey
3323
3817
  });
3324
3818
  s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
3325
3819
  s.entitlements.setFromList(result.entitlements);
3820
+ try {
3821
+ const sourceProductId = result.entitlements[0]?.source.productId;
3822
+ const sourceSubscriptionId = result.entitlements[0]?.source.subscriptionId;
3823
+ const props = { rail };
3824
+ if (sourceProductId) props.productId = sourceProductId;
3825
+ if (sourceSubscriptionId) props.subscriptionId = sourceSubscriptionId;
3826
+ if (result.idempotent_replay) props.idempotent_replay = true;
3827
+ this.track("purchase.completed", props);
3828
+ } catch {
3829
+ }
3326
3830
  s.debug.emit(
3327
3831
  "sdk.purchase_evidence_sent",
3328
3832
  "StoreKit transaction forwarded. Waiting for backend verification.",
@@ -3378,13 +3882,15 @@ var CrossdeckClient = class {
3378
3882
  }
3379
3883
  this.state.autoTracker?.uninstall();
3380
3884
  this.state.identity.reset();
3381
- this.state.entitlements.clear();
3885
+ this.state.entitlements.clearAll();
3382
3886
  this.state.events.reset();
3383
3887
  this.state.superProps.clear();
3384
3888
  this.state.breadcrumbs.clear();
3385
3889
  this.state.errorContext = {};
3386
3890
  this.state.errorTags = {};
3387
3891
  this.state.developerUserId = null;
3892
+ this.state.lastServerTime = null;
3893
+ this.state.lastClientTime = null;
3388
3894
  if (this.state.autoTracker) {
3389
3895
  const tracker = new AutoTracker(
3390
3896
  this.state.options.autoTrack,
@@ -3513,7 +4019,9 @@ function isLocalHostname() {
3513
4019
  const hostname = w?.location?.hostname;
3514
4020
  if (!hostname) return false;
3515
4021
  if (hostname === "localhost" || hostname === "127.0.0.1") return true;
4022
+ if (hostname === "0.0.0.0") return true;
3516
4023
  if (hostname === "::1" || hostname === "[::1]") return true;
4024
+ if (/^\[?fe80::/i.test(hostname)) return true;
3517
4025
  if (hostname.endsWith(".local")) return true;
3518
4026
  if (/^10\./.test(hostname)) return true;
3519
4027
  if (/^192\.168\./.test(hostname)) return true;
@@ -3649,6 +4157,116 @@ var CROSSDECK_ERROR_CODES = Object.freeze([
3649
4157
  description: "The server returned a 2xx with an unparseable body.",
3650
4158
  resolution: "Likely a transient backend bug. Retry; if it persists, contact support with the requestId.",
3651
4159
  retryable: true
4160
+ },
4161
+ // ----- Backend-emitted codes (v1.4.0 Phase 6.2 backfill) -----
4162
+ // Mirror of backend/src/api/v1-errors.ts ApiErrorCode. A developer
4163
+ // hitting any of these on the wire can look them up via
4164
+ // getErrorCode(code) for a canonical remediation step instead of
4165
+ // hunting through Slack history.
4166
+ {
4167
+ code: "missing_api_key",
4168
+ type: "authentication_error",
4169
+ description: "No Authorization header (or Crossdeck-Api-Key header) on the request.",
4170
+ 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.",
4171
+ retryable: false
4172
+ },
4173
+ {
4174
+ code: "invalid_api_key",
4175
+ type: "authentication_error",
4176
+ description: "The API key is malformed, unknown, or doesn't resolve to a project.",
4177
+ 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.",
4178
+ retryable: false
4179
+ },
4180
+ {
4181
+ code: "key_revoked",
4182
+ type: "authentication_error",
4183
+ description: "The API key was revoked in the Crossdeck dashboard.",
4184
+ 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.",
4185
+ retryable: false
4186
+ },
4187
+ {
4188
+ code: "identity_token_invalid",
4189
+ type: "authentication_error",
4190
+ description: "The Firebase / Apple / Google ID token supplied with the request didn't verify against the dashboard's configured signers.",
4191
+ 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.",
4192
+ retryable: true
4193
+ },
4194
+ {
4195
+ code: "origin_not_allowed",
4196
+ type: "permission_error",
4197
+ description: "The Origin header isn't in the project's Allowed origins list.",
4198
+ resolution: "Add the origin (e.g. https://app.example.com) under dashboard \u2192 Settings \u2192 Allowed origins. Wildcards like https://*.example.com are supported.",
4199
+ retryable: false
4200
+ },
4201
+ {
4202
+ code: "bundle_id_not_allowed",
4203
+ type: "permission_error",
4204
+ description: "The iOS bundle ID sent via X-Crossdeck-Bundle-Id isn't registered under this app's Apple identity lock.",
4205
+ resolution: "Add the bundle ID under dashboard \u2192 Apps \u2192 <your app> \u2192 iOS bundle IDs.",
4206
+ retryable: false
4207
+ },
4208
+ {
4209
+ code: "package_name_not_allowed",
4210
+ type: "permission_error",
4211
+ description: "The Android package name sent via X-Crossdeck-Package-Name isn't registered under this app's Android identity lock.",
4212
+ resolution: "Add the package name under dashboard \u2192 Apps \u2192 <your app> \u2192 Android package names.",
4213
+ retryable: false
4214
+ },
4215
+ {
4216
+ code: "env_mismatch",
4217
+ type: "permission_error",
4218
+ description: "The request env (inferred from key prefix) doesn't match the resolved app's configured env.",
4219
+ 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.",
4220
+ retryable: false
4221
+ },
4222
+ {
4223
+ code: "idempotency_key_in_use",
4224
+ type: "invalid_request_error",
4225
+ description: "An Idempotency-Key was reused for a request with a different body (Stripe-grade contract).",
4226
+ 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.",
4227
+ retryable: false
4228
+ },
4229
+ {
4230
+ code: "rate_limited",
4231
+ type: "rate_limit_error",
4232
+ description: "Request rate exceeded the project's per-second cap.",
4233
+ 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.",
4234
+ retryable: true
4235
+ },
4236
+ {
4237
+ code: "internal_error",
4238
+ type: "internal_error",
4239
+ description: "Server-side issue. Safe to retry with backoff.",
4240
+ resolution: "The SDK retries automatically. If your code paths through to this error, contact support with the requestId from the response envelope.",
4241
+ retryable: true
4242
+ },
4243
+ {
4244
+ code: "google_not_supported",
4245
+ type: "invalid_request_error",
4246
+ description: "POST /purchases/sync with rail=google is gated until the Play Developer API reconciliation worker ships.",
4247
+ resolution: "Until v1.5+, Google Play purchases verify via Real-time Developer Notifications. The SDK auto-track path handles this transparently for Android consumers.",
4248
+ retryable: false
4249
+ },
4250
+ {
4251
+ code: "stripe_not_supported",
4252
+ type: "invalid_request_error",
4253
+ description: "POST /purchases/sync with rail=stripe is unsupported \u2014 Stripe Checkout's redirect flow uses platform webhooks instead.",
4254
+ resolution: "Wire Stripe via the standard Checkout / Customer Portal flow; Crossdeck reconciles via the platform webhook automatically. No SDK call needed.",
4255
+ retryable: false
4256
+ },
4257
+ {
4258
+ code: "missing_required_param",
4259
+ type: "invalid_request_error",
4260
+ description: "A required field is absent from the request body.",
4261
+ 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.",
4262
+ retryable: false
4263
+ },
4264
+ {
4265
+ code: "invalid_param_value",
4266
+ type: "invalid_request_error",
4267
+ description: "A field is present but the value failed validation (wrong shape, wrong length, wrong enum value).",
4268
+ resolution: "Read error.message for the specific field + reason. SDK-managed call sites should never emit this \u2014 file a bug if you do.",
4269
+ retryable: false
3652
4270
  }
3653
4271
  ]);
3654
4272
  function getErrorCode(code) {