@cross-deck/web 1.5.7 → 1.6.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
@@ -99,7 +99,7 @@ function typeMapForStatus(status) {
99
99
  }
100
100
 
101
101
  // src/_version.ts
102
- var SDK_VERSION = "1.5.7";
102
+ var SDK_VERSION = "1.6.2";
103
103
  var SDK_NAME = "@cross-deck/web";
104
104
 
105
105
  // src/http.ts
@@ -1448,6 +1448,8 @@ var DEFAULT_AUTO_TRACK = {
1448
1448
  errors: true
1449
1449
  };
1450
1450
  var SESSION_RESUME_THRESHOLD_MS = 30 * 60 * 1e3;
1451
+ var SESSION_STORAGE_KEY = "crossdeck:session";
1452
+ var ACTIVITY_PERSIST_THROTTLE_MS = 5e3;
1451
1453
  var EMPTY_ACQUISITION = {
1452
1454
  utm_source: "",
1453
1455
  utm_medium: "",
@@ -1463,11 +1465,13 @@ var EMPTY_ACQUISITION = {
1463
1465
  twclid: ""
1464
1466
  };
1465
1467
  var AutoTracker = class {
1466
- constructor(cfg, track) {
1468
+ constructor(cfg, track, opts) {
1467
1469
  this.cfg = cfg;
1468
1470
  this.track = track;
1469
1471
  this.session = null;
1470
1472
  this.cleanups = [];
1473
+ /** Last time we flushed lastActivityAt to storage (throttle gate). */
1474
+ this.lastPersistAt = 0;
1471
1475
  /**
1472
1476
  * Stable per-page-view identifier. Minted at every `page.viewed`
1473
1477
  * emission and attached to every subsequent event until the next
@@ -1479,6 +1483,8 @@ var AutoTracker = class {
1479
1483
  * install if `autoTrack.pageViews !== false`).
1480
1484
  */
1481
1485
  this.pageviewId = null;
1486
+ this.storage = opts?.storage ?? null;
1487
+ this.sessionKey = opts?.storageKey ?? SESSION_STORAGE_KEY;
1482
1488
  }
1483
1489
  install() {
1484
1490
  if (!isBrowserSafe()) return;
@@ -1497,6 +1503,7 @@ var AutoTracker = class {
1497
1503
  if (this.session && !this.session.endedSent) {
1498
1504
  this.emitSessionEnd();
1499
1505
  }
1506
+ this.clearStoredSession();
1500
1507
  this.session = null;
1501
1508
  }
1502
1509
  /** Exposed for tests + consumers that want to reset the session manually. */
@@ -1504,8 +1511,31 @@ var AutoTracker = class {
1504
1511
  if (this.session && !this.session.endedSent) this.emitSessionEnd();
1505
1512
  this.pageviewId = null;
1506
1513
  this.session = this.startNewSession();
1514
+ this.persistSession();
1507
1515
  this.emitSessionStart();
1508
1516
  }
1517
+ /**
1518
+ * Keep the rolling session window alive. Called by the host on EVERY
1519
+ * tracked event (auto or custom) so any activity — not just the
1520
+ * pageviews/clicks AutoTracker emits itself — pushes the 30-min idle
1521
+ * boundary forward. In-memory time is updated exactly; the storage
1522
+ * flush is throttled (pagehide forces a final flush).
1523
+ */
1524
+ markActivity() {
1525
+ if (!this.session) return;
1526
+ const now = Date.now();
1527
+ if (now - this.session.lastActivityAt >= SESSION_RESUME_THRESHOLD_MS) {
1528
+ this.pageviewId = null;
1529
+ this.session = this.startNewSession();
1530
+ this.persistSession();
1531
+ this.emitSessionStart();
1532
+ return;
1533
+ }
1534
+ this.session.lastActivityAt = now;
1535
+ if (now - this.lastPersistAt >= ACTIVITY_PERSIST_THROTTLE_MS) {
1536
+ this.persistSession();
1537
+ }
1538
+ }
1509
1539
  /** Exposed for inspection/tests — returns the current sessionId (or null if not in a session). */
1510
1540
  get currentSessionId() {
1511
1541
  return this.session?.sessionId ?? null;
@@ -1528,26 +1558,42 @@ var AutoTracker = class {
1528
1558
  }
1529
1559
  // ---------- sessions ----------
1530
1560
  installSessionTracking() {
1531
- this.session = this.startNewSession();
1532
- this.emitSessionStart();
1561
+ const now = Date.now();
1562
+ const stored = this.readStoredSession();
1563
+ if (stored && now - stored.lastActivityAt < SESSION_RESUME_THRESHOLD_MS) {
1564
+ this.session = {
1565
+ sessionId: stored.id,
1566
+ startedAt: stored.startedAt,
1567
+ lastActivityAt: now,
1568
+ hiddenAt: null,
1569
+ endedSent: false,
1570
+ acquisition: stored.acquisition
1571
+ };
1572
+ this.persistSession();
1573
+ } else {
1574
+ this.session = this.startNewSession();
1575
+ this.persistSession();
1576
+ this.emitSessionStart();
1577
+ }
1533
1578
  const onVisChange = () => {
1534
1579
  if (!this.session) return;
1535
1580
  const doc2 = globalThis.document;
1536
1581
  if (doc2.visibilityState === "hidden") {
1537
1582
  this.session.hiddenAt = Date.now();
1583
+ this.persistSession();
1538
1584
  } else if (doc2.visibilityState === "visible") {
1539
- const hiddenFor = this.session.hiddenAt ? Date.now() - this.session.hiddenAt : 0;
1540
- if (hiddenFor >= SESSION_RESUME_THRESHOLD_MS) {
1541
- this.emitSessionEnd();
1585
+ const idleFor = Date.now() - this.session.lastActivityAt;
1586
+ if (idleFor >= SESSION_RESUME_THRESHOLD_MS) {
1542
1587
  this.pageviewId = null;
1543
1588
  this.session = this.startNewSession();
1589
+ this.persistSession();
1544
1590
  this.emitSessionStart();
1545
1591
  } else {
1546
1592
  this.session.hiddenAt = null;
1547
1593
  }
1548
1594
  }
1549
1595
  };
1550
- const onPageHide = () => this.emitSessionEnd();
1596
+ const onPageHide = () => this.persistSession();
1551
1597
  const w = globalThis.window;
1552
1598
  const doc = globalThis.document;
1553
1599
  doc.addEventListener("visibilitychange", onVisChange);
@@ -1560,14 +1606,63 @@ var AutoTracker = class {
1560
1606
  });
1561
1607
  }
1562
1608
  startNewSession() {
1609
+ const now = Date.now();
1563
1610
  return {
1564
1611
  sessionId: mintSessionId(),
1565
- startedAt: Date.now(),
1612
+ startedAt: now,
1613
+ lastActivityAt: now,
1566
1614
  hiddenAt: null,
1567
1615
  endedSent: false,
1568
1616
  acquisition: captureAcquisition()
1569
1617
  };
1570
1618
  }
1619
+ /**
1620
+ * Read the persisted session continuity record. Returns null on no
1621
+ * storage, no record, malformed JSON, or a record missing its required
1622
+ * fields — every failure degrades to "no session to resume", which is
1623
+ * the safe (start-fresh) path.
1624
+ */
1625
+ readStoredSession() {
1626
+ if (!this.storage) return null;
1627
+ try {
1628
+ const raw = this.storage.getItem(this.sessionKey);
1629
+ if (!raw) return null;
1630
+ const p = JSON.parse(raw);
1631
+ if (!p || typeof p.id !== "string" || typeof p.startedAt !== "number" || typeof p.lastActivityAt !== "number") {
1632
+ return null;
1633
+ }
1634
+ return {
1635
+ id: p.id,
1636
+ startedAt: p.startedAt,
1637
+ lastActivityAt: p.lastActivityAt,
1638
+ acquisition: p.acquisition && typeof p.acquisition === "object" ? p.acquisition : EMPTY_ACQUISITION
1639
+ };
1640
+ } catch {
1641
+ return null;
1642
+ }
1643
+ }
1644
+ /** Flush the current session to storage. No-op without storage/session. */
1645
+ persistSession() {
1646
+ if (!this.storage || !this.session) return;
1647
+ this.lastPersistAt = Date.now();
1648
+ try {
1649
+ const rec = {
1650
+ id: this.session.sessionId,
1651
+ startedAt: this.session.startedAt,
1652
+ lastActivityAt: this.session.lastActivityAt,
1653
+ acquisition: this.session.acquisition
1654
+ };
1655
+ this.storage.setItem(this.sessionKey, JSON.stringify(rec));
1656
+ } catch {
1657
+ }
1658
+ }
1659
+ clearStoredSession() {
1660
+ if (!this.storage) return;
1661
+ try {
1662
+ this.storage.removeItem(this.sessionKey);
1663
+ } catch {
1664
+ }
1665
+ }
1571
1666
  emitSessionStart() {
1572
1667
  if (!this.session) return;
1573
1668
  this.track("session.started", { sessionId: this.session.sessionId });
@@ -1739,6 +1834,78 @@ function isInsidePasswordField(el) {
1739
1834
  if (el.closest('input[type="password"]')) return true;
1740
1835
  return false;
1741
1836
  }
1837
+ var INLINE_LABEL_TAGS = /* @__PURE__ */ new Set([
1838
+ "span",
1839
+ "b",
1840
+ "strong",
1841
+ "em",
1842
+ "i",
1843
+ "small",
1844
+ "mark",
1845
+ "u",
1846
+ "label",
1847
+ "abbr",
1848
+ "time",
1849
+ "bdi",
1850
+ "cite",
1851
+ "code",
1852
+ "kbd",
1853
+ "q",
1854
+ "sub",
1855
+ "sup"
1856
+ ]);
1857
+ var NON_LABEL_SUBTREES = /* @__PURE__ */ new Set(["svg", "style", "script", "noscript"]);
1858
+ var CONTAINER_MARKERS = "a[href], button, input, select, textarea, [role='button'], [role='link'], h1, h2, h3, h4, h5, h6";
1859
+ var HEADING_SELECTOR = "h1, h2, h3, h4, h5, h6";
1860
+ function collapseWs(s) {
1861
+ return s.replace(/\s+/g, " ").trim();
1862
+ }
1863
+ function boundaryText(el) {
1864
+ let out = "";
1865
+ const walk = (node) => {
1866
+ const kids = node.childNodes;
1867
+ for (let i = 0; i < kids.length; i++) {
1868
+ const child = kids[i];
1869
+ if (!child) continue;
1870
+ if (child.nodeType === 3) {
1871
+ out += child.textContent || "";
1872
+ } else if (child.nodeType === 1) {
1873
+ if (NON_LABEL_SUBTREES.has(child.tagName.toLowerCase())) continue;
1874
+ out += " ";
1875
+ walk(child);
1876
+ out += " ";
1877
+ }
1878
+ }
1879
+ };
1880
+ walk(el);
1881
+ return collapseWs(out);
1882
+ }
1883
+ function directLabel(el) {
1884
+ let out = "";
1885
+ const kids = el.childNodes;
1886
+ for (let i = 0; i < kids.length; i++) {
1887
+ const child = kids[i];
1888
+ if (!child) continue;
1889
+ if (child.nodeType === 3) {
1890
+ out += " " + (child.textContent || "");
1891
+ } else if (child.nodeType === 1 && INLINE_LABEL_TAGS.has(child.tagName.toLowerCase())) {
1892
+ out += " " + boundaryText(child);
1893
+ }
1894
+ }
1895
+ return collapseWs(out);
1896
+ }
1897
+ function visibleLabel(el) {
1898
+ const isContainer = el.querySelector(CONTAINER_MARKERS) !== null;
1899
+ if (!isContainer) return boundaryText(el);
1900
+ const direct = directLabel(el);
1901
+ if (direct) return direct;
1902
+ const heading = el.querySelector(HEADING_SELECTOR);
1903
+ if (heading) {
1904
+ const h = boundaryText(heading);
1905
+ if (h) return h;
1906
+ }
1907
+ return "";
1908
+ }
1742
1909
  function extractText(el) {
1743
1910
  const clean = (s) => s.replace(/\s+/g, " ").trim();
1744
1911
  const explicit = el.getAttribute("data-cd-track") || el.getAttribute("data-track") || el.getAttribute("data-testid");
@@ -1765,7 +1932,7 @@ function extractText(el) {
1765
1932
  const t = clean(el.value);
1766
1933
  if (t) return t;
1767
1934
  }
1768
- const text = clean(el.textContent || "");
1935
+ const text = visibleLabel(el);
1769
1936
  if (text) return text;
1770
1937
  const title = el.getAttribute("title");
1771
1938
  if (title) {
@@ -2478,6 +2645,210 @@ function sendDiagnosticTelemetry(payload) {
2478
2645
  }
2479
2646
  }
2480
2647
 
2648
+ // src/error-codes.ts
2649
+ var CROSSDECK_ERROR_CODES = Object.freeze([
2650
+ // ----- Configuration -----
2651
+ {
2652
+ code: "invalid_public_key",
2653
+ type: "configuration_error",
2654
+ description: "The publishable key passed to Crossdeck.init() doesn't start with cd_pub_.",
2655
+ resolution: "Copy the key from your Crossdeck dashboard \u2192 API keys page.",
2656
+ retryable: false
2657
+ },
2658
+ {
2659
+ code: "missing_app_id",
2660
+ type: "configuration_error",
2661
+ description: "Crossdeck.init() was called without an appId.",
2662
+ resolution: "Add appId to your init options \u2014 find it in the dashboard's Apps page.",
2663
+ retryable: false
2664
+ },
2665
+ {
2666
+ code: "invalid_environment",
2667
+ type: "configuration_error",
2668
+ description: "Crossdeck.init() requires environment: 'production' | 'sandbox'.",
2669
+ resolution: 'Pass the literal string "production" or "sandbox" \u2014 no other values are accepted.',
2670
+ retryable: false
2671
+ },
2672
+ {
2673
+ code: "environment_mismatch",
2674
+ type: "configuration_error",
2675
+ description: "The publishable key's env prefix doesn't match the declared environment option.",
2676
+ resolution: "Either change `environment` to match the key prefix (cd_pub_test_ \u2194 sandbox, cd_pub_live_ \u2194 production), or swap the key for one minted in the right env.",
2677
+ retryable: false
2678
+ },
2679
+ {
2680
+ code: "not_initialized",
2681
+ type: "configuration_error",
2682
+ description: "An SDK method was called before Crossdeck.init().",
2683
+ resolution: "Call Crossdeck.init({ appId, publicKey, environment }) once at app startup before any other method.",
2684
+ retryable: false
2685
+ },
2686
+ // ----- Identify / track / purchase argument validation -----
2687
+ {
2688
+ code: "missing_user_id",
2689
+ type: "invalid_request_error",
2690
+ description: "identify() was called with an empty userId.",
2691
+ resolution: "Pass a stable, non-empty user identifier from your auth layer \u2014 never a hardcoded placeholder.",
2692
+ retryable: false
2693
+ },
2694
+ {
2695
+ code: "missing_event_name",
2696
+ type: "invalid_request_error",
2697
+ description: "track() was called without an event name.",
2698
+ resolution: "Pass a non-empty string as the first argument.",
2699
+ retryable: false
2700
+ },
2701
+ {
2702
+ code: "missing_group_type",
2703
+ type: "invalid_request_error",
2704
+ description: "group() was called without a group type.",
2705
+ resolution: 'Pass a non-empty type (e.g. "org", "team") as the first argument.',
2706
+ retryable: false
2707
+ },
2708
+ {
2709
+ code: "missing_signed_transaction_info",
2710
+ type: "invalid_request_error",
2711
+ description: "syncPurchases() was called without StoreKit 2 signed transaction info.",
2712
+ resolution: "Pass the JWS string from Transaction.currentEntitlements / Transaction.updates.",
2713
+ retryable: false
2714
+ },
2715
+ // ----- Network / transport -----
2716
+ {
2717
+ code: "fetch_failed",
2718
+ type: "network_error",
2719
+ description: "The underlying fetch() call failed (typically a network outage or DNS issue).",
2720
+ resolution: "Check the user's network. The SDK will retry automatically with exponential backoff.",
2721
+ retryable: true
2722
+ },
2723
+ {
2724
+ code: "request_timeout",
2725
+ type: "network_error",
2726
+ description: "A request was aborted after the configured timeoutMs (default 15s).",
2727
+ resolution: "Check the user's connection. Increase timeoutMs in init options if the user is on a known-slow network.",
2728
+ retryable: true
2729
+ },
2730
+ {
2731
+ code: "invalid_json_response",
2732
+ type: "internal_error",
2733
+ description: "The server returned a 2xx with an unparseable body.",
2734
+ resolution: "Likely a transient backend bug. Retry; if it persists, contact support with the requestId.",
2735
+ retryable: true
2736
+ },
2737
+ // ----- Backend-emitted codes (v1.4.0 Phase 6.2 backfill) -----
2738
+ // Mirror of backend/src/api/v1-errors.ts ApiErrorCode. A developer
2739
+ // hitting any of these on the wire can look them up via
2740
+ // getErrorCode(code) for a canonical remediation step instead of
2741
+ // hunting through Slack history.
2742
+ {
2743
+ code: "missing_api_key",
2744
+ type: "authentication_error",
2745
+ description: "No Authorization header (or Crossdeck-Api-Key header) on the request.",
2746
+ 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.",
2747
+ retryable: false
2748
+ },
2749
+ {
2750
+ code: "invalid_api_key",
2751
+ type: "authentication_error",
2752
+ description: "The API key is malformed, unknown, or doesn't resolve to a project.",
2753
+ 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.",
2754
+ retryable: false
2755
+ },
2756
+ {
2757
+ code: "key_revoked",
2758
+ type: "authentication_error",
2759
+ description: "The API key was revoked in the Crossdeck dashboard.",
2760
+ 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.",
2761
+ retryable: false
2762
+ },
2763
+ {
2764
+ code: "identity_token_invalid",
2765
+ type: "authentication_error",
2766
+ description: "The Firebase / Apple / Google ID token supplied with the request didn't verify against the dashboard's configured signers.",
2767
+ 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.",
2768
+ retryable: true
2769
+ },
2770
+ {
2771
+ code: "origin_not_allowed",
2772
+ type: "permission_error",
2773
+ description: "The Origin header isn't in the project's Allowed origins list.",
2774
+ resolution: "Add the origin (e.g. https://app.example.com) under dashboard \u2192 Settings \u2192 Allowed origins. Wildcards like https://*.example.com are supported.",
2775
+ retryable: false
2776
+ },
2777
+ {
2778
+ code: "bundle_id_not_allowed",
2779
+ type: "permission_error",
2780
+ description: "The iOS bundle ID sent via X-Crossdeck-Bundle-Id isn't registered under this app's Apple identity lock.",
2781
+ resolution: "Add the bundle ID under dashboard \u2192 Apps \u2192 <your app> \u2192 iOS bundle IDs.",
2782
+ retryable: false
2783
+ },
2784
+ {
2785
+ code: "package_name_not_allowed",
2786
+ type: "permission_error",
2787
+ description: "The Android package name sent via X-Crossdeck-Package-Name isn't registered under this app's Android identity lock.",
2788
+ resolution: "Add the package name under dashboard \u2192 Apps \u2192 <your app> \u2192 Android package names.",
2789
+ retryable: false
2790
+ },
2791
+ {
2792
+ code: "env_mismatch",
2793
+ type: "permission_error",
2794
+ description: "The request env (inferred from key prefix) doesn't match the resolved app's configured env.",
2795
+ 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.",
2796
+ retryable: false
2797
+ },
2798
+ {
2799
+ code: "idempotency_key_in_use",
2800
+ type: "invalid_request_error",
2801
+ description: "An Idempotency-Key was reused for a request with a different body (Stripe-grade contract).",
2802
+ 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.",
2803
+ retryable: false
2804
+ },
2805
+ {
2806
+ code: "rate_limited",
2807
+ type: "rate_limit_error",
2808
+ description: "Request rate exceeded the project's per-second cap.",
2809
+ 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.",
2810
+ retryable: true
2811
+ },
2812
+ {
2813
+ code: "internal_error",
2814
+ type: "internal_error",
2815
+ description: "Server-side issue. Safe to retry with backoff.",
2816
+ resolution: "The SDK retries automatically. If your code paths through to this error, contact support with the requestId from the response envelope.",
2817
+ retryable: true
2818
+ },
2819
+ {
2820
+ code: "google_not_supported",
2821
+ type: "invalid_request_error",
2822
+ description: "POST /purchases/sync with rail=google is gated until the Play Developer API reconciliation worker ships.",
2823
+ resolution: "Until v1.5+, Google Play purchases verify via Real-time Developer Notifications. The SDK auto-track path handles this transparently for Android consumers.",
2824
+ retryable: false
2825
+ },
2826
+ {
2827
+ code: "stripe_not_supported",
2828
+ type: "invalid_request_error",
2829
+ description: "POST /purchases/sync with rail=stripe is unsupported \u2014 Stripe Checkout's redirect flow uses platform webhooks instead.",
2830
+ resolution: "Wire Stripe via the standard Checkout / Customer Portal flow; Crossdeck reconciles via the platform webhook automatically. No SDK call needed.",
2831
+ retryable: false
2832
+ },
2833
+ {
2834
+ code: "missing_required_param",
2835
+ type: "invalid_request_error",
2836
+ description: "A required field is absent from the request body.",
2837
+ 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.",
2838
+ retryable: false
2839
+ },
2840
+ {
2841
+ code: "invalid_param_value",
2842
+ type: "invalid_request_error",
2843
+ description: "A field is present but the value failed validation (wrong shape, wrong length, wrong enum value).",
2844
+ resolution: "Read error.message for the specific field + reason. SDK-managed call sites should never emit this \u2014 file a bug if you do.",
2845
+ retryable: false
2846
+ }
2847
+ ]);
2848
+ function getErrorCode(code) {
2849
+ return CROSSDECK_ERROR_CODES.find((e) => e.code === code);
2850
+ }
2851
+
2481
2852
  // src/_contract-verifiers.ts
2482
2853
  function buildVerifierContext(opts) {
2483
2854
  return {
@@ -3002,13 +3373,64 @@ var VERIFIER_CONTRACT_FAILED_PAYLOAD_SCHEMA_LOCK = {
3002
3373
  );
3003
3374
  }
3004
3375
  };
3376
+ var BACKEND_WIRE_CODES = Object.freeze([
3377
+ "missing_api_key",
3378
+ "invalid_api_key",
3379
+ "key_revoked",
3380
+ "identity_token_invalid",
3381
+ "origin_not_allowed",
3382
+ "bundle_id_not_allowed",
3383
+ "package_name_not_allowed",
3384
+ "env_mismatch",
3385
+ "idempotency_key_in_use",
3386
+ "rate_limited",
3387
+ "internal_error",
3388
+ "google_not_supported",
3389
+ "stripe_not_supported",
3390
+ "missing_required_param",
3391
+ "invalid_param_value"
3392
+ ]);
3393
+ var VERIFIER_SDK_ERROR_CODES_CATALOGUE = {
3394
+ contractId: "sdk-error-codes-catalogue",
3395
+ bootTest() {
3396
+ const t0 = nowMs();
3397
+ try {
3398
+ const missing = [];
3399
+ for (const code of BACKEND_WIRE_CODES) {
3400
+ const entry = getErrorCode(code);
3401
+ if (!entry || !entry.description || entry.description.trim().length === 0 || !entry.resolution || entry.resolution.trim().length === 0) {
3402
+ missing.push(code);
3403
+ }
3404
+ }
3405
+ if (missing.length > 0) {
3406
+ return fail(
3407
+ "sdk-error-codes-catalogue",
3408
+ `catalogue missing description+resolution for backend code(s): ${missing.join(", ")}`,
3409
+ nowMs() - t0
3410
+ );
3411
+ }
3412
+ return pass(
3413
+ "sdk-error-codes-catalogue",
3414
+ `all ${BACKEND_WIRE_CODES.length} backend wire codes carry description + resolution`,
3415
+ nowMs() - t0
3416
+ );
3417
+ } catch (err) {
3418
+ return fail(
3419
+ "sdk-error-codes-catalogue",
3420
+ `boot test threw: ${err.message?.slice(0, 80) ?? "unknown error"}`,
3421
+ nowMs() - t0
3422
+ );
3423
+ }
3424
+ }
3425
+ };
3005
3426
  var STATIC_VERIFIERS = Object.freeze([
3006
3427
  VERIFIER_PER_USER_CACHE_ISOLATION,
3007
3428
  VERIFIER_IDEMPOTENCY_KEY_DETERMINISTIC,
3008
3429
  VERIFIER_ERROR_ENVELOPE_SHAPE,
3009
3430
  VERIFIER_FLUSH_INTERVAL_PARITY,
3010
3431
  VERIFIER_SUPER_PROPERTY_MERGE_PRECEDENCE,
3011
- VERIFIER_CONTRACT_FAILED_PAYLOAD_SCHEMA_LOCK
3432
+ VERIFIER_CONTRACT_FAILED_PAYLOAD_SCHEMA_LOCK,
3433
+ VERIFIER_SDK_ERROR_CODES_CATALOGUE
3012
3434
  ]);
3013
3435
  async function runBootSelfTest(verifiers, reporter, ctx) {
3014
3436
  if (ctx.disableContractAssertions) {
@@ -3312,19 +3734,58 @@ var DEFAULT_ERROR_CAPTURE = {
3312
3734
  "ResizeObserver loop completed with undelivered notifications",
3313
3735
  "Non-Error promise rejection captured"
3314
3736
  // NOTE: We deliberately do NOT drop cross-origin "Script error."
3315
- // events here. They used to be silently filtered as noise, but
3316
- // silent drops are the opposite of "developer sleeps well at
3317
- // night". We now capture them with a clear label and the
3318
- // `cross_origin` tag so the dashboard surfaces them as a
3319
- // distinct, actionable category (the fix is always the same
3320
- // add `crossorigin="anonymous"` to the script tag + CORS
3321
- // headers on the script's origin). Apps that genuinely want
3322
- // them muted can re-add "Script error" to ignoreErrors via
3323
- // init config.
3737
+ // events here. The actionability principle (see denyUrls
3738
+ // comment below) draws the line at "developer cannot act":
3739
+ // cross-origin CORS opacity HAS a real, code-shaped fix (add
3740
+ // `crossorigin="anonymous"` to the script tag + CORS headers
3741
+ // on the script's origin). The dashboard surfaces these with a
3742
+ // `cross_origin` tag pointing at that fix. Apps that genuinely
3743
+ // want them muted can re-add "Script error" to ignoreErrors
3744
+ // via init config.
3324
3745
  ],
3325
3746
  allowUrls: [],
3326
3747
  denyUrls: [
3327
- // Common third-party extensions that pollute error streams.
3748
+ // The actionability principle
3749
+ // ---------------------------
3750
+ // Crossdeck's default philosophy is "classify, don't silently
3751
+ // drop" — surfacing errors the developer can fix is more useful
3752
+ // than hiding them. That's right for cross-origin "Script
3753
+ // error." (real, code-shaped CORS fix) and for plain
3754
+ // application bugs (obviously real).
3755
+ //
3756
+ // It's NOT right for events the developer cannot act on.
3757
+ // - A user's installed browser extension throwing inside
3758
+ // its own `chrome-extension://` URL: the developer can't
3759
+ // ship a fix for someone else's extension.
3760
+ // - An ad blocker preventing `googletagmanager.com` from
3761
+ // loading: the developer can't unblock the user's blocker.
3762
+ //
3763
+ // Capturing these creates a noise tab that's always non-empty
3764
+ // and never actionable, which trains the dev to ignore the
3765
+ // noise tab — and then the actionable noise (CORS opacity,
3766
+ // etc.) gets ignored along with it. Same lesson as Crossdeck's
3767
+ // "0-2 notifications a week" discipline: a signal that fires
3768
+ // constantly with nothing actionable behind it stops being a
3769
+ // signal.
3770
+ //
3771
+ // So we drop at the source for the unactionable category, and
3772
+ // keep capturing for the actionable category. Same principle
3773
+ // applied to two structurally different inputs — not a new
3774
+ // philosophy.
3775
+ //
3776
+ // The bootstrap list below is the minimum that ships in code.
3777
+ // The full, versioned list arrives at boot via /v1/config —
3778
+ // see `backend/src/lib/error-noise-deny-list.ts`. The SDK
3779
+ // applies the union of (bootstrap + remote), so a freshly-
3780
+ // installed SDK protects users immediately, and remote updates
3781
+ // (new ad-network domains, new pixel hosts) reach every
3782
+ // install without an SDK release.
3783
+ //
3784
+ // The backend Layer-2 classifier
3785
+ // (`backend/src/api/lib/noise-classifier.ts`) is the safety
3786
+ // net for events that slip past these patterns — older SDK
3787
+ // versions in the wild that haven't fetched the remote list
3788
+ // yet, or brand-new patterns the list hasn't named.
3328
3789
  /^chrome-extension:\/\//,
3329
3790
  /^moz-extension:\/\//,
3330
3791
  /^safari-extension:\/\//,
@@ -4117,7 +4578,12 @@ var CrossdeckClient = class {
4117
4578
  if (autoTrack.sessions || autoTrack.pageViews) {
4118
4579
  const tracker = new AutoTracker(
4119
4580
  autoTrack,
4120
- (name, properties) => this.track(name, properties)
4581
+ (name, properties) => this.track(name, properties),
4582
+ // Persist session continuity on the SAME adapter as identity, so
4583
+ // a visit survives full-page navigations (multi-page sites
4584
+ // re-install the SDK on every page) and honours the same consent
4585
+ // posture — MemoryStorage when identity persistence is off.
4586
+ { storage: effectiveStorage, storageKey: opts.storagePrefix + "session" }
4121
4587
  );
4122
4588
  this.state.autoTracker = tracker;
4123
4589
  tracker.install();
@@ -4582,6 +5048,12 @@ var CrossdeckClient = class {
4582
5048
  * true even before the session's first network round-trip. Returns
4583
5049
  * false only for a genuinely new install that has never completed a
4584
5050
  * getEntitlements(), or for an entitlement past its own validUntil.
5051
+ *
5052
+ * Throws `not_initialized` (CrossdeckError, type
5053
+ * `configuration_error`) if called before `Crossdeck.init()`. The
5054
+ * `useEntitlement` React hook and the Vue composable both swallow
5055
+ * this and return `false`; bare callers must guard for it (or call
5056
+ * after `init()` resolves).
4585
5057
  */
4586
5058
  isEntitled(key) {
4587
5059
  const s = this.requireStarted();
@@ -4597,8 +5069,21 @@ var CrossdeckClient = class {
4597
5069
  *
4598
5070
  * The listener is invoked AFTER the cache mutates — once after a
4599
5071
  * successful `getEntitlements()` warms it, again after `syncPurchases()`
4600
- * delivers fresh entitlements, and once on `reset()` to fire the
4601
- * empty-cache state for logout flows.
5072
+ * delivers fresh entitlements, once on `reset()` to fire the empty-
5073
+ * cache state for logout flows, AND once on `identify()` after the
5074
+ * per-user cache slot rotates and re-hydrates from device storage.
5075
+ *
5076
+ * IMPORTANT — the `identify()` fire is a TRAP if you treat it as
5077
+ * authoritative network state. `identify()` does NOT fetch entitlements;
5078
+ * it switches the per-user cache slot and rehydrates from device
5079
+ * storage (which is empty for a brand-new install, and last-known-good
5080
+ * — possibly stale — for a returning user). A listener that gates a
5081
+ * paywall on the first fire after an identity switch will read
5082
+ * `false` for a paying customer on a fresh device and let them past
5083
+ * the gate as free. The network-truth fire is the one that follows
5084
+ * the next `getEntitlements()` resolution. Either call
5085
+ * `getEntitlements()` explicitly after `identify()`, or have your
5086
+ * gating code tolerate the empty-then-populated transition.
4602
5087
  *
4603
5088
  * It is NOT invoked synchronously on subscribe. Callers that need
4604
5089
  * the current state should read it via `isEntitled()` / `listEntitlements()`
@@ -4707,6 +5192,7 @@ var CrossdeckClient = class {
4707
5192
  );
4708
5193
  }
4709
5194
  }
5195
+ s.autoTracker?.markActivity();
4710
5196
  const enriched = { ...s.deviceInfo };
4711
5197
  const sessionId = s.autoTracker?.currentSessionId;
4712
5198
  if (sessionId) enriched.sessionId = sessionId;
@@ -4961,6 +5447,25 @@ var CrossdeckClient = class {
4961
5447
  events: s.events.getStats()
4962
5448
  };
4963
5449
  }
5450
+ /**
5451
+ * The stable reference to hand Stripe at checkout so the resulting
5452
+ * purchase attributes to THIS person. Stamp it on the Checkout Session
5453
+ * as `metadata.crossdeck_ref` (see docs/connect-stripe) — the platform
5454
+ * webhook reads it back, validates it, and attaches the subscription to
5455
+ * the right customer instead of stranding it on an anonymous record.
5456
+ *
5457
+ * Returns the strongest identifier the SDK currently holds, in the same
5458
+ * precedence the server resolves by:
5459
+ * crossdeckCustomerId (cdcust_…) > developerUserId > anonymousId
5460
+ * anonymousId is always present, so this always returns a usable,
5461
+ * non-empty reference — even before identify(). Identify the user as
5462
+ * early as you can, though, so the reference is their stable identity
5463
+ * and not a per-device anon id.
5464
+ */
5465
+ getCheckoutReference() {
5466
+ const s = this.requireStarted();
5467
+ return s.identity.crossdeckCustomerId ?? s.developerUserId ?? s.identity.anonymousId;
5468
+ }
4964
5469
  // ---------- private helpers ----------
4965
5470
  requireStarted() {
4966
5471
  if (!this.state) {
@@ -5077,213 +5582,9 @@ function installUnloadFlush(onUnload) {
5077
5582
  };
5078
5583
  }
5079
5584
 
5080
- // src/error-codes.ts
5081
- var CROSSDECK_ERROR_CODES = Object.freeze([
5082
- // ----- Configuration -----
5083
- {
5084
- code: "invalid_public_key",
5085
- type: "configuration_error",
5086
- description: "The publishable key passed to Crossdeck.init() doesn't start with cd_pub_.",
5087
- resolution: "Copy the key from your Crossdeck dashboard \u2192 API keys page.",
5088
- retryable: false
5089
- },
5090
- {
5091
- code: "missing_app_id",
5092
- type: "configuration_error",
5093
- description: "Crossdeck.init() was called without an appId.",
5094
- resolution: "Add appId to your init options \u2014 find it in the dashboard's Apps page.",
5095
- retryable: false
5096
- },
5097
- {
5098
- code: "invalid_environment",
5099
- type: "configuration_error",
5100
- description: "Crossdeck.init() requires environment: 'production' | 'sandbox'.",
5101
- resolution: 'Pass the literal string "production" or "sandbox" \u2014 no other values are accepted.',
5102
- retryable: false
5103
- },
5104
- {
5105
- code: "environment_mismatch",
5106
- type: "configuration_error",
5107
- description: "The publishable key's env prefix doesn't match the declared environment option.",
5108
- resolution: "Either change `environment` to match the key prefix (cd_pub_test_ \u2194 sandbox, cd_pub_live_ \u2194 production), or swap the key for one minted in the right env.",
5109
- retryable: false
5110
- },
5111
- {
5112
- code: "not_initialized",
5113
- type: "configuration_error",
5114
- description: "An SDK method was called before Crossdeck.init().",
5115
- resolution: "Call Crossdeck.init({ appId, publicKey, environment }) once at app startup before any other method.",
5116
- retryable: false
5117
- },
5118
- // ----- Identify / track / purchase argument validation -----
5119
- {
5120
- code: "missing_user_id",
5121
- type: "invalid_request_error",
5122
- description: "identify() was called with an empty userId.",
5123
- resolution: "Pass a stable, non-empty user identifier from your auth layer \u2014 never a hardcoded placeholder.",
5124
- retryable: false
5125
- },
5126
- {
5127
- code: "missing_event_name",
5128
- type: "invalid_request_error",
5129
- description: "track() was called without an event name.",
5130
- resolution: "Pass a non-empty string as the first argument.",
5131
- retryable: false
5132
- },
5133
- {
5134
- code: "missing_group_type",
5135
- type: "invalid_request_error",
5136
- description: "group() was called without a group type.",
5137
- resolution: 'Pass a non-empty type (e.g. "org", "team") as the first argument.',
5138
- retryable: false
5139
- },
5140
- {
5141
- code: "missing_signed_transaction_info",
5142
- type: "invalid_request_error",
5143
- description: "syncPurchases() was called without StoreKit 2 signed transaction info.",
5144
- resolution: "Pass the JWS string from Transaction.currentEntitlements / Transaction.updates.",
5145
- retryable: false
5146
- },
5147
- // ----- Network / transport -----
5148
- {
5149
- code: "fetch_failed",
5150
- type: "network_error",
5151
- description: "The underlying fetch() call failed (typically a network outage or DNS issue).",
5152
- resolution: "Check the user's network. The SDK will retry automatically with exponential backoff.",
5153
- retryable: true
5154
- },
5155
- {
5156
- code: "request_timeout",
5157
- type: "network_error",
5158
- description: "A request was aborted after the configured timeoutMs (default 15s).",
5159
- resolution: "Check the user's connection. Increase timeoutMs in init options if the user is on a known-slow network.",
5160
- retryable: true
5161
- },
5162
- {
5163
- code: "invalid_json_response",
5164
- type: "internal_error",
5165
- description: "The server returned a 2xx with an unparseable body.",
5166
- resolution: "Likely a transient backend bug. Retry; if it persists, contact support with the requestId.",
5167
- retryable: true
5168
- },
5169
- // ----- Backend-emitted codes (v1.4.0 Phase 6.2 backfill) -----
5170
- // Mirror of backend/src/api/v1-errors.ts ApiErrorCode. A developer
5171
- // hitting any of these on the wire can look them up via
5172
- // getErrorCode(code) for a canonical remediation step instead of
5173
- // hunting through Slack history.
5174
- {
5175
- code: "missing_api_key",
5176
- type: "authentication_error",
5177
- description: "No Authorization header (or Crossdeck-Api-Key header) on the request.",
5178
- 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.",
5179
- retryable: false
5180
- },
5181
- {
5182
- code: "invalid_api_key",
5183
- type: "authentication_error",
5184
- description: "The API key is malformed, unknown, or doesn't resolve to a project.",
5185
- 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.",
5186
- retryable: false
5187
- },
5188
- {
5189
- code: "key_revoked",
5190
- type: "authentication_error",
5191
- description: "The API key was revoked in the Crossdeck dashboard.",
5192
- 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.",
5193
- retryable: false
5194
- },
5195
- {
5196
- code: "identity_token_invalid",
5197
- type: "authentication_error",
5198
- description: "The Firebase / Apple / Google ID token supplied with the request didn't verify against the dashboard's configured signers.",
5199
- 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.",
5200
- retryable: true
5201
- },
5202
- {
5203
- code: "origin_not_allowed",
5204
- type: "permission_error",
5205
- description: "The Origin header isn't in the project's Allowed origins list.",
5206
- resolution: "Add the origin (e.g. https://app.example.com) under dashboard \u2192 Settings \u2192 Allowed origins. Wildcards like https://*.example.com are supported.",
5207
- retryable: false
5208
- },
5209
- {
5210
- code: "bundle_id_not_allowed",
5211
- type: "permission_error",
5212
- description: "The iOS bundle ID sent via X-Crossdeck-Bundle-Id isn't registered under this app's Apple identity lock.",
5213
- resolution: "Add the bundle ID under dashboard \u2192 Apps \u2192 <your app> \u2192 iOS bundle IDs.",
5214
- retryable: false
5215
- },
5216
- {
5217
- code: "package_name_not_allowed",
5218
- type: "permission_error",
5219
- description: "The Android package name sent via X-Crossdeck-Package-Name isn't registered under this app's Android identity lock.",
5220
- resolution: "Add the package name under dashboard \u2192 Apps \u2192 <your app> \u2192 Android package names.",
5221
- retryable: false
5222
- },
5223
- {
5224
- code: "env_mismatch",
5225
- type: "permission_error",
5226
- description: "The request env (inferred from key prefix) doesn't match the resolved app's configured env.",
5227
- 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.",
5228
- retryable: false
5229
- },
5230
- {
5231
- code: "idempotency_key_in_use",
5232
- type: "invalid_request_error",
5233
- description: "An Idempotency-Key was reused for a request with a different body (Stripe-grade contract).",
5234
- 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.",
5235
- retryable: false
5236
- },
5237
- {
5238
- code: "rate_limited",
5239
- type: "rate_limit_error",
5240
- description: "Request rate exceeded the project's per-second cap.",
5241
- 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.",
5242
- retryable: true
5243
- },
5244
- {
5245
- code: "internal_error",
5246
- type: "internal_error",
5247
- description: "Server-side issue. Safe to retry with backoff.",
5248
- resolution: "The SDK retries automatically. If your code paths through to this error, contact support with the requestId from the response envelope.",
5249
- retryable: true
5250
- },
5251
- {
5252
- code: "google_not_supported",
5253
- type: "invalid_request_error",
5254
- description: "POST /purchases/sync with rail=google is gated until the Play Developer API reconciliation worker ships.",
5255
- resolution: "Until v1.5+, Google Play purchases verify via Real-time Developer Notifications. The SDK auto-track path handles this transparently for Android consumers.",
5256
- retryable: false
5257
- },
5258
- {
5259
- code: "stripe_not_supported",
5260
- type: "invalid_request_error",
5261
- description: "POST /purchases/sync with rail=stripe is unsupported \u2014 Stripe Checkout's redirect flow uses platform webhooks instead.",
5262
- resolution: "Wire Stripe via the standard Checkout / Customer Portal flow; Crossdeck reconciles via the platform webhook automatically. No SDK call needed.",
5263
- retryable: false
5264
- },
5265
- {
5266
- code: "missing_required_param",
5267
- type: "invalid_request_error",
5268
- description: "A required field is absent from the request body.",
5269
- 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.",
5270
- retryable: false
5271
- },
5272
- {
5273
- code: "invalid_param_value",
5274
- type: "invalid_request_error",
5275
- description: "A field is present but the value failed validation (wrong shape, wrong length, wrong enum value).",
5276
- resolution: "Read error.message for the specific field + reason. SDK-managed call sites should never emit this \u2014 file a bug if you do.",
5277
- retryable: false
5278
- }
5279
- ]);
5280
- function getErrorCode(code) {
5281
- return CROSSDECK_ERROR_CODES.find((e) => e.code === code);
5282
- }
5283
-
5284
5585
  // src/_contracts-bundled.ts
5285
- var BUNDLED_IN = "@cross-deck/web@1.5.7";
5286
- var SDK_VERSION2 = "1.5.7";
5586
+ var BUNDLED_IN = "@cross-deck/web@1.6.2";
5587
+ var SDK_VERSION2 = "1.6.2";
5287
5588
  var BUNDLED_CONTRACTS = Object.freeze([
5288
5589
  {
5289
5590
  "id": "contract-failed-payload-schema-lock",
@@ -5405,7 +5706,8 @@ var BUNDLED_CONTRACTS = Object.freeze([
5405
5706
  "legal/security/index.html#diagnostic",
5406
5707
  "legal/sdk-data/index.html#b-diagnostic"
5407
5708
  ],
5408
- "bundledIn": "@cross-deck/web@1.5.7"
5709
+ "bundledIn": "@cross-deck/web@1.6.2",
5710
+ "runtimeVerified": true
5409
5711
  },
5410
5712
  {
5411
5713
  "id": "error-envelope-shape",
@@ -5444,7 +5746,8 @@ var BUNDLED_CONTRACTS = Object.freeze([
5444
5746
  ],
5445
5747
  "registeredAt": "2026-05-26",
5446
5748
  "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 8 (codifies existing contract)",
5447
- "bundledIn": "@cross-deck/web@1.5.7"
5749
+ "bundledIn": "@cross-deck/web@1.6.2",
5750
+ "runtimeVerified": true
5448
5751
  },
5449
5752
  {
5450
5753
  "id": "flush-interval-parity",
@@ -5489,7 +5792,8 @@ var BUNDLED_CONTRACTS = Object.freeze([
5489
5792
  ],
5490
5793
  "registeredAt": "2026-05-26",
5491
5794
  "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 3.3",
5492
- "bundledIn": "@cross-deck/web@1.5.7"
5795
+ "bundledIn": "@cross-deck/web@1.6.2",
5796
+ "runtimeVerified": true
5493
5797
  },
5494
5798
  {
5495
5799
  "id": "idempotency-key-deterministic",
@@ -5594,7 +5898,8 @@ var BUNDLED_CONTRACTS = Object.freeze([
5594
5898
  ],
5595
5899
  "registeredAt": "2026-05-26",
5596
5900
  "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 2.2.a + 2.2.b + 2.2.c",
5597
- "bundledIn": "@cross-deck/web@1.5.7"
5901
+ "bundledIn": "@cross-deck/web@1.6.2",
5902
+ "runtimeVerified": true
5598
5903
  },
5599
5904
  {
5600
5905
  "id": "init-reentry-drains-prior-queue",
@@ -5621,7 +5926,8 @@ var BUNDLED_CONTRACTS = Object.freeze([
5621
5926
  ],
5622
5927
  "registeredAt": "2026-05-26",
5623
5928
  "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 5.5",
5624
- "bundledIn": "@cross-deck/web@1.5.7"
5929
+ "bundledIn": "@cross-deck/web@1.6.2",
5930
+ "runtimeVerified": false
5625
5931
  },
5626
5932
  {
5627
5933
  "id": "per-user-cache-isolation",
@@ -5700,7 +6006,8 @@ var BUNDLED_CONTRACTS = Object.freeze([
5700
6006
  ],
5701
6007
  "registeredAt": "2026-05-26",
5702
6008
  "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 1.3 (web/RN) + dogfood-gap fix (swift + android)",
5703
- "bundledIn": "@cross-deck/web@1.5.7"
6009
+ "bundledIn": "@cross-deck/web@1.6.2",
6010
+ "runtimeVerified": true
5704
6011
  },
5705
6012
  {
5706
6013
  "id": "sdk-error-codes-catalogue",
@@ -5714,9 +6021,14 @@ var BUNDLED_CONTRACTS = Object.freeze([
5714
6021
  "codeRef": [
5715
6022
  "sdks/web/src/error-codes.ts",
5716
6023
  "sdks/node/src/error-codes.ts",
6024
+ "sdks/web/src/_contract-verifiers.ts",
5717
6025
  "backend/src/api/v1-errors.ts"
5718
6026
  ],
5719
6027
  "testRef": [
6028
+ {
6029
+ "file": "sdks/web/tests/contract-verifiers.test.ts",
6030
+ "name": "sdk-error-codes-catalogue covers every backend wire code with remediation"
6031
+ },
5720
6032
  {
5721
6033
  "file": "sdks/web/tests/error-codes-backfill.test.ts",
5722
6034
  "name": "includes backend code"
@@ -5740,7 +6052,50 @@ var BUNDLED_CONTRACTS = Object.freeze([
5740
6052
  ],
5741
6053
  "registeredAt": "2026-05-26",
5742
6054
  "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 6.2",
5743
- "bundledIn": "@cross-deck/web@1.5.7"
6055
+ "bundledIn": "@cross-deck/web@1.6.2",
6056
+ "runtimeVerified": true
6057
+ },
6058
+ {
6059
+ "id": "super-property-merge-precedence",
6060
+ "pillar": "analytics",
6061
+ "status": "enforced",
6062
+ "claim": "Every Crossdeck SDK merges event properties with the precedence device < super < caller (caller-supplied values win over registered super-properties, which win over auto-attached device info). Pre-v1.4.0 Swift had it INVERTED (super < device < caller \u2014 device clobbered super), so a `register('plan', 'pro')` super-property was silently overridden by auto-attached device fields whenever keys collided. Cross-SDK funnel queries on super-property keys returned different answers per platform.",
6063
+ "appliesTo": [
6064
+ "web",
6065
+ "swift"
6066
+ ],
6067
+ "codeRef": [
6068
+ "sdks/web/src/super-properties.ts",
6069
+ "sdks/web/src/_contract-verifiers.ts",
6070
+ "sdks/swift/Sources/Crossdeck/EventPropertyMerge.swift",
6071
+ "sdks/swift/Sources/Crossdeck/Crossdeck.swift"
6072
+ ],
6073
+ "testRef": [
6074
+ {
6075
+ "file": "sdks/web/tests/contract-verifiers.test.ts",
6076
+ "name": "super-property-merge-precedence verifies caller > super > device"
6077
+ },
6078
+ {
6079
+ "file": "sdks/swift/Tests/CrossdeckTests/EventPropertyMergeTests.swift",
6080
+ "name": "test_super_overrides_device"
6081
+ },
6082
+ {
6083
+ "file": "sdks/swift/Tests/CrossdeckTests/EventPropertyMergeTests.swift",
6084
+ "name": "test_caller_overrides_super"
6085
+ },
6086
+ {
6087
+ "file": "sdks/swift/Tests/CrossdeckTests/EventPropertyMergeTests.swift",
6088
+ "name": "test_full_precedence_chain"
6089
+ },
6090
+ {
6091
+ "file": "sdks/swift/Tests/CrossdeckTests/EventPropertyMergeTests.swift",
6092
+ "name": "test_matchesWebNodeRNPrecedence"
6093
+ }
6094
+ ],
6095
+ "registeredAt": "2026-05-26",
6096
+ "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 3.2",
6097
+ "bundledIn": "@cross-deck/web@1.6.2",
6098
+ "runtimeVerified": true
5744
6099
  },
5745
6100
  {
5746
6101
  "id": "sync-purchases-funnel-parity",
@@ -5773,7 +6128,8 @@ var BUNDLED_CONTRACTS = Object.freeze([
5773
6128
  ],
5774
6129
  "registeredAt": "2026-05-26",
5775
6130
  "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 3.5",
5776
- "bundledIn": "@cross-deck/web@1.5.7"
6131
+ "bundledIn": "@cross-deck/web@1.6.2",
6132
+ "runtimeVerified": false
5777
6133
  }
5778
6134
  ]);
5779
6135