@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/react.cjs CHANGED
@@ -93,7 +93,7 @@ function typeMapForStatus(status) {
93
93
  }
94
94
 
95
95
  // src/_version.ts
96
- var SDK_VERSION = "1.5.7";
96
+ var SDK_VERSION = "1.6.2";
97
97
  var SDK_NAME = "@cross-deck/web";
98
98
 
99
99
  // src/http.ts
@@ -1442,6 +1442,8 @@ var DEFAULT_AUTO_TRACK = {
1442
1442
  errors: true
1443
1443
  };
1444
1444
  var SESSION_RESUME_THRESHOLD_MS = 30 * 60 * 1e3;
1445
+ var SESSION_STORAGE_KEY = "crossdeck:session";
1446
+ var ACTIVITY_PERSIST_THROTTLE_MS = 5e3;
1445
1447
  var EMPTY_ACQUISITION = {
1446
1448
  utm_source: "",
1447
1449
  utm_medium: "",
@@ -1457,11 +1459,13 @@ var EMPTY_ACQUISITION = {
1457
1459
  twclid: ""
1458
1460
  };
1459
1461
  var AutoTracker = class {
1460
- constructor(cfg, track) {
1462
+ constructor(cfg, track, opts) {
1461
1463
  this.cfg = cfg;
1462
1464
  this.track = track;
1463
1465
  this.session = null;
1464
1466
  this.cleanups = [];
1467
+ /** Last time we flushed lastActivityAt to storage (throttle gate). */
1468
+ this.lastPersistAt = 0;
1465
1469
  /**
1466
1470
  * Stable per-page-view identifier. Minted at every `page.viewed`
1467
1471
  * emission and attached to every subsequent event until the next
@@ -1473,6 +1477,8 @@ var AutoTracker = class {
1473
1477
  * install if `autoTrack.pageViews !== false`).
1474
1478
  */
1475
1479
  this.pageviewId = null;
1480
+ this.storage = opts?.storage ?? null;
1481
+ this.sessionKey = opts?.storageKey ?? SESSION_STORAGE_KEY;
1476
1482
  }
1477
1483
  install() {
1478
1484
  if (!isBrowserSafe()) return;
@@ -1491,6 +1497,7 @@ var AutoTracker = class {
1491
1497
  if (this.session && !this.session.endedSent) {
1492
1498
  this.emitSessionEnd();
1493
1499
  }
1500
+ this.clearStoredSession();
1494
1501
  this.session = null;
1495
1502
  }
1496
1503
  /** Exposed for tests + consumers that want to reset the session manually. */
@@ -1498,8 +1505,31 @@ var AutoTracker = class {
1498
1505
  if (this.session && !this.session.endedSent) this.emitSessionEnd();
1499
1506
  this.pageviewId = null;
1500
1507
  this.session = this.startNewSession();
1508
+ this.persistSession();
1501
1509
  this.emitSessionStart();
1502
1510
  }
1511
+ /**
1512
+ * Keep the rolling session window alive. Called by the host on EVERY
1513
+ * tracked event (auto or custom) so any activity — not just the
1514
+ * pageviews/clicks AutoTracker emits itself — pushes the 30-min idle
1515
+ * boundary forward. In-memory time is updated exactly; the storage
1516
+ * flush is throttled (pagehide forces a final flush).
1517
+ */
1518
+ markActivity() {
1519
+ if (!this.session) return;
1520
+ const now = Date.now();
1521
+ if (now - this.session.lastActivityAt >= SESSION_RESUME_THRESHOLD_MS) {
1522
+ this.pageviewId = null;
1523
+ this.session = this.startNewSession();
1524
+ this.persistSession();
1525
+ this.emitSessionStart();
1526
+ return;
1527
+ }
1528
+ this.session.lastActivityAt = now;
1529
+ if (now - this.lastPersistAt >= ACTIVITY_PERSIST_THROTTLE_MS) {
1530
+ this.persistSession();
1531
+ }
1532
+ }
1503
1533
  /** Exposed for inspection/tests — returns the current sessionId (or null if not in a session). */
1504
1534
  get currentSessionId() {
1505
1535
  return this.session?.sessionId ?? null;
@@ -1522,26 +1552,42 @@ var AutoTracker = class {
1522
1552
  }
1523
1553
  // ---------- sessions ----------
1524
1554
  installSessionTracking() {
1525
- this.session = this.startNewSession();
1526
- this.emitSessionStart();
1555
+ const now = Date.now();
1556
+ const stored = this.readStoredSession();
1557
+ if (stored && now - stored.lastActivityAt < SESSION_RESUME_THRESHOLD_MS) {
1558
+ this.session = {
1559
+ sessionId: stored.id,
1560
+ startedAt: stored.startedAt,
1561
+ lastActivityAt: now,
1562
+ hiddenAt: null,
1563
+ endedSent: false,
1564
+ acquisition: stored.acquisition
1565
+ };
1566
+ this.persistSession();
1567
+ } else {
1568
+ this.session = this.startNewSession();
1569
+ this.persistSession();
1570
+ this.emitSessionStart();
1571
+ }
1527
1572
  const onVisChange = () => {
1528
1573
  if (!this.session) return;
1529
1574
  const doc2 = globalThis.document;
1530
1575
  if (doc2.visibilityState === "hidden") {
1531
1576
  this.session.hiddenAt = Date.now();
1577
+ this.persistSession();
1532
1578
  } else if (doc2.visibilityState === "visible") {
1533
- const hiddenFor = this.session.hiddenAt ? Date.now() - this.session.hiddenAt : 0;
1534
- if (hiddenFor >= SESSION_RESUME_THRESHOLD_MS) {
1535
- this.emitSessionEnd();
1579
+ const idleFor = Date.now() - this.session.lastActivityAt;
1580
+ if (idleFor >= SESSION_RESUME_THRESHOLD_MS) {
1536
1581
  this.pageviewId = null;
1537
1582
  this.session = this.startNewSession();
1583
+ this.persistSession();
1538
1584
  this.emitSessionStart();
1539
1585
  } else {
1540
1586
  this.session.hiddenAt = null;
1541
1587
  }
1542
1588
  }
1543
1589
  };
1544
- const onPageHide = () => this.emitSessionEnd();
1590
+ const onPageHide = () => this.persistSession();
1545
1591
  const w = globalThis.window;
1546
1592
  const doc = globalThis.document;
1547
1593
  doc.addEventListener("visibilitychange", onVisChange);
@@ -1554,14 +1600,63 @@ var AutoTracker = class {
1554
1600
  });
1555
1601
  }
1556
1602
  startNewSession() {
1603
+ const now = Date.now();
1557
1604
  return {
1558
1605
  sessionId: mintSessionId(),
1559
- startedAt: Date.now(),
1606
+ startedAt: now,
1607
+ lastActivityAt: now,
1560
1608
  hiddenAt: null,
1561
1609
  endedSent: false,
1562
1610
  acquisition: captureAcquisition()
1563
1611
  };
1564
1612
  }
1613
+ /**
1614
+ * Read the persisted session continuity record. Returns null on no
1615
+ * storage, no record, malformed JSON, or a record missing its required
1616
+ * fields — every failure degrades to "no session to resume", which is
1617
+ * the safe (start-fresh) path.
1618
+ */
1619
+ readStoredSession() {
1620
+ if (!this.storage) return null;
1621
+ try {
1622
+ const raw = this.storage.getItem(this.sessionKey);
1623
+ if (!raw) return null;
1624
+ const p = JSON.parse(raw);
1625
+ if (!p || typeof p.id !== "string" || typeof p.startedAt !== "number" || typeof p.lastActivityAt !== "number") {
1626
+ return null;
1627
+ }
1628
+ return {
1629
+ id: p.id,
1630
+ startedAt: p.startedAt,
1631
+ lastActivityAt: p.lastActivityAt,
1632
+ acquisition: p.acquisition && typeof p.acquisition === "object" ? p.acquisition : EMPTY_ACQUISITION
1633
+ };
1634
+ } catch {
1635
+ return null;
1636
+ }
1637
+ }
1638
+ /** Flush the current session to storage. No-op without storage/session. */
1639
+ persistSession() {
1640
+ if (!this.storage || !this.session) return;
1641
+ this.lastPersistAt = Date.now();
1642
+ try {
1643
+ const rec = {
1644
+ id: this.session.sessionId,
1645
+ startedAt: this.session.startedAt,
1646
+ lastActivityAt: this.session.lastActivityAt,
1647
+ acquisition: this.session.acquisition
1648
+ };
1649
+ this.storage.setItem(this.sessionKey, JSON.stringify(rec));
1650
+ } catch {
1651
+ }
1652
+ }
1653
+ clearStoredSession() {
1654
+ if (!this.storage) return;
1655
+ try {
1656
+ this.storage.removeItem(this.sessionKey);
1657
+ } catch {
1658
+ }
1659
+ }
1565
1660
  emitSessionStart() {
1566
1661
  if (!this.session) return;
1567
1662
  this.track("session.started", { sessionId: this.session.sessionId });
@@ -1733,6 +1828,78 @@ function isInsidePasswordField(el) {
1733
1828
  if (el.closest('input[type="password"]')) return true;
1734
1829
  return false;
1735
1830
  }
1831
+ var INLINE_LABEL_TAGS = /* @__PURE__ */ new Set([
1832
+ "span",
1833
+ "b",
1834
+ "strong",
1835
+ "em",
1836
+ "i",
1837
+ "small",
1838
+ "mark",
1839
+ "u",
1840
+ "label",
1841
+ "abbr",
1842
+ "time",
1843
+ "bdi",
1844
+ "cite",
1845
+ "code",
1846
+ "kbd",
1847
+ "q",
1848
+ "sub",
1849
+ "sup"
1850
+ ]);
1851
+ var NON_LABEL_SUBTREES = /* @__PURE__ */ new Set(["svg", "style", "script", "noscript"]);
1852
+ var CONTAINER_MARKERS = "a[href], button, input, select, textarea, [role='button'], [role='link'], h1, h2, h3, h4, h5, h6";
1853
+ var HEADING_SELECTOR = "h1, h2, h3, h4, h5, h6";
1854
+ function collapseWs(s) {
1855
+ return s.replace(/\s+/g, " ").trim();
1856
+ }
1857
+ function boundaryText(el) {
1858
+ let out = "";
1859
+ const walk = (node) => {
1860
+ const kids = node.childNodes;
1861
+ for (let i = 0; i < kids.length; i++) {
1862
+ const child = kids[i];
1863
+ if (!child) continue;
1864
+ if (child.nodeType === 3) {
1865
+ out += child.textContent || "";
1866
+ } else if (child.nodeType === 1) {
1867
+ if (NON_LABEL_SUBTREES.has(child.tagName.toLowerCase())) continue;
1868
+ out += " ";
1869
+ walk(child);
1870
+ out += " ";
1871
+ }
1872
+ }
1873
+ };
1874
+ walk(el);
1875
+ return collapseWs(out);
1876
+ }
1877
+ function directLabel(el) {
1878
+ let out = "";
1879
+ const kids = el.childNodes;
1880
+ for (let i = 0; i < kids.length; i++) {
1881
+ const child = kids[i];
1882
+ if (!child) continue;
1883
+ if (child.nodeType === 3) {
1884
+ out += " " + (child.textContent || "");
1885
+ } else if (child.nodeType === 1 && INLINE_LABEL_TAGS.has(child.tagName.toLowerCase())) {
1886
+ out += " " + boundaryText(child);
1887
+ }
1888
+ }
1889
+ return collapseWs(out);
1890
+ }
1891
+ function visibleLabel(el) {
1892
+ const isContainer = el.querySelector(CONTAINER_MARKERS) !== null;
1893
+ if (!isContainer) return boundaryText(el);
1894
+ const direct = directLabel(el);
1895
+ if (direct) return direct;
1896
+ const heading = el.querySelector(HEADING_SELECTOR);
1897
+ if (heading) {
1898
+ const h = boundaryText(heading);
1899
+ if (h) return h;
1900
+ }
1901
+ return "";
1902
+ }
1736
1903
  function extractText(el) {
1737
1904
  const clean = (s) => s.replace(/\s+/g, " ").trim();
1738
1905
  const explicit = el.getAttribute("data-cd-track") || el.getAttribute("data-track") || el.getAttribute("data-testid");
@@ -1759,7 +1926,7 @@ function extractText(el) {
1759
1926
  const t = clean(el.value);
1760
1927
  if (t) return t;
1761
1928
  }
1762
- const text = clean(el.textContent || "");
1929
+ const text = visibleLabel(el);
1763
1930
  if (text) return text;
1764
1931
  const title = el.getAttribute("title");
1765
1932
  if (title) {
@@ -2472,6 +2639,210 @@ function sendDiagnosticTelemetry(payload) {
2472
2639
  }
2473
2640
  }
2474
2641
 
2642
+ // src/error-codes.ts
2643
+ var CROSSDECK_ERROR_CODES = Object.freeze([
2644
+ // ----- Configuration -----
2645
+ {
2646
+ code: "invalid_public_key",
2647
+ type: "configuration_error",
2648
+ description: "The publishable key passed to Crossdeck.init() doesn't start with cd_pub_.",
2649
+ resolution: "Copy the key from your Crossdeck dashboard \u2192 API keys page.",
2650
+ retryable: false
2651
+ },
2652
+ {
2653
+ code: "missing_app_id",
2654
+ type: "configuration_error",
2655
+ description: "Crossdeck.init() was called without an appId.",
2656
+ resolution: "Add appId to your init options \u2014 find it in the dashboard's Apps page.",
2657
+ retryable: false
2658
+ },
2659
+ {
2660
+ code: "invalid_environment",
2661
+ type: "configuration_error",
2662
+ description: "Crossdeck.init() requires environment: 'production' | 'sandbox'.",
2663
+ resolution: 'Pass the literal string "production" or "sandbox" \u2014 no other values are accepted.',
2664
+ retryable: false
2665
+ },
2666
+ {
2667
+ code: "environment_mismatch",
2668
+ type: "configuration_error",
2669
+ description: "The publishable key's env prefix doesn't match the declared environment option.",
2670
+ 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.",
2671
+ retryable: false
2672
+ },
2673
+ {
2674
+ code: "not_initialized",
2675
+ type: "configuration_error",
2676
+ description: "An SDK method was called before Crossdeck.init().",
2677
+ resolution: "Call Crossdeck.init({ appId, publicKey, environment }) once at app startup before any other method.",
2678
+ retryable: false
2679
+ },
2680
+ // ----- Identify / track / purchase argument validation -----
2681
+ {
2682
+ code: "missing_user_id",
2683
+ type: "invalid_request_error",
2684
+ description: "identify() was called with an empty userId.",
2685
+ resolution: "Pass a stable, non-empty user identifier from your auth layer \u2014 never a hardcoded placeholder.",
2686
+ retryable: false
2687
+ },
2688
+ {
2689
+ code: "missing_event_name",
2690
+ type: "invalid_request_error",
2691
+ description: "track() was called without an event name.",
2692
+ resolution: "Pass a non-empty string as the first argument.",
2693
+ retryable: false
2694
+ },
2695
+ {
2696
+ code: "missing_group_type",
2697
+ type: "invalid_request_error",
2698
+ description: "group() was called without a group type.",
2699
+ resolution: 'Pass a non-empty type (e.g. "org", "team") as the first argument.',
2700
+ retryable: false
2701
+ },
2702
+ {
2703
+ code: "missing_signed_transaction_info",
2704
+ type: "invalid_request_error",
2705
+ description: "syncPurchases() was called without StoreKit 2 signed transaction info.",
2706
+ resolution: "Pass the JWS string from Transaction.currentEntitlements / Transaction.updates.",
2707
+ retryable: false
2708
+ },
2709
+ // ----- Network / transport -----
2710
+ {
2711
+ code: "fetch_failed",
2712
+ type: "network_error",
2713
+ description: "The underlying fetch() call failed (typically a network outage or DNS issue).",
2714
+ resolution: "Check the user's network. The SDK will retry automatically with exponential backoff.",
2715
+ retryable: true
2716
+ },
2717
+ {
2718
+ code: "request_timeout",
2719
+ type: "network_error",
2720
+ description: "A request was aborted after the configured timeoutMs (default 15s).",
2721
+ resolution: "Check the user's connection. Increase timeoutMs in init options if the user is on a known-slow network.",
2722
+ retryable: true
2723
+ },
2724
+ {
2725
+ code: "invalid_json_response",
2726
+ type: "internal_error",
2727
+ description: "The server returned a 2xx with an unparseable body.",
2728
+ resolution: "Likely a transient backend bug. Retry; if it persists, contact support with the requestId.",
2729
+ retryable: true
2730
+ },
2731
+ // ----- Backend-emitted codes (v1.4.0 Phase 6.2 backfill) -----
2732
+ // Mirror of backend/src/api/v1-errors.ts ApiErrorCode. A developer
2733
+ // hitting any of these on the wire can look them up via
2734
+ // getErrorCode(code) for a canonical remediation step instead of
2735
+ // hunting through Slack history.
2736
+ {
2737
+ code: "missing_api_key",
2738
+ type: "authentication_error",
2739
+ description: "No Authorization header (or Crossdeck-Api-Key header) on the request.",
2740
+ 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.",
2741
+ retryable: false
2742
+ },
2743
+ {
2744
+ code: "invalid_api_key",
2745
+ type: "authentication_error",
2746
+ description: "The API key is malformed, unknown, or doesn't resolve to a project.",
2747
+ 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.",
2748
+ retryable: false
2749
+ },
2750
+ {
2751
+ code: "key_revoked",
2752
+ type: "authentication_error",
2753
+ description: "The API key was revoked in the Crossdeck dashboard.",
2754
+ 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.",
2755
+ retryable: false
2756
+ },
2757
+ {
2758
+ code: "identity_token_invalid",
2759
+ type: "authentication_error",
2760
+ description: "The Firebase / Apple / Google ID token supplied with the request didn't verify against the dashboard's configured signers.",
2761
+ 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.",
2762
+ retryable: true
2763
+ },
2764
+ {
2765
+ code: "origin_not_allowed",
2766
+ type: "permission_error",
2767
+ description: "The Origin header isn't in the project's Allowed origins list.",
2768
+ resolution: "Add the origin (e.g. https://app.example.com) under dashboard \u2192 Settings \u2192 Allowed origins. Wildcards like https://*.example.com are supported.",
2769
+ retryable: false
2770
+ },
2771
+ {
2772
+ code: "bundle_id_not_allowed",
2773
+ type: "permission_error",
2774
+ description: "The iOS bundle ID sent via X-Crossdeck-Bundle-Id isn't registered under this app's Apple identity lock.",
2775
+ resolution: "Add the bundle ID under dashboard \u2192 Apps \u2192 <your app> \u2192 iOS bundle IDs.",
2776
+ retryable: false
2777
+ },
2778
+ {
2779
+ code: "package_name_not_allowed",
2780
+ type: "permission_error",
2781
+ description: "The Android package name sent via X-Crossdeck-Package-Name isn't registered under this app's Android identity lock.",
2782
+ resolution: "Add the package name under dashboard \u2192 Apps \u2192 <your app> \u2192 Android package names.",
2783
+ retryable: false
2784
+ },
2785
+ {
2786
+ code: "env_mismatch",
2787
+ type: "permission_error",
2788
+ description: "The request env (inferred from key prefix) doesn't match the resolved app's configured env.",
2789
+ 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.",
2790
+ retryable: false
2791
+ },
2792
+ {
2793
+ code: "idempotency_key_in_use",
2794
+ type: "invalid_request_error",
2795
+ description: "An Idempotency-Key was reused for a request with a different body (Stripe-grade contract).",
2796
+ 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.",
2797
+ retryable: false
2798
+ },
2799
+ {
2800
+ code: "rate_limited",
2801
+ type: "rate_limit_error",
2802
+ description: "Request rate exceeded the project's per-second cap.",
2803
+ 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.",
2804
+ retryable: true
2805
+ },
2806
+ {
2807
+ code: "internal_error",
2808
+ type: "internal_error",
2809
+ description: "Server-side issue. Safe to retry with backoff.",
2810
+ resolution: "The SDK retries automatically. If your code paths through to this error, contact support with the requestId from the response envelope.",
2811
+ retryable: true
2812
+ },
2813
+ {
2814
+ code: "google_not_supported",
2815
+ type: "invalid_request_error",
2816
+ description: "POST /purchases/sync with rail=google is gated until the Play Developer API reconciliation worker ships.",
2817
+ resolution: "Until v1.5+, Google Play purchases verify via Real-time Developer Notifications. The SDK auto-track path handles this transparently for Android consumers.",
2818
+ retryable: false
2819
+ },
2820
+ {
2821
+ code: "stripe_not_supported",
2822
+ type: "invalid_request_error",
2823
+ description: "POST /purchases/sync with rail=stripe is unsupported \u2014 Stripe Checkout's redirect flow uses platform webhooks instead.",
2824
+ resolution: "Wire Stripe via the standard Checkout / Customer Portal flow; Crossdeck reconciles via the platform webhook automatically. No SDK call needed.",
2825
+ retryable: false
2826
+ },
2827
+ {
2828
+ code: "missing_required_param",
2829
+ type: "invalid_request_error",
2830
+ description: "A required field is absent from the request body.",
2831
+ 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.",
2832
+ retryable: false
2833
+ },
2834
+ {
2835
+ code: "invalid_param_value",
2836
+ type: "invalid_request_error",
2837
+ description: "A field is present but the value failed validation (wrong shape, wrong length, wrong enum value).",
2838
+ resolution: "Read error.message for the specific field + reason. SDK-managed call sites should never emit this \u2014 file a bug if you do.",
2839
+ retryable: false
2840
+ }
2841
+ ]);
2842
+ function getErrorCode(code) {
2843
+ return CROSSDECK_ERROR_CODES.find((e) => e.code === code);
2844
+ }
2845
+
2475
2846
  // src/_contract-verifiers.ts
2476
2847
  function buildVerifierContext(opts) {
2477
2848
  return {
@@ -2996,13 +3367,64 @@ var VERIFIER_CONTRACT_FAILED_PAYLOAD_SCHEMA_LOCK = {
2996
3367
  );
2997
3368
  }
2998
3369
  };
3370
+ var BACKEND_WIRE_CODES = Object.freeze([
3371
+ "missing_api_key",
3372
+ "invalid_api_key",
3373
+ "key_revoked",
3374
+ "identity_token_invalid",
3375
+ "origin_not_allowed",
3376
+ "bundle_id_not_allowed",
3377
+ "package_name_not_allowed",
3378
+ "env_mismatch",
3379
+ "idempotency_key_in_use",
3380
+ "rate_limited",
3381
+ "internal_error",
3382
+ "google_not_supported",
3383
+ "stripe_not_supported",
3384
+ "missing_required_param",
3385
+ "invalid_param_value"
3386
+ ]);
3387
+ var VERIFIER_SDK_ERROR_CODES_CATALOGUE = {
3388
+ contractId: "sdk-error-codes-catalogue",
3389
+ bootTest() {
3390
+ const t0 = nowMs();
3391
+ try {
3392
+ const missing = [];
3393
+ for (const code of BACKEND_WIRE_CODES) {
3394
+ const entry = getErrorCode(code);
3395
+ if (!entry || !entry.description || entry.description.trim().length === 0 || !entry.resolution || entry.resolution.trim().length === 0) {
3396
+ missing.push(code);
3397
+ }
3398
+ }
3399
+ if (missing.length > 0) {
3400
+ return fail(
3401
+ "sdk-error-codes-catalogue",
3402
+ `catalogue missing description+resolution for backend code(s): ${missing.join(", ")}`,
3403
+ nowMs() - t0
3404
+ );
3405
+ }
3406
+ return pass(
3407
+ "sdk-error-codes-catalogue",
3408
+ `all ${BACKEND_WIRE_CODES.length} backend wire codes carry description + resolution`,
3409
+ nowMs() - t0
3410
+ );
3411
+ } catch (err) {
3412
+ return fail(
3413
+ "sdk-error-codes-catalogue",
3414
+ `boot test threw: ${err.message?.slice(0, 80) ?? "unknown error"}`,
3415
+ nowMs() - t0
3416
+ );
3417
+ }
3418
+ }
3419
+ };
2999
3420
  var STATIC_VERIFIERS = Object.freeze([
3000
3421
  VERIFIER_PER_USER_CACHE_ISOLATION,
3001
3422
  VERIFIER_IDEMPOTENCY_KEY_DETERMINISTIC,
3002
3423
  VERIFIER_ERROR_ENVELOPE_SHAPE,
3003
3424
  VERIFIER_FLUSH_INTERVAL_PARITY,
3004
3425
  VERIFIER_SUPER_PROPERTY_MERGE_PRECEDENCE,
3005
- VERIFIER_CONTRACT_FAILED_PAYLOAD_SCHEMA_LOCK
3426
+ VERIFIER_CONTRACT_FAILED_PAYLOAD_SCHEMA_LOCK,
3427
+ VERIFIER_SDK_ERROR_CODES_CATALOGUE
3006
3428
  ]);
3007
3429
  async function runBootSelfTest(verifiers, reporter, ctx) {
3008
3430
  if (ctx.disableContractAssertions) {
@@ -3306,19 +3728,58 @@ var DEFAULT_ERROR_CAPTURE = {
3306
3728
  "ResizeObserver loop completed with undelivered notifications",
3307
3729
  "Non-Error promise rejection captured"
3308
3730
  // NOTE: We deliberately do NOT drop cross-origin "Script error."
3309
- // events here. They used to be silently filtered as noise, but
3310
- // silent drops are the opposite of "developer sleeps well at
3311
- // night". We now capture them with a clear label and the
3312
- // `cross_origin` tag so the dashboard surfaces them as a
3313
- // distinct, actionable category (the fix is always the same
3314
- // add `crossorigin="anonymous"` to the script tag + CORS
3315
- // headers on the script's origin). Apps that genuinely want
3316
- // them muted can re-add "Script error" to ignoreErrors via
3317
- // init config.
3731
+ // events here. The actionability principle (see denyUrls
3732
+ // comment below) draws the line at "developer cannot act":
3733
+ // cross-origin CORS opacity HAS a real, code-shaped fix (add
3734
+ // `crossorigin="anonymous"` to the script tag + CORS headers
3735
+ // on the script's origin). The dashboard surfaces these with a
3736
+ // `cross_origin` tag pointing at that fix. Apps that genuinely
3737
+ // want them muted can re-add "Script error" to ignoreErrors
3738
+ // via init config.
3318
3739
  ],
3319
3740
  allowUrls: [],
3320
3741
  denyUrls: [
3321
- // Common third-party extensions that pollute error streams.
3742
+ // The actionability principle
3743
+ // ---------------------------
3744
+ // Crossdeck's default philosophy is "classify, don't silently
3745
+ // drop" — surfacing errors the developer can fix is more useful
3746
+ // than hiding them. That's right for cross-origin "Script
3747
+ // error." (real, code-shaped CORS fix) and for plain
3748
+ // application bugs (obviously real).
3749
+ //
3750
+ // It's NOT right for events the developer cannot act on.
3751
+ // - A user's installed browser extension throwing inside
3752
+ // its own `chrome-extension://` URL: the developer can't
3753
+ // ship a fix for someone else's extension.
3754
+ // - An ad blocker preventing `googletagmanager.com` from
3755
+ // loading: the developer can't unblock the user's blocker.
3756
+ //
3757
+ // Capturing these creates a noise tab that's always non-empty
3758
+ // and never actionable, which trains the dev to ignore the
3759
+ // noise tab — and then the actionable noise (CORS opacity,
3760
+ // etc.) gets ignored along with it. Same lesson as Crossdeck's
3761
+ // "0-2 notifications a week" discipline: a signal that fires
3762
+ // constantly with nothing actionable behind it stops being a
3763
+ // signal.
3764
+ //
3765
+ // So we drop at the source for the unactionable category, and
3766
+ // keep capturing for the actionable category. Same principle
3767
+ // applied to two structurally different inputs — not a new
3768
+ // philosophy.
3769
+ //
3770
+ // The bootstrap list below is the minimum that ships in code.
3771
+ // The full, versioned list arrives at boot via /v1/config —
3772
+ // see `backend/src/lib/error-noise-deny-list.ts`. The SDK
3773
+ // applies the union of (bootstrap + remote), so a freshly-
3774
+ // installed SDK protects users immediately, and remote updates
3775
+ // (new ad-network domains, new pixel hosts) reach every
3776
+ // install without an SDK release.
3777
+ //
3778
+ // The backend Layer-2 classifier
3779
+ // (`backend/src/api/lib/noise-classifier.ts`) is the safety
3780
+ // net for events that slip past these patterns — older SDK
3781
+ // versions in the wild that haven't fetched the remote list
3782
+ // yet, or brand-new patterns the list hasn't named.
3322
3783
  /^chrome-extension:\/\//,
3323
3784
  /^moz-extension:\/\//,
3324
3785
  /^safari-extension:\/\//,
@@ -4111,7 +4572,12 @@ var CrossdeckClient = class {
4111
4572
  if (autoTrack.sessions || autoTrack.pageViews) {
4112
4573
  const tracker = new AutoTracker(
4113
4574
  autoTrack,
4114
- (name, properties) => this.track(name, properties)
4575
+ (name, properties) => this.track(name, properties),
4576
+ // Persist session continuity on the SAME adapter as identity, so
4577
+ // a visit survives full-page navigations (multi-page sites
4578
+ // re-install the SDK on every page) and honours the same consent
4579
+ // posture — MemoryStorage when identity persistence is off.
4580
+ { storage: effectiveStorage, storageKey: opts.storagePrefix + "session" }
4115
4581
  );
4116
4582
  this.state.autoTracker = tracker;
4117
4583
  tracker.install();
@@ -4576,6 +5042,12 @@ var CrossdeckClient = class {
4576
5042
  * true even before the session's first network round-trip. Returns
4577
5043
  * false only for a genuinely new install that has never completed a
4578
5044
  * getEntitlements(), or for an entitlement past its own validUntil.
5045
+ *
5046
+ * Throws `not_initialized` (CrossdeckError, type
5047
+ * `configuration_error`) if called before `Crossdeck.init()`. The
5048
+ * `useEntitlement` React hook and the Vue composable both swallow
5049
+ * this and return `false`; bare callers must guard for it (or call
5050
+ * after `init()` resolves).
4579
5051
  */
4580
5052
  isEntitled(key) {
4581
5053
  const s = this.requireStarted();
@@ -4591,8 +5063,21 @@ var CrossdeckClient = class {
4591
5063
  *
4592
5064
  * The listener is invoked AFTER the cache mutates — once after a
4593
5065
  * successful `getEntitlements()` warms it, again after `syncPurchases()`
4594
- * delivers fresh entitlements, and once on `reset()` to fire the
4595
- * empty-cache state for logout flows.
5066
+ * delivers fresh entitlements, once on `reset()` to fire the empty-
5067
+ * cache state for logout flows, AND once on `identify()` after the
5068
+ * per-user cache slot rotates and re-hydrates from device storage.
5069
+ *
5070
+ * IMPORTANT — the `identify()` fire is a TRAP if you treat it as
5071
+ * authoritative network state. `identify()` does NOT fetch entitlements;
5072
+ * it switches the per-user cache slot and rehydrates from device
5073
+ * storage (which is empty for a brand-new install, and last-known-good
5074
+ * — possibly stale — for a returning user). A listener that gates a
5075
+ * paywall on the first fire after an identity switch will read
5076
+ * `false` for a paying customer on a fresh device and let them past
5077
+ * the gate as free. The network-truth fire is the one that follows
5078
+ * the next `getEntitlements()` resolution. Either call
5079
+ * `getEntitlements()` explicitly after `identify()`, or have your
5080
+ * gating code tolerate the empty-then-populated transition.
4596
5081
  *
4597
5082
  * It is NOT invoked synchronously on subscribe. Callers that need
4598
5083
  * the current state should read it via `isEntitled()` / `listEntitlements()`
@@ -4701,6 +5186,7 @@ var CrossdeckClient = class {
4701
5186
  );
4702
5187
  }
4703
5188
  }
5189
+ s.autoTracker?.markActivity();
4704
5190
  const enriched = { ...s.deviceInfo };
4705
5191
  const sessionId = s.autoTracker?.currentSessionId;
4706
5192
  if (sessionId) enriched.sessionId = sessionId;
@@ -4955,6 +5441,25 @@ var CrossdeckClient = class {
4955
5441
  events: s.events.getStats()
4956
5442
  };
4957
5443
  }
5444
+ /**
5445
+ * The stable reference to hand Stripe at checkout so the resulting
5446
+ * purchase attributes to THIS person. Stamp it on the Checkout Session
5447
+ * as `metadata.crossdeck_ref` (see docs/connect-stripe) — the platform
5448
+ * webhook reads it back, validates it, and attaches the subscription to
5449
+ * the right customer instead of stranding it on an anonymous record.
5450
+ *
5451
+ * Returns the strongest identifier the SDK currently holds, in the same
5452
+ * precedence the server resolves by:
5453
+ * crossdeckCustomerId (cdcust_…) > developerUserId > anonymousId
5454
+ * anonymousId is always present, so this always returns a usable,
5455
+ * non-empty reference — even before identify(). Identify the user as
5456
+ * early as you can, though, so the reference is their stable identity
5457
+ * and not a per-device anon id.
5458
+ */
5459
+ getCheckoutReference() {
5460
+ const s = this.requireStarted();
5461
+ return s.identity.crossdeckCustomerId ?? s.developerUserId ?? s.identity.anonymousId;
5462
+ }
4958
5463
  // ---------- private helpers ----------
4959
5464
  requireStarted() {
4960
5465
  if (!this.state) {