@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.mjs CHANGED
@@ -64,7 +64,7 @@ function typeMapForStatus(status) {
64
64
  }
65
65
 
66
66
  // src/_version.ts
67
- var SDK_VERSION = "1.5.7";
67
+ var SDK_VERSION = "1.6.2";
68
68
  var SDK_NAME = "@cross-deck/web";
69
69
 
70
70
  // src/http.ts
@@ -1413,6 +1413,8 @@ var DEFAULT_AUTO_TRACK = {
1413
1413
  errors: true
1414
1414
  };
1415
1415
  var SESSION_RESUME_THRESHOLD_MS = 30 * 60 * 1e3;
1416
+ var SESSION_STORAGE_KEY = "crossdeck:session";
1417
+ var ACTIVITY_PERSIST_THROTTLE_MS = 5e3;
1416
1418
  var EMPTY_ACQUISITION = {
1417
1419
  utm_source: "",
1418
1420
  utm_medium: "",
@@ -1428,11 +1430,13 @@ var EMPTY_ACQUISITION = {
1428
1430
  twclid: ""
1429
1431
  };
1430
1432
  var AutoTracker = class {
1431
- constructor(cfg, track) {
1433
+ constructor(cfg, track, opts) {
1432
1434
  this.cfg = cfg;
1433
1435
  this.track = track;
1434
1436
  this.session = null;
1435
1437
  this.cleanups = [];
1438
+ /** Last time we flushed lastActivityAt to storage (throttle gate). */
1439
+ this.lastPersistAt = 0;
1436
1440
  /**
1437
1441
  * Stable per-page-view identifier. Minted at every `page.viewed`
1438
1442
  * emission and attached to every subsequent event until the next
@@ -1444,6 +1448,8 @@ var AutoTracker = class {
1444
1448
  * install if `autoTrack.pageViews !== false`).
1445
1449
  */
1446
1450
  this.pageviewId = null;
1451
+ this.storage = opts?.storage ?? null;
1452
+ this.sessionKey = opts?.storageKey ?? SESSION_STORAGE_KEY;
1447
1453
  }
1448
1454
  install() {
1449
1455
  if (!isBrowserSafe()) return;
@@ -1462,6 +1468,7 @@ var AutoTracker = class {
1462
1468
  if (this.session && !this.session.endedSent) {
1463
1469
  this.emitSessionEnd();
1464
1470
  }
1471
+ this.clearStoredSession();
1465
1472
  this.session = null;
1466
1473
  }
1467
1474
  /** Exposed for tests + consumers that want to reset the session manually. */
@@ -1469,8 +1476,31 @@ var AutoTracker = class {
1469
1476
  if (this.session && !this.session.endedSent) this.emitSessionEnd();
1470
1477
  this.pageviewId = null;
1471
1478
  this.session = this.startNewSession();
1479
+ this.persistSession();
1472
1480
  this.emitSessionStart();
1473
1481
  }
1482
+ /**
1483
+ * Keep the rolling session window alive. Called by the host on EVERY
1484
+ * tracked event (auto or custom) so any activity — not just the
1485
+ * pageviews/clicks AutoTracker emits itself — pushes the 30-min idle
1486
+ * boundary forward. In-memory time is updated exactly; the storage
1487
+ * flush is throttled (pagehide forces a final flush).
1488
+ */
1489
+ markActivity() {
1490
+ if (!this.session) return;
1491
+ const now = Date.now();
1492
+ if (now - this.session.lastActivityAt >= SESSION_RESUME_THRESHOLD_MS) {
1493
+ this.pageviewId = null;
1494
+ this.session = this.startNewSession();
1495
+ this.persistSession();
1496
+ this.emitSessionStart();
1497
+ return;
1498
+ }
1499
+ this.session.lastActivityAt = now;
1500
+ if (now - this.lastPersistAt >= ACTIVITY_PERSIST_THROTTLE_MS) {
1501
+ this.persistSession();
1502
+ }
1503
+ }
1474
1504
  /** Exposed for inspection/tests — returns the current sessionId (or null if not in a session). */
1475
1505
  get currentSessionId() {
1476
1506
  return this.session?.sessionId ?? null;
@@ -1493,26 +1523,42 @@ var AutoTracker = class {
1493
1523
  }
1494
1524
  // ---------- sessions ----------
1495
1525
  installSessionTracking() {
1496
- this.session = this.startNewSession();
1497
- this.emitSessionStart();
1526
+ const now = Date.now();
1527
+ const stored = this.readStoredSession();
1528
+ if (stored && now - stored.lastActivityAt < SESSION_RESUME_THRESHOLD_MS) {
1529
+ this.session = {
1530
+ sessionId: stored.id,
1531
+ startedAt: stored.startedAt,
1532
+ lastActivityAt: now,
1533
+ hiddenAt: null,
1534
+ endedSent: false,
1535
+ acquisition: stored.acquisition
1536
+ };
1537
+ this.persistSession();
1538
+ } else {
1539
+ this.session = this.startNewSession();
1540
+ this.persistSession();
1541
+ this.emitSessionStart();
1542
+ }
1498
1543
  const onVisChange = () => {
1499
1544
  if (!this.session) return;
1500
1545
  const doc2 = globalThis.document;
1501
1546
  if (doc2.visibilityState === "hidden") {
1502
1547
  this.session.hiddenAt = Date.now();
1548
+ this.persistSession();
1503
1549
  } else if (doc2.visibilityState === "visible") {
1504
- const hiddenFor = this.session.hiddenAt ? Date.now() - this.session.hiddenAt : 0;
1505
- if (hiddenFor >= SESSION_RESUME_THRESHOLD_MS) {
1506
- this.emitSessionEnd();
1550
+ const idleFor = Date.now() - this.session.lastActivityAt;
1551
+ if (idleFor >= SESSION_RESUME_THRESHOLD_MS) {
1507
1552
  this.pageviewId = null;
1508
1553
  this.session = this.startNewSession();
1554
+ this.persistSession();
1509
1555
  this.emitSessionStart();
1510
1556
  } else {
1511
1557
  this.session.hiddenAt = null;
1512
1558
  }
1513
1559
  }
1514
1560
  };
1515
- const onPageHide = () => this.emitSessionEnd();
1561
+ const onPageHide = () => this.persistSession();
1516
1562
  const w = globalThis.window;
1517
1563
  const doc = globalThis.document;
1518
1564
  doc.addEventListener("visibilitychange", onVisChange);
@@ -1525,14 +1571,63 @@ var AutoTracker = class {
1525
1571
  });
1526
1572
  }
1527
1573
  startNewSession() {
1574
+ const now = Date.now();
1528
1575
  return {
1529
1576
  sessionId: mintSessionId(),
1530
- startedAt: Date.now(),
1577
+ startedAt: now,
1578
+ lastActivityAt: now,
1531
1579
  hiddenAt: null,
1532
1580
  endedSent: false,
1533
1581
  acquisition: captureAcquisition()
1534
1582
  };
1535
1583
  }
1584
+ /**
1585
+ * Read the persisted session continuity record. Returns null on no
1586
+ * storage, no record, malformed JSON, or a record missing its required
1587
+ * fields — every failure degrades to "no session to resume", which is
1588
+ * the safe (start-fresh) path.
1589
+ */
1590
+ readStoredSession() {
1591
+ if (!this.storage) return null;
1592
+ try {
1593
+ const raw = this.storage.getItem(this.sessionKey);
1594
+ if (!raw) return null;
1595
+ const p = JSON.parse(raw);
1596
+ if (!p || typeof p.id !== "string" || typeof p.startedAt !== "number" || typeof p.lastActivityAt !== "number") {
1597
+ return null;
1598
+ }
1599
+ return {
1600
+ id: p.id,
1601
+ startedAt: p.startedAt,
1602
+ lastActivityAt: p.lastActivityAt,
1603
+ acquisition: p.acquisition && typeof p.acquisition === "object" ? p.acquisition : EMPTY_ACQUISITION
1604
+ };
1605
+ } catch {
1606
+ return null;
1607
+ }
1608
+ }
1609
+ /** Flush the current session to storage. No-op without storage/session. */
1610
+ persistSession() {
1611
+ if (!this.storage || !this.session) return;
1612
+ this.lastPersistAt = Date.now();
1613
+ try {
1614
+ const rec = {
1615
+ id: this.session.sessionId,
1616
+ startedAt: this.session.startedAt,
1617
+ lastActivityAt: this.session.lastActivityAt,
1618
+ acquisition: this.session.acquisition
1619
+ };
1620
+ this.storage.setItem(this.sessionKey, JSON.stringify(rec));
1621
+ } catch {
1622
+ }
1623
+ }
1624
+ clearStoredSession() {
1625
+ if (!this.storage) return;
1626
+ try {
1627
+ this.storage.removeItem(this.sessionKey);
1628
+ } catch {
1629
+ }
1630
+ }
1536
1631
  emitSessionStart() {
1537
1632
  if (!this.session) return;
1538
1633
  this.track("session.started", { sessionId: this.session.sessionId });
@@ -1704,6 +1799,78 @@ function isInsidePasswordField(el) {
1704
1799
  if (el.closest('input[type="password"]')) return true;
1705
1800
  return false;
1706
1801
  }
1802
+ var INLINE_LABEL_TAGS = /* @__PURE__ */ new Set([
1803
+ "span",
1804
+ "b",
1805
+ "strong",
1806
+ "em",
1807
+ "i",
1808
+ "small",
1809
+ "mark",
1810
+ "u",
1811
+ "label",
1812
+ "abbr",
1813
+ "time",
1814
+ "bdi",
1815
+ "cite",
1816
+ "code",
1817
+ "kbd",
1818
+ "q",
1819
+ "sub",
1820
+ "sup"
1821
+ ]);
1822
+ var NON_LABEL_SUBTREES = /* @__PURE__ */ new Set(["svg", "style", "script", "noscript"]);
1823
+ var CONTAINER_MARKERS = "a[href], button, input, select, textarea, [role='button'], [role='link'], h1, h2, h3, h4, h5, h6";
1824
+ var HEADING_SELECTOR = "h1, h2, h3, h4, h5, h6";
1825
+ function collapseWs(s) {
1826
+ return s.replace(/\s+/g, " ").trim();
1827
+ }
1828
+ function boundaryText(el) {
1829
+ let out = "";
1830
+ const walk = (node) => {
1831
+ const kids = node.childNodes;
1832
+ for (let i = 0; i < kids.length; i++) {
1833
+ const child = kids[i];
1834
+ if (!child) continue;
1835
+ if (child.nodeType === 3) {
1836
+ out += child.textContent || "";
1837
+ } else if (child.nodeType === 1) {
1838
+ if (NON_LABEL_SUBTREES.has(child.tagName.toLowerCase())) continue;
1839
+ out += " ";
1840
+ walk(child);
1841
+ out += " ";
1842
+ }
1843
+ }
1844
+ };
1845
+ walk(el);
1846
+ return collapseWs(out);
1847
+ }
1848
+ function directLabel(el) {
1849
+ let out = "";
1850
+ const kids = el.childNodes;
1851
+ for (let i = 0; i < kids.length; i++) {
1852
+ const child = kids[i];
1853
+ if (!child) continue;
1854
+ if (child.nodeType === 3) {
1855
+ out += " " + (child.textContent || "");
1856
+ } else if (child.nodeType === 1 && INLINE_LABEL_TAGS.has(child.tagName.toLowerCase())) {
1857
+ out += " " + boundaryText(child);
1858
+ }
1859
+ }
1860
+ return collapseWs(out);
1861
+ }
1862
+ function visibleLabel(el) {
1863
+ const isContainer = el.querySelector(CONTAINER_MARKERS) !== null;
1864
+ if (!isContainer) return boundaryText(el);
1865
+ const direct = directLabel(el);
1866
+ if (direct) return direct;
1867
+ const heading = el.querySelector(HEADING_SELECTOR);
1868
+ if (heading) {
1869
+ const h = boundaryText(heading);
1870
+ if (h) return h;
1871
+ }
1872
+ return "";
1873
+ }
1707
1874
  function extractText(el) {
1708
1875
  const clean = (s) => s.replace(/\s+/g, " ").trim();
1709
1876
  const explicit = el.getAttribute("data-cd-track") || el.getAttribute("data-track") || el.getAttribute("data-testid");
@@ -1730,7 +1897,7 @@ function extractText(el) {
1730
1897
  const t = clean(el.value);
1731
1898
  if (t) return t;
1732
1899
  }
1733
- const text = clean(el.textContent || "");
1900
+ const text = visibleLabel(el);
1734
1901
  if (text) return text;
1735
1902
  const title = el.getAttribute("title");
1736
1903
  if (title) {
@@ -2443,6 +2610,210 @@ function sendDiagnosticTelemetry(payload) {
2443
2610
  }
2444
2611
  }
2445
2612
 
2613
+ // src/error-codes.ts
2614
+ var CROSSDECK_ERROR_CODES = Object.freeze([
2615
+ // ----- Configuration -----
2616
+ {
2617
+ code: "invalid_public_key",
2618
+ type: "configuration_error",
2619
+ description: "The publishable key passed to Crossdeck.init() doesn't start with cd_pub_.",
2620
+ resolution: "Copy the key from your Crossdeck dashboard \u2192 API keys page.",
2621
+ retryable: false
2622
+ },
2623
+ {
2624
+ code: "missing_app_id",
2625
+ type: "configuration_error",
2626
+ description: "Crossdeck.init() was called without an appId.",
2627
+ resolution: "Add appId to your init options \u2014 find it in the dashboard's Apps page.",
2628
+ retryable: false
2629
+ },
2630
+ {
2631
+ code: "invalid_environment",
2632
+ type: "configuration_error",
2633
+ description: "Crossdeck.init() requires environment: 'production' | 'sandbox'.",
2634
+ resolution: 'Pass the literal string "production" or "sandbox" \u2014 no other values are accepted.',
2635
+ retryable: false
2636
+ },
2637
+ {
2638
+ code: "environment_mismatch",
2639
+ type: "configuration_error",
2640
+ description: "The publishable key's env prefix doesn't match the declared environment option.",
2641
+ 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.",
2642
+ retryable: false
2643
+ },
2644
+ {
2645
+ code: "not_initialized",
2646
+ type: "configuration_error",
2647
+ description: "An SDK method was called before Crossdeck.init().",
2648
+ resolution: "Call Crossdeck.init({ appId, publicKey, environment }) once at app startup before any other method.",
2649
+ retryable: false
2650
+ },
2651
+ // ----- Identify / track / purchase argument validation -----
2652
+ {
2653
+ code: "missing_user_id",
2654
+ type: "invalid_request_error",
2655
+ description: "identify() was called with an empty userId.",
2656
+ resolution: "Pass a stable, non-empty user identifier from your auth layer \u2014 never a hardcoded placeholder.",
2657
+ retryable: false
2658
+ },
2659
+ {
2660
+ code: "missing_event_name",
2661
+ type: "invalid_request_error",
2662
+ description: "track() was called without an event name.",
2663
+ resolution: "Pass a non-empty string as the first argument.",
2664
+ retryable: false
2665
+ },
2666
+ {
2667
+ code: "missing_group_type",
2668
+ type: "invalid_request_error",
2669
+ description: "group() was called without a group type.",
2670
+ resolution: 'Pass a non-empty type (e.g. "org", "team") as the first argument.',
2671
+ retryable: false
2672
+ },
2673
+ {
2674
+ code: "missing_signed_transaction_info",
2675
+ type: "invalid_request_error",
2676
+ description: "syncPurchases() was called without StoreKit 2 signed transaction info.",
2677
+ resolution: "Pass the JWS string from Transaction.currentEntitlements / Transaction.updates.",
2678
+ retryable: false
2679
+ },
2680
+ // ----- Network / transport -----
2681
+ {
2682
+ code: "fetch_failed",
2683
+ type: "network_error",
2684
+ description: "The underlying fetch() call failed (typically a network outage or DNS issue).",
2685
+ resolution: "Check the user's network. The SDK will retry automatically with exponential backoff.",
2686
+ retryable: true
2687
+ },
2688
+ {
2689
+ code: "request_timeout",
2690
+ type: "network_error",
2691
+ description: "A request was aborted after the configured timeoutMs (default 15s).",
2692
+ resolution: "Check the user's connection. Increase timeoutMs in init options if the user is on a known-slow network.",
2693
+ retryable: true
2694
+ },
2695
+ {
2696
+ code: "invalid_json_response",
2697
+ type: "internal_error",
2698
+ description: "The server returned a 2xx with an unparseable body.",
2699
+ resolution: "Likely a transient backend bug. Retry; if it persists, contact support with the requestId.",
2700
+ retryable: true
2701
+ },
2702
+ // ----- Backend-emitted codes (v1.4.0 Phase 6.2 backfill) -----
2703
+ // Mirror of backend/src/api/v1-errors.ts ApiErrorCode. A developer
2704
+ // hitting any of these on the wire can look them up via
2705
+ // getErrorCode(code) for a canonical remediation step instead of
2706
+ // hunting through Slack history.
2707
+ {
2708
+ code: "missing_api_key",
2709
+ type: "authentication_error",
2710
+ description: "No Authorization header (or Crossdeck-Api-Key header) on the request.",
2711
+ 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.",
2712
+ retryable: false
2713
+ },
2714
+ {
2715
+ code: "invalid_api_key",
2716
+ type: "authentication_error",
2717
+ description: "The API key is malformed, unknown, or doesn't resolve to a project.",
2718
+ 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.",
2719
+ retryable: false
2720
+ },
2721
+ {
2722
+ code: "key_revoked",
2723
+ type: "authentication_error",
2724
+ description: "The API key was revoked in the Crossdeck dashboard.",
2725
+ 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.",
2726
+ retryable: false
2727
+ },
2728
+ {
2729
+ code: "identity_token_invalid",
2730
+ type: "authentication_error",
2731
+ description: "The Firebase / Apple / Google ID token supplied with the request didn't verify against the dashboard's configured signers.",
2732
+ 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.",
2733
+ retryable: true
2734
+ },
2735
+ {
2736
+ code: "origin_not_allowed",
2737
+ type: "permission_error",
2738
+ description: "The Origin header isn't in the project's Allowed origins list.",
2739
+ resolution: "Add the origin (e.g. https://app.example.com) under dashboard \u2192 Settings \u2192 Allowed origins. Wildcards like https://*.example.com are supported.",
2740
+ retryable: false
2741
+ },
2742
+ {
2743
+ code: "bundle_id_not_allowed",
2744
+ type: "permission_error",
2745
+ description: "The iOS bundle ID sent via X-Crossdeck-Bundle-Id isn't registered under this app's Apple identity lock.",
2746
+ resolution: "Add the bundle ID under dashboard \u2192 Apps \u2192 <your app> \u2192 iOS bundle IDs.",
2747
+ retryable: false
2748
+ },
2749
+ {
2750
+ code: "package_name_not_allowed",
2751
+ type: "permission_error",
2752
+ description: "The Android package name sent via X-Crossdeck-Package-Name isn't registered under this app's Android identity lock.",
2753
+ resolution: "Add the package name under dashboard \u2192 Apps \u2192 <your app> \u2192 Android package names.",
2754
+ retryable: false
2755
+ },
2756
+ {
2757
+ code: "env_mismatch",
2758
+ type: "permission_error",
2759
+ description: "The request env (inferred from key prefix) doesn't match the resolved app's configured env.",
2760
+ 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.",
2761
+ retryable: false
2762
+ },
2763
+ {
2764
+ code: "idempotency_key_in_use",
2765
+ type: "invalid_request_error",
2766
+ description: "An Idempotency-Key was reused for a request with a different body (Stripe-grade contract).",
2767
+ 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.",
2768
+ retryable: false
2769
+ },
2770
+ {
2771
+ code: "rate_limited",
2772
+ type: "rate_limit_error",
2773
+ description: "Request rate exceeded the project's per-second cap.",
2774
+ 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.",
2775
+ retryable: true
2776
+ },
2777
+ {
2778
+ code: "internal_error",
2779
+ type: "internal_error",
2780
+ description: "Server-side issue. Safe to retry with backoff.",
2781
+ resolution: "The SDK retries automatically. If your code paths through to this error, contact support with the requestId from the response envelope.",
2782
+ retryable: true
2783
+ },
2784
+ {
2785
+ code: "google_not_supported",
2786
+ type: "invalid_request_error",
2787
+ description: "POST /purchases/sync with rail=google is gated until the Play Developer API reconciliation worker ships.",
2788
+ resolution: "Until v1.5+, Google Play purchases verify via Real-time Developer Notifications. The SDK auto-track path handles this transparently for Android consumers.",
2789
+ retryable: false
2790
+ },
2791
+ {
2792
+ code: "stripe_not_supported",
2793
+ type: "invalid_request_error",
2794
+ description: "POST /purchases/sync with rail=stripe is unsupported \u2014 Stripe Checkout's redirect flow uses platform webhooks instead.",
2795
+ resolution: "Wire Stripe via the standard Checkout / Customer Portal flow; Crossdeck reconciles via the platform webhook automatically. No SDK call needed.",
2796
+ retryable: false
2797
+ },
2798
+ {
2799
+ code: "missing_required_param",
2800
+ type: "invalid_request_error",
2801
+ description: "A required field is absent from the request body.",
2802
+ 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.",
2803
+ retryable: false
2804
+ },
2805
+ {
2806
+ code: "invalid_param_value",
2807
+ type: "invalid_request_error",
2808
+ description: "A field is present but the value failed validation (wrong shape, wrong length, wrong enum value).",
2809
+ resolution: "Read error.message for the specific field + reason. SDK-managed call sites should never emit this \u2014 file a bug if you do.",
2810
+ retryable: false
2811
+ }
2812
+ ]);
2813
+ function getErrorCode(code) {
2814
+ return CROSSDECK_ERROR_CODES.find((e) => e.code === code);
2815
+ }
2816
+
2446
2817
  // src/_contract-verifiers.ts
2447
2818
  function buildVerifierContext(opts) {
2448
2819
  return {
@@ -2967,13 +3338,64 @@ var VERIFIER_CONTRACT_FAILED_PAYLOAD_SCHEMA_LOCK = {
2967
3338
  );
2968
3339
  }
2969
3340
  };
3341
+ var BACKEND_WIRE_CODES = Object.freeze([
3342
+ "missing_api_key",
3343
+ "invalid_api_key",
3344
+ "key_revoked",
3345
+ "identity_token_invalid",
3346
+ "origin_not_allowed",
3347
+ "bundle_id_not_allowed",
3348
+ "package_name_not_allowed",
3349
+ "env_mismatch",
3350
+ "idempotency_key_in_use",
3351
+ "rate_limited",
3352
+ "internal_error",
3353
+ "google_not_supported",
3354
+ "stripe_not_supported",
3355
+ "missing_required_param",
3356
+ "invalid_param_value"
3357
+ ]);
3358
+ var VERIFIER_SDK_ERROR_CODES_CATALOGUE = {
3359
+ contractId: "sdk-error-codes-catalogue",
3360
+ bootTest() {
3361
+ const t0 = nowMs();
3362
+ try {
3363
+ const missing = [];
3364
+ for (const code of BACKEND_WIRE_CODES) {
3365
+ const entry = getErrorCode(code);
3366
+ if (!entry || !entry.description || entry.description.trim().length === 0 || !entry.resolution || entry.resolution.trim().length === 0) {
3367
+ missing.push(code);
3368
+ }
3369
+ }
3370
+ if (missing.length > 0) {
3371
+ return fail(
3372
+ "sdk-error-codes-catalogue",
3373
+ `catalogue missing description+resolution for backend code(s): ${missing.join(", ")}`,
3374
+ nowMs() - t0
3375
+ );
3376
+ }
3377
+ return pass(
3378
+ "sdk-error-codes-catalogue",
3379
+ `all ${BACKEND_WIRE_CODES.length} backend wire codes carry description + resolution`,
3380
+ nowMs() - t0
3381
+ );
3382
+ } catch (err) {
3383
+ return fail(
3384
+ "sdk-error-codes-catalogue",
3385
+ `boot test threw: ${err.message?.slice(0, 80) ?? "unknown error"}`,
3386
+ nowMs() - t0
3387
+ );
3388
+ }
3389
+ }
3390
+ };
2970
3391
  var STATIC_VERIFIERS = Object.freeze([
2971
3392
  VERIFIER_PER_USER_CACHE_ISOLATION,
2972
3393
  VERIFIER_IDEMPOTENCY_KEY_DETERMINISTIC,
2973
3394
  VERIFIER_ERROR_ENVELOPE_SHAPE,
2974
3395
  VERIFIER_FLUSH_INTERVAL_PARITY,
2975
3396
  VERIFIER_SUPER_PROPERTY_MERGE_PRECEDENCE,
2976
- VERIFIER_CONTRACT_FAILED_PAYLOAD_SCHEMA_LOCK
3397
+ VERIFIER_CONTRACT_FAILED_PAYLOAD_SCHEMA_LOCK,
3398
+ VERIFIER_SDK_ERROR_CODES_CATALOGUE
2977
3399
  ]);
2978
3400
  async function runBootSelfTest(verifiers, reporter, ctx) {
2979
3401
  if (ctx.disableContractAssertions) {
@@ -3277,19 +3699,58 @@ var DEFAULT_ERROR_CAPTURE = {
3277
3699
  "ResizeObserver loop completed with undelivered notifications",
3278
3700
  "Non-Error promise rejection captured"
3279
3701
  // NOTE: We deliberately do NOT drop cross-origin "Script error."
3280
- // events here. They used to be silently filtered as noise, but
3281
- // silent drops are the opposite of "developer sleeps well at
3282
- // night". We now capture them with a clear label and the
3283
- // `cross_origin` tag so the dashboard surfaces them as a
3284
- // distinct, actionable category (the fix is always the same
3285
- // add `crossorigin="anonymous"` to the script tag + CORS
3286
- // headers on the script's origin). Apps that genuinely want
3287
- // them muted can re-add "Script error" to ignoreErrors via
3288
- // init config.
3702
+ // events here. The actionability principle (see denyUrls
3703
+ // comment below) draws the line at "developer cannot act":
3704
+ // cross-origin CORS opacity HAS a real, code-shaped fix (add
3705
+ // `crossorigin="anonymous"` to the script tag + CORS headers
3706
+ // on the script's origin). The dashboard surfaces these with a
3707
+ // `cross_origin` tag pointing at that fix. Apps that genuinely
3708
+ // want them muted can re-add "Script error" to ignoreErrors
3709
+ // via init config.
3289
3710
  ],
3290
3711
  allowUrls: [],
3291
3712
  denyUrls: [
3292
- // Common third-party extensions that pollute error streams.
3713
+ // The actionability principle
3714
+ // ---------------------------
3715
+ // Crossdeck's default philosophy is "classify, don't silently
3716
+ // drop" — surfacing errors the developer can fix is more useful
3717
+ // than hiding them. That's right for cross-origin "Script
3718
+ // error." (real, code-shaped CORS fix) and for plain
3719
+ // application bugs (obviously real).
3720
+ //
3721
+ // It's NOT right for events the developer cannot act on.
3722
+ // - A user's installed browser extension throwing inside
3723
+ // its own `chrome-extension://` URL: the developer can't
3724
+ // ship a fix for someone else's extension.
3725
+ // - An ad blocker preventing `googletagmanager.com` from
3726
+ // loading: the developer can't unblock the user's blocker.
3727
+ //
3728
+ // Capturing these creates a noise tab that's always non-empty
3729
+ // and never actionable, which trains the dev to ignore the
3730
+ // noise tab — and then the actionable noise (CORS opacity,
3731
+ // etc.) gets ignored along with it. Same lesson as Crossdeck's
3732
+ // "0-2 notifications a week" discipline: a signal that fires
3733
+ // constantly with nothing actionable behind it stops being a
3734
+ // signal.
3735
+ //
3736
+ // So we drop at the source for the unactionable category, and
3737
+ // keep capturing for the actionable category. Same principle
3738
+ // applied to two structurally different inputs — not a new
3739
+ // philosophy.
3740
+ //
3741
+ // The bootstrap list below is the minimum that ships in code.
3742
+ // The full, versioned list arrives at boot via /v1/config —
3743
+ // see `backend/src/lib/error-noise-deny-list.ts`. The SDK
3744
+ // applies the union of (bootstrap + remote), so a freshly-
3745
+ // installed SDK protects users immediately, and remote updates
3746
+ // (new ad-network domains, new pixel hosts) reach every
3747
+ // install without an SDK release.
3748
+ //
3749
+ // The backend Layer-2 classifier
3750
+ // (`backend/src/api/lib/noise-classifier.ts`) is the safety
3751
+ // net for events that slip past these patterns — older SDK
3752
+ // versions in the wild that haven't fetched the remote list
3753
+ // yet, or brand-new patterns the list hasn't named.
3293
3754
  /^chrome-extension:\/\//,
3294
3755
  /^moz-extension:\/\//,
3295
3756
  /^safari-extension:\/\//,
@@ -4082,7 +4543,12 @@ var CrossdeckClient = class {
4082
4543
  if (autoTrack.sessions || autoTrack.pageViews) {
4083
4544
  const tracker = new AutoTracker(
4084
4545
  autoTrack,
4085
- (name, properties) => this.track(name, properties)
4546
+ (name, properties) => this.track(name, properties),
4547
+ // Persist session continuity on the SAME adapter as identity, so
4548
+ // a visit survives full-page navigations (multi-page sites
4549
+ // re-install the SDK on every page) and honours the same consent
4550
+ // posture — MemoryStorage when identity persistence is off.
4551
+ { storage: effectiveStorage, storageKey: opts.storagePrefix + "session" }
4086
4552
  );
4087
4553
  this.state.autoTracker = tracker;
4088
4554
  tracker.install();
@@ -4547,6 +5013,12 @@ var CrossdeckClient = class {
4547
5013
  * true even before the session's first network round-trip. Returns
4548
5014
  * false only for a genuinely new install that has never completed a
4549
5015
  * getEntitlements(), or for an entitlement past its own validUntil.
5016
+ *
5017
+ * Throws `not_initialized` (CrossdeckError, type
5018
+ * `configuration_error`) if called before `Crossdeck.init()`. The
5019
+ * `useEntitlement` React hook and the Vue composable both swallow
5020
+ * this and return `false`; bare callers must guard for it (or call
5021
+ * after `init()` resolves).
4550
5022
  */
4551
5023
  isEntitled(key) {
4552
5024
  const s = this.requireStarted();
@@ -4562,8 +5034,21 @@ var CrossdeckClient = class {
4562
5034
  *
4563
5035
  * The listener is invoked AFTER the cache mutates — once after a
4564
5036
  * successful `getEntitlements()` warms it, again after `syncPurchases()`
4565
- * delivers fresh entitlements, and once on `reset()` to fire the
4566
- * empty-cache state for logout flows.
5037
+ * delivers fresh entitlements, once on `reset()` to fire the empty-
5038
+ * cache state for logout flows, AND once on `identify()` after the
5039
+ * per-user cache slot rotates and re-hydrates from device storage.
5040
+ *
5041
+ * IMPORTANT — the `identify()` fire is a TRAP if you treat it as
5042
+ * authoritative network state. `identify()` does NOT fetch entitlements;
5043
+ * it switches the per-user cache slot and rehydrates from device
5044
+ * storage (which is empty for a brand-new install, and last-known-good
5045
+ * — possibly stale — for a returning user). A listener that gates a
5046
+ * paywall on the first fire after an identity switch will read
5047
+ * `false` for a paying customer on a fresh device and let them past
5048
+ * the gate as free. The network-truth fire is the one that follows
5049
+ * the next `getEntitlements()` resolution. Either call
5050
+ * `getEntitlements()` explicitly after `identify()`, or have your
5051
+ * gating code tolerate the empty-then-populated transition.
4567
5052
  *
4568
5053
  * It is NOT invoked synchronously on subscribe. Callers that need
4569
5054
  * the current state should read it via `isEntitled()` / `listEntitlements()`
@@ -4672,6 +5157,7 @@ var CrossdeckClient = class {
4672
5157
  );
4673
5158
  }
4674
5159
  }
5160
+ s.autoTracker?.markActivity();
4675
5161
  const enriched = { ...s.deviceInfo };
4676
5162
  const sessionId = s.autoTracker?.currentSessionId;
4677
5163
  if (sessionId) enriched.sessionId = sessionId;
@@ -4926,6 +5412,25 @@ var CrossdeckClient = class {
4926
5412
  events: s.events.getStats()
4927
5413
  };
4928
5414
  }
5415
+ /**
5416
+ * The stable reference to hand Stripe at checkout so the resulting
5417
+ * purchase attributes to THIS person. Stamp it on the Checkout Session
5418
+ * as `metadata.crossdeck_ref` (see docs/connect-stripe) — the platform
5419
+ * webhook reads it back, validates it, and attaches the subscription to
5420
+ * the right customer instead of stranding it on an anonymous record.
5421
+ *
5422
+ * Returns the strongest identifier the SDK currently holds, in the same
5423
+ * precedence the server resolves by:
5424
+ * crossdeckCustomerId (cdcust_…) > developerUserId > anonymousId
5425
+ * anonymousId is always present, so this always returns a usable,
5426
+ * non-empty reference — even before identify(). Identify the user as
5427
+ * early as you can, though, so the reference is their stable identity
5428
+ * and not a per-device anon id.
5429
+ */
5430
+ getCheckoutReference() {
5431
+ const s = this.requireStarted();
5432
+ return s.identity.crossdeckCustomerId ?? s.developerUserId ?? s.identity.anonymousId;
5433
+ }
4929
5434
  // ---------- private helpers ----------
4930
5435
  requireStarted() {
4931
5436
  if (!this.state) {
@@ -5042,213 +5547,9 @@ function installUnloadFlush(onUnload) {
5042
5547
  };
5043
5548
  }
5044
5549
 
5045
- // src/error-codes.ts
5046
- var CROSSDECK_ERROR_CODES = Object.freeze([
5047
- // ----- Configuration -----
5048
- {
5049
- code: "invalid_public_key",
5050
- type: "configuration_error",
5051
- description: "The publishable key passed to Crossdeck.init() doesn't start with cd_pub_.",
5052
- resolution: "Copy the key from your Crossdeck dashboard \u2192 API keys page.",
5053
- retryable: false
5054
- },
5055
- {
5056
- code: "missing_app_id",
5057
- type: "configuration_error",
5058
- description: "Crossdeck.init() was called without an appId.",
5059
- resolution: "Add appId to your init options \u2014 find it in the dashboard's Apps page.",
5060
- retryable: false
5061
- },
5062
- {
5063
- code: "invalid_environment",
5064
- type: "configuration_error",
5065
- description: "Crossdeck.init() requires environment: 'production' | 'sandbox'.",
5066
- resolution: 'Pass the literal string "production" or "sandbox" \u2014 no other values are accepted.',
5067
- retryable: false
5068
- },
5069
- {
5070
- code: "environment_mismatch",
5071
- type: "configuration_error",
5072
- description: "The publishable key's env prefix doesn't match the declared environment option.",
5073
- 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.",
5074
- retryable: false
5075
- },
5076
- {
5077
- code: "not_initialized",
5078
- type: "configuration_error",
5079
- description: "An SDK method was called before Crossdeck.init().",
5080
- resolution: "Call Crossdeck.init({ appId, publicKey, environment }) once at app startup before any other method.",
5081
- retryable: false
5082
- },
5083
- // ----- Identify / track / purchase argument validation -----
5084
- {
5085
- code: "missing_user_id",
5086
- type: "invalid_request_error",
5087
- description: "identify() was called with an empty userId.",
5088
- resolution: "Pass a stable, non-empty user identifier from your auth layer \u2014 never a hardcoded placeholder.",
5089
- retryable: false
5090
- },
5091
- {
5092
- code: "missing_event_name",
5093
- type: "invalid_request_error",
5094
- description: "track() was called without an event name.",
5095
- resolution: "Pass a non-empty string as the first argument.",
5096
- retryable: false
5097
- },
5098
- {
5099
- code: "missing_group_type",
5100
- type: "invalid_request_error",
5101
- description: "group() was called without a group type.",
5102
- resolution: 'Pass a non-empty type (e.g. "org", "team") as the first argument.',
5103
- retryable: false
5104
- },
5105
- {
5106
- code: "missing_signed_transaction_info",
5107
- type: "invalid_request_error",
5108
- description: "syncPurchases() was called without StoreKit 2 signed transaction info.",
5109
- resolution: "Pass the JWS string from Transaction.currentEntitlements / Transaction.updates.",
5110
- retryable: false
5111
- },
5112
- // ----- Network / transport -----
5113
- {
5114
- code: "fetch_failed",
5115
- type: "network_error",
5116
- description: "The underlying fetch() call failed (typically a network outage or DNS issue).",
5117
- resolution: "Check the user's network. The SDK will retry automatically with exponential backoff.",
5118
- retryable: true
5119
- },
5120
- {
5121
- code: "request_timeout",
5122
- type: "network_error",
5123
- description: "A request was aborted after the configured timeoutMs (default 15s).",
5124
- resolution: "Check the user's connection. Increase timeoutMs in init options if the user is on a known-slow network.",
5125
- retryable: true
5126
- },
5127
- {
5128
- code: "invalid_json_response",
5129
- type: "internal_error",
5130
- description: "The server returned a 2xx with an unparseable body.",
5131
- resolution: "Likely a transient backend bug. Retry; if it persists, contact support with the requestId.",
5132
- retryable: true
5133
- },
5134
- // ----- Backend-emitted codes (v1.4.0 Phase 6.2 backfill) -----
5135
- // Mirror of backend/src/api/v1-errors.ts ApiErrorCode. A developer
5136
- // hitting any of these on the wire can look them up via
5137
- // getErrorCode(code) for a canonical remediation step instead of
5138
- // hunting through Slack history.
5139
- {
5140
- code: "missing_api_key",
5141
- type: "authentication_error",
5142
- description: "No Authorization header (or Crossdeck-Api-Key header) on the request.",
5143
- 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.",
5144
- retryable: false
5145
- },
5146
- {
5147
- code: "invalid_api_key",
5148
- type: "authentication_error",
5149
- description: "The API key is malformed, unknown, or doesn't resolve to a project.",
5150
- 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.",
5151
- retryable: false
5152
- },
5153
- {
5154
- code: "key_revoked",
5155
- type: "authentication_error",
5156
- description: "The API key was revoked in the Crossdeck dashboard.",
5157
- 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.",
5158
- retryable: false
5159
- },
5160
- {
5161
- code: "identity_token_invalid",
5162
- type: "authentication_error",
5163
- description: "The Firebase / Apple / Google ID token supplied with the request didn't verify against the dashboard's configured signers.",
5164
- 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.",
5165
- retryable: true
5166
- },
5167
- {
5168
- code: "origin_not_allowed",
5169
- type: "permission_error",
5170
- description: "The Origin header isn't in the project's Allowed origins list.",
5171
- resolution: "Add the origin (e.g. https://app.example.com) under dashboard \u2192 Settings \u2192 Allowed origins. Wildcards like https://*.example.com are supported.",
5172
- retryable: false
5173
- },
5174
- {
5175
- code: "bundle_id_not_allowed",
5176
- type: "permission_error",
5177
- description: "The iOS bundle ID sent via X-Crossdeck-Bundle-Id isn't registered under this app's Apple identity lock.",
5178
- resolution: "Add the bundle ID under dashboard \u2192 Apps \u2192 <your app> \u2192 iOS bundle IDs.",
5179
- retryable: false
5180
- },
5181
- {
5182
- code: "package_name_not_allowed",
5183
- type: "permission_error",
5184
- description: "The Android package name sent via X-Crossdeck-Package-Name isn't registered under this app's Android identity lock.",
5185
- resolution: "Add the package name under dashboard \u2192 Apps \u2192 <your app> \u2192 Android package names.",
5186
- retryable: false
5187
- },
5188
- {
5189
- code: "env_mismatch",
5190
- type: "permission_error",
5191
- description: "The request env (inferred from key prefix) doesn't match the resolved app's configured env.",
5192
- 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.",
5193
- retryable: false
5194
- },
5195
- {
5196
- code: "idempotency_key_in_use",
5197
- type: "invalid_request_error",
5198
- description: "An Idempotency-Key was reused for a request with a different body (Stripe-grade contract).",
5199
- 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.",
5200
- retryable: false
5201
- },
5202
- {
5203
- code: "rate_limited",
5204
- type: "rate_limit_error",
5205
- description: "Request rate exceeded the project's per-second cap.",
5206
- 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.",
5207
- retryable: true
5208
- },
5209
- {
5210
- code: "internal_error",
5211
- type: "internal_error",
5212
- description: "Server-side issue. Safe to retry with backoff.",
5213
- resolution: "The SDK retries automatically. If your code paths through to this error, contact support with the requestId from the response envelope.",
5214
- retryable: true
5215
- },
5216
- {
5217
- code: "google_not_supported",
5218
- type: "invalid_request_error",
5219
- description: "POST /purchases/sync with rail=google is gated until the Play Developer API reconciliation worker ships.",
5220
- resolution: "Until v1.5+, Google Play purchases verify via Real-time Developer Notifications. The SDK auto-track path handles this transparently for Android consumers.",
5221
- retryable: false
5222
- },
5223
- {
5224
- code: "stripe_not_supported",
5225
- type: "invalid_request_error",
5226
- description: "POST /purchases/sync with rail=stripe is unsupported \u2014 Stripe Checkout's redirect flow uses platform webhooks instead.",
5227
- resolution: "Wire Stripe via the standard Checkout / Customer Portal flow; Crossdeck reconciles via the platform webhook automatically. No SDK call needed.",
5228
- retryable: false
5229
- },
5230
- {
5231
- code: "missing_required_param",
5232
- type: "invalid_request_error",
5233
- description: "A required field is absent from the request body.",
5234
- 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.",
5235
- retryable: false
5236
- },
5237
- {
5238
- code: "invalid_param_value",
5239
- type: "invalid_request_error",
5240
- description: "A field is present but the value failed validation (wrong shape, wrong length, wrong enum value).",
5241
- resolution: "Read error.message for the specific field + reason. SDK-managed call sites should never emit this \u2014 file a bug if you do.",
5242
- retryable: false
5243
- }
5244
- ]);
5245
- function getErrorCode(code) {
5246
- return CROSSDECK_ERROR_CODES.find((e) => e.code === code);
5247
- }
5248
-
5249
5550
  // src/_contracts-bundled.ts
5250
- var BUNDLED_IN = "@cross-deck/web@1.5.7";
5251
- var SDK_VERSION2 = "1.5.7";
5551
+ var BUNDLED_IN = "@cross-deck/web@1.6.2";
5552
+ var SDK_VERSION2 = "1.6.2";
5252
5553
  var BUNDLED_CONTRACTS = Object.freeze([
5253
5554
  {
5254
5555
  "id": "contract-failed-payload-schema-lock",
@@ -5370,7 +5671,8 @@ var BUNDLED_CONTRACTS = Object.freeze([
5370
5671
  "legal/security/index.html#diagnostic",
5371
5672
  "legal/sdk-data/index.html#b-diagnostic"
5372
5673
  ],
5373
- "bundledIn": "@cross-deck/web@1.5.7"
5674
+ "bundledIn": "@cross-deck/web@1.6.2",
5675
+ "runtimeVerified": true
5374
5676
  },
5375
5677
  {
5376
5678
  "id": "error-envelope-shape",
@@ -5409,7 +5711,8 @@ var BUNDLED_CONTRACTS = Object.freeze([
5409
5711
  ],
5410
5712
  "registeredAt": "2026-05-26",
5411
5713
  "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 8 (codifies existing contract)",
5412
- "bundledIn": "@cross-deck/web@1.5.7"
5714
+ "bundledIn": "@cross-deck/web@1.6.2",
5715
+ "runtimeVerified": true
5413
5716
  },
5414
5717
  {
5415
5718
  "id": "flush-interval-parity",
@@ -5454,7 +5757,8 @@ var BUNDLED_CONTRACTS = Object.freeze([
5454
5757
  ],
5455
5758
  "registeredAt": "2026-05-26",
5456
5759
  "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 3.3",
5457
- "bundledIn": "@cross-deck/web@1.5.7"
5760
+ "bundledIn": "@cross-deck/web@1.6.2",
5761
+ "runtimeVerified": true
5458
5762
  },
5459
5763
  {
5460
5764
  "id": "idempotency-key-deterministic",
@@ -5559,7 +5863,8 @@ var BUNDLED_CONTRACTS = Object.freeze([
5559
5863
  ],
5560
5864
  "registeredAt": "2026-05-26",
5561
5865
  "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 2.2.a + 2.2.b + 2.2.c",
5562
- "bundledIn": "@cross-deck/web@1.5.7"
5866
+ "bundledIn": "@cross-deck/web@1.6.2",
5867
+ "runtimeVerified": true
5563
5868
  },
5564
5869
  {
5565
5870
  "id": "init-reentry-drains-prior-queue",
@@ -5586,7 +5891,8 @@ var BUNDLED_CONTRACTS = Object.freeze([
5586
5891
  ],
5587
5892
  "registeredAt": "2026-05-26",
5588
5893
  "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 5.5",
5589
- "bundledIn": "@cross-deck/web@1.5.7"
5894
+ "bundledIn": "@cross-deck/web@1.6.2",
5895
+ "runtimeVerified": false
5590
5896
  },
5591
5897
  {
5592
5898
  "id": "per-user-cache-isolation",
@@ -5665,7 +5971,8 @@ var BUNDLED_CONTRACTS = Object.freeze([
5665
5971
  ],
5666
5972
  "registeredAt": "2026-05-26",
5667
5973
  "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 1.3 (web/RN) + dogfood-gap fix (swift + android)",
5668
- "bundledIn": "@cross-deck/web@1.5.7"
5974
+ "bundledIn": "@cross-deck/web@1.6.2",
5975
+ "runtimeVerified": true
5669
5976
  },
5670
5977
  {
5671
5978
  "id": "sdk-error-codes-catalogue",
@@ -5679,9 +5986,14 @@ var BUNDLED_CONTRACTS = Object.freeze([
5679
5986
  "codeRef": [
5680
5987
  "sdks/web/src/error-codes.ts",
5681
5988
  "sdks/node/src/error-codes.ts",
5989
+ "sdks/web/src/_contract-verifiers.ts",
5682
5990
  "backend/src/api/v1-errors.ts"
5683
5991
  ],
5684
5992
  "testRef": [
5993
+ {
5994
+ "file": "sdks/web/tests/contract-verifiers.test.ts",
5995
+ "name": "sdk-error-codes-catalogue covers every backend wire code with remediation"
5996
+ },
5685
5997
  {
5686
5998
  "file": "sdks/web/tests/error-codes-backfill.test.ts",
5687
5999
  "name": "includes backend code"
@@ -5705,7 +6017,50 @@ var BUNDLED_CONTRACTS = Object.freeze([
5705
6017
  ],
5706
6018
  "registeredAt": "2026-05-26",
5707
6019
  "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 6.2",
5708
- "bundledIn": "@cross-deck/web@1.5.7"
6020
+ "bundledIn": "@cross-deck/web@1.6.2",
6021
+ "runtimeVerified": true
6022
+ },
6023
+ {
6024
+ "id": "super-property-merge-precedence",
6025
+ "pillar": "analytics",
6026
+ "status": "enforced",
6027
+ "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.",
6028
+ "appliesTo": [
6029
+ "web",
6030
+ "swift"
6031
+ ],
6032
+ "codeRef": [
6033
+ "sdks/web/src/super-properties.ts",
6034
+ "sdks/web/src/_contract-verifiers.ts",
6035
+ "sdks/swift/Sources/Crossdeck/EventPropertyMerge.swift",
6036
+ "sdks/swift/Sources/Crossdeck/Crossdeck.swift"
6037
+ ],
6038
+ "testRef": [
6039
+ {
6040
+ "file": "sdks/web/tests/contract-verifiers.test.ts",
6041
+ "name": "super-property-merge-precedence verifies caller > super > device"
6042
+ },
6043
+ {
6044
+ "file": "sdks/swift/Tests/CrossdeckTests/EventPropertyMergeTests.swift",
6045
+ "name": "test_super_overrides_device"
6046
+ },
6047
+ {
6048
+ "file": "sdks/swift/Tests/CrossdeckTests/EventPropertyMergeTests.swift",
6049
+ "name": "test_caller_overrides_super"
6050
+ },
6051
+ {
6052
+ "file": "sdks/swift/Tests/CrossdeckTests/EventPropertyMergeTests.swift",
6053
+ "name": "test_full_precedence_chain"
6054
+ },
6055
+ {
6056
+ "file": "sdks/swift/Tests/CrossdeckTests/EventPropertyMergeTests.swift",
6057
+ "name": "test_matchesWebNodeRNPrecedence"
6058
+ }
6059
+ ],
6060
+ "registeredAt": "2026-05-26",
6061
+ "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 3.2",
6062
+ "bundledIn": "@cross-deck/web@1.6.2",
6063
+ "runtimeVerified": true
5709
6064
  },
5710
6065
  {
5711
6066
  "id": "sync-purchases-funnel-parity",
@@ -5738,7 +6093,8 @@ var BUNDLED_CONTRACTS = Object.freeze([
5738
6093
  ],
5739
6094
  "registeredAt": "2026-05-26",
5740
6095
  "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 3.5",
5741
- "bundledIn": "@cross-deck/web@1.5.7"
6096
+ "bundledIn": "@cross-deck/web@1.6.2",
6097
+ "runtimeVerified": false
5742
6098
  }
5743
6099
  ]);
5744
6100