@cross-deck/web 1.5.7 → 1.6.1

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.d.mts CHANGED
@@ -232,9 +232,9 @@ declare const CrossdeckContracts: {
232
232
  /** Filter by lifecycle status. */
233
233
  readonly withStatus: (status: ContractStatus) => readonly Contract[];
234
234
  /** Semver of the SDK release these contracts were bundled with. */
235
- readonly sdkVersion: "1.5.7";
235
+ readonly sdkVersion: "1.6.1";
236
236
  /** Fully-qualified bundle identifier — e.g. `@cross-deck/web@1.4.2`. */
237
- readonly bundledIn: "@cross-deck/web@1.5.7";
237
+ readonly bundledIn: "@cross-deck/web@1.6.1";
238
238
  /**
239
239
  * Resolve a failing test back to the contract it exercises.
240
240
  * Used by test-framework hooks (Vitest `afterEach`, XCTest
@@ -634,6 +634,12 @@ declare class CrossdeckClient {
634
634
  * true even before the session's first network round-trip. Returns
635
635
  * false only for a genuinely new install that has never completed a
636
636
  * getEntitlements(), or for an entitlement past its own validUntil.
637
+ *
638
+ * Throws `not_initialized` (CrossdeckError, type
639
+ * `configuration_error`) if called before `Crossdeck.init()`. The
640
+ * `useEntitlement` React hook and the Vue composable both swallow
641
+ * this and return `false`; bare callers must guard for it (or call
642
+ * after `init()` resolves).
637
643
  */
638
644
  isEntitled(key: string): boolean;
639
645
  /** Snapshot of the local entitlement cache. */
@@ -643,8 +649,21 @@ declare class CrossdeckClient {
643
649
  *
644
650
  * The listener is invoked AFTER the cache mutates — once after a
645
651
  * successful `getEntitlements()` warms it, again after `syncPurchases()`
646
- * delivers fresh entitlements, and once on `reset()` to fire the
647
- * empty-cache state for logout flows.
652
+ * delivers fresh entitlements, once on `reset()` to fire the empty-
653
+ * cache state for logout flows, AND once on `identify()` after the
654
+ * per-user cache slot rotates and re-hydrates from device storage.
655
+ *
656
+ * IMPORTANT — the `identify()` fire is a TRAP if you treat it as
657
+ * authoritative network state. `identify()` does NOT fetch entitlements;
658
+ * it switches the per-user cache slot and rehydrates from device
659
+ * storage (which is empty for a brand-new install, and last-known-good
660
+ * — possibly stale — for a returning user). A listener that gates a
661
+ * paywall on the first fire after an identity switch will read
662
+ * `false` for a paying customer on a fresh device and let them past
663
+ * the gate as free. The network-truth fire is the one that follows
664
+ * the next `getEntitlements()` resolution. Either call
665
+ * `getEntitlements()` explicitly after `identify()`, or have your
666
+ * gating code tolerate the empty-then-populated transition.
648
667
  *
649
668
  * It is NOT invoked synchronously on subscribe. Callers that need
650
669
  * the current state should read it via `isEntitled()` / `listEntitlements()`
@@ -748,6 +767,22 @@ declare class CrossdeckClient {
748
767
  * `entitlements`. Pre-start values are sensible empties.
749
768
  */
750
769
  diagnostics(): Diagnostics;
770
+ /**
771
+ * The stable reference to hand Stripe at checkout so the resulting
772
+ * purchase attributes to THIS person. Stamp it on the Checkout Session
773
+ * as `metadata.crossdeck_ref` (see docs/connect-stripe) — the platform
774
+ * webhook reads it back, validates it, and attaches the subscription to
775
+ * the right customer instead of stranding it on an anonymous record.
776
+ *
777
+ * Returns the strongest identifier the SDK currently holds, in the same
778
+ * precedence the server resolves by:
779
+ * crossdeckCustomerId (cdcust_…) > developerUserId > anonymousId
780
+ * anonymousId is always present, so this always returns a usable,
781
+ * non-empty reference — even before identify(). Identify the user as
782
+ * early as you can, though, so the reference is their stable identity
783
+ * and not a per-device anon id.
784
+ */
785
+ getCheckoutReference(): string;
751
786
  private requireStarted;
752
787
  /**
753
788
  * Build the identity query for /v1/entitlements. Priority:
@@ -886,7 +921,7 @@ declare class MemoryStorage implements KeyValueStorage {
886
921
  *
887
922
  * Do NOT edit by hand — `node scripts/sync-sdk-versions.mjs`.
888
923
  */
889
- declare const SDK_VERSION = "1.5.7";
924
+ declare const SDK_VERSION = "1.6.1";
890
925
  declare const SDK_NAME = "@cross-deck/web";
891
926
 
892
927
  /**
package/dist/index.d.ts CHANGED
@@ -232,9 +232,9 @@ declare const CrossdeckContracts: {
232
232
  /** Filter by lifecycle status. */
233
233
  readonly withStatus: (status: ContractStatus) => readonly Contract[];
234
234
  /** Semver of the SDK release these contracts were bundled with. */
235
- readonly sdkVersion: "1.5.7";
235
+ readonly sdkVersion: "1.6.1";
236
236
  /** Fully-qualified bundle identifier — e.g. `@cross-deck/web@1.4.2`. */
237
- readonly bundledIn: "@cross-deck/web@1.5.7";
237
+ readonly bundledIn: "@cross-deck/web@1.6.1";
238
238
  /**
239
239
  * Resolve a failing test back to the contract it exercises.
240
240
  * Used by test-framework hooks (Vitest `afterEach`, XCTest
@@ -634,6 +634,12 @@ declare class CrossdeckClient {
634
634
  * true even before the session's first network round-trip. Returns
635
635
  * false only for a genuinely new install that has never completed a
636
636
  * getEntitlements(), or for an entitlement past its own validUntil.
637
+ *
638
+ * Throws `not_initialized` (CrossdeckError, type
639
+ * `configuration_error`) if called before `Crossdeck.init()`. The
640
+ * `useEntitlement` React hook and the Vue composable both swallow
641
+ * this and return `false`; bare callers must guard for it (or call
642
+ * after `init()` resolves).
637
643
  */
638
644
  isEntitled(key: string): boolean;
639
645
  /** Snapshot of the local entitlement cache. */
@@ -643,8 +649,21 @@ declare class CrossdeckClient {
643
649
  *
644
650
  * The listener is invoked AFTER the cache mutates — once after a
645
651
  * successful `getEntitlements()` warms it, again after `syncPurchases()`
646
- * delivers fresh entitlements, and once on `reset()` to fire the
647
- * empty-cache state for logout flows.
652
+ * delivers fresh entitlements, once on `reset()` to fire the empty-
653
+ * cache state for logout flows, AND once on `identify()` after the
654
+ * per-user cache slot rotates and re-hydrates from device storage.
655
+ *
656
+ * IMPORTANT — the `identify()` fire is a TRAP if you treat it as
657
+ * authoritative network state. `identify()` does NOT fetch entitlements;
658
+ * it switches the per-user cache slot and rehydrates from device
659
+ * storage (which is empty for a brand-new install, and last-known-good
660
+ * — possibly stale — for a returning user). A listener that gates a
661
+ * paywall on the first fire after an identity switch will read
662
+ * `false` for a paying customer on a fresh device and let them past
663
+ * the gate as free. The network-truth fire is the one that follows
664
+ * the next `getEntitlements()` resolution. Either call
665
+ * `getEntitlements()` explicitly after `identify()`, or have your
666
+ * gating code tolerate the empty-then-populated transition.
648
667
  *
649
668
  * It is NOT invoked synchronously on subscribe. Callers that need
650
669
  * the current state should read it via `isEntitled()` / `listEntitlements()`
@@ -748,6 +767,22 @@ declare class CrossdeckClient {
748
767
  * `entitlements`. Pre-start values are sensible empties.
749
768
  */
750
769
  diagnostics(): Diagnostics;
770
+ /**
771
+ * The stable reference to hand Stripe at checkout so the resulting
772
+ * purchase attributes to THIS person. Stamp it on the Checkout Session
773
+ * as `metadata.crossdeck_ref` (see docs/connect-stripe) — the platform
774
+ * webhook reads it back, validates it, and attaches the subscription to
775
+ * the right customer instead of stranding it on an anonymous record.
776
+ *
777
+ * Returns the strongest identifier the SDK currently holds, in the same
778
+ * precedence the server resolves by:
779
+ * crossdeckCustomerId (cdcust_…) > developerUserId > anonymousId
780
+ * anonymousId is always present, so this always returns a usable,
781
+ * non-empty reference — even before identify(). Identify the user as
782
+ * early as you can, though, so the reference is their stable identity
783
+ * and not a per-device anon id.
784
+ */
785
+ getCheckoutReference(): string;
751
786
  private requireStarted;
752
787
  /**
753
788
  * Build the identity query for /v1/entitlements. Priority:
@@ -886,7 +921,7 @@ declare class MemoryStorage implements KeyValueStorage {
886
921
  *
887
922
  * Do NOT edit by hand — `node scripts/sync-sdk-versions.mjs`.
888
923
  */
889
- declare const SDK_VERSION = "1.5.7";
924
+ declare const SDK_VERSION = "1.6.1";
890
925
  declare const SDK_NAME = "@cross-deck/web";
891
926
 
892
927
  /**
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.1";
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,24 @@ 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
+ this.session.lastActivityAt = now;
1493
+ if (now - this.lastPersistAt >= ACTIVITY_PERSIST_THROTTLE_MS) {
1494
+ this.persistSession();
1495
+ }
1496
+ }
1474
1497
  /** Exposed for inspection/tests — returns the current sessionId (or null if not in a session). */
1475
1498
  get currentSessionId() {
1476
1499
  return this.session?.sessionId ?? null;
@@ -1493,26 +1516,43 @@ var AutoTracker = class {
1493
1516
  }
1494
1517
  // ---------- sessions ----------
1495
1518
  installSessionTracking() {
1496
- this.session = this.startNewSession();
1497
- this.emitSessionStart();
1519
+ const now = Date.now();
1520
+ const stored = this.readStoredSession();
1521
+ if (stored && now - stored.lastActivityAt < SESSION_RESUME_THRESHOLD_MS) {
1522
+ this.session = {
1523
+ sessionId: stored.id,
1524
+ startedAt: stored.startedAt,
1525
+ lastActivityAt: now,
1526
+ hiddenAt: null,
1527
+ endedSent: false,
1528
+ acquisition: stored.acquisition
1529
+ };
1530
+ this.persistSession();
1531
+ } else {
1532
+ this.session = this.startNewSession();
1533
+ this.persistSession();
1534
+ this.emitSessionStart();
1535
+ }
1498
1536
  const onVisChange = () => {
1499
1537
  if (!this.session) return;
1500
1538
  const doc2 = globalThis.document;
1501
1539
  if (doc2.visibilityState === "hidden") {
1502
1540
  this.session.hiddenAt = Date.now();
1541
+ this.persistSession();
1503
1542
  } else if (doc2.visibilityState === "visible") {
1504
- const hiddenFor = this.session.hiddenAt ? Date.now() - this.session.hiddenAt : 0;
1505
- if (hiddenFor >= SESSION_RESUME_THRESHOLD_MS) {
1543
+ const idleFor = Date.now() - this.session.lastActivityAt;
1544
+ if (idleFor >= SESSION_RESUME_THRESHOLD_MS) {
1506
1545
  this.emitSessionEnd();
1507
1546
  this.pageviewId = null;
1508
1547
  this.session = this.startNewSession();
1548
+ this.persistSession();
1509
1549
  this.emitSessionStart();
1510
1550
  } else {
1511
1551
  this.session.hiddenAt = null;
1512
1552
  }
1513
1553
  }
1514
1554
  };
1515
- const onPageHide = () => this.emitSessionEnd();
1555
+ const onPageHide = () => this.persistSession();
1516
1556
  const w = globalThis.window;
1517
1557
  const doc = globalThis.document;
1518
1558
  doc.addEventListener("visibilitychange", onVisChange);
@@ -1525,14 +1565,63 @@ var AutoTracker = class {
1525
1565
  });
1526
1566
  }
1527
1567
  startNewSession() {
1568
+ const now = Date.now();
1528
1569
  return {
1529
1570
  sessionId: mintSessionId(),
1530
- startedAt: Date.now(),
1571
+ startedAt: now,
1572
+ lastActivityAt: now,
1531
1573
  hiddenAt: null,
1532
1574
  endedSent: false,
1533
1575
  acquisition: captureAcquisition()
1534
1576
  };
1535
1577
  }
1578
+ /**
1579
+ * Read the persisted session continuity record. Returns null on no
1580
+ * storage, no record, malformed JSON, or a record missing its required
1581
+ * fields — every failure degrades to "no session to resume", which is
1582
+ * the safe (start-fresh) path.
1583
+ */
1584
+ readStoredSession() {
1585
+ if (!this.storage) return null;
1586
+ try {
1587
+ const raw = this.storage.getItem(this.sessionKey);
1588
+ if (!raw) return null;
1589
+ const p = JSON.parse(raw);
1590
+ if (!p || typeof p.id !== "string" || typeof p.startedAt !== "number" || typeof p.lastActivityAt !== "number") {
1591
+ return null;
1592
+ }
1593
+ return {
1594
+ id: p.id,
1595
+ startedAt: p.startedAt,
1596
+ lastActivityAt: p.lastActivityAt,
1597
+ acquisition: p.acquisition && typeof p.acquisition === "object" ? p.acquisition : EMPTY_ACQUISITION
1598
+ };
1599
+ } catch {
1600
+ return null;
1601
+ }
1602
+ }
1603
+ /** Flush the current session to storage. No-op without storage/session. */
1604
+ persistSession() {
1605
+ if (!this.storage || !this.session) return;
1606
+ this.lastPersistAt = Date.now();
1607
+ try {
1608
+ const rec = {
1609
+ id: this.session.sessionId,
1610
+ startedAt: this.session.startedAt,
1611
+ lastActivityAt: this.session.lastActivityAt,
1612
+ acquisition: this.session.acquisition
1613
+ };
1614
+ this.storage.setItem(this.sessionKey, JSON.stringify(rec));
1615
+ } catch {
1616
+ }
1617
+ }
1618
+ clearStoredSession() {
1619
+ if (!this.storage) return;
1620
+ try {
1621
+ this.storage.removeItem(this.sessionKey);
1622
+ } catch {
1623
+ }
1624
+ }
1536
1625
  emitSessionStart() {
1537
1626
  if (!this.session) return;
1538
1627
  this.track("session.started", { sessionId: this.session.sessionId });
@@ -1704,6 +1793,78 @@ function isInsidePasswordField(el) {
1704
1793
  if (el.closest('input[type="password"]')) return true;
1705
1794
  return false;
1706
1795
  }
1796
+ var INLINE_LABEL_TAGS = /* @__PURE__ */ new Set([
1797
+ "span",
1798
+ "b",
1799
+ "strong",
1800
+ "em",
1801
+ "i",
1802
+ "small",
1803
+ "mark",
1804
+ "u",
1805
+ "label",
1806
+ "abbr",
1807
+ "time",
1808
+ "bdi",
1809
+ "cite",
1810
+ "code",
1811
+ "kbd",
1812
+ "q",
1813
+ "sub",
1814
+ "sup"
1815
+ ]);
1816
+ var NON_LABEL_SUBTREES = /* @__PURE__ */ new Set(["svg", "style", "script", "noscript"]);
1817
+ var CONTAINER_MARKERS = "a[href], button, input, select, textarea, [role='button'], [role='link'], h1, h2, h3, h4, h5, h6";
1818
+ var HEADING_SELECTOR = "h1, h2, h3, h4, h5, h6";
1819
+ function collapseWs(s) {
1820
+ return s.replace(/\s+/g, " ").trim();
1821
+ }
1822
+ function boundaryText(el) {
1823
+ let out = "";
1824
+ const walk = (node) => {
1825
+ const kids = node.childNodes;
1826
+ for (let i = 0; i < kids.length; i++) {
1827
+ const child = kids[i];
1828
+ if (!child) continue;
1829
+ if (child.nodeType === 3) {
1830
+ out += child.textContent || "";
1831
+ } else if (child.nodeType === 1) {
1832
+ if (NON_LABEL_SUBTREES.has(child.tagName.toLowerCase())) continue;
1833
+ out += " ";
1834
+ walk(child);
1835
+ out += " ";
1836
+ }
1837
+ }
1838
+ };
1839
+ walk(el);
1840
+ return collapseWs(out);
1841
+ }
1842
+ function directLabel(el) {
1843
+ let out = "";
1844
+ const kids = el.childNodes;
1845
+ for (let i = 0; i < kids.length; i++) {
1846
+ const child = kids[i];
1847
+ if (!child) continue;
1848
+ if (child.nodeType === 3) {
1849
+ out += " " + (child.textContent || "");
1850
+ } else if (child.nodeType === 1 && INLINE_LABEL_TAGS.has(child.tagName.toLowerCase())) {
1851
+ out += " " + boundaryText(child);
1852
+ }
1853
+ }
1854
+ return collapseWs(out);
1855
+ }
1856
+ function visibleLabel(el) {
1857
+ const isContainer = el.querySelector(CONTAINER_MARKERS) !== null;
1858
+ if (!isContainer) return boundaryText(el);
1859
+ const direct = directLabel(el);
1860
+ if (direct) return direct;
1861
+ const heading = el.querySelector(HEADING_SELECTOR);
1862
+ if (heading) {
1863
+ const h = boundaryText(heading);
1864
+ if (h) return h;
1865
+ }
1866
+ return "";
1867
+ }
1707
1868
  function extractText(el) {
1708
1869
  const clean = (s) => s.replace(/\s+/g, " ").trim();
1709
1870
  const explicit = el.getAttribute("data-cd-track") || el.getAttribute("data-track") || el.getAttribute("data-testid");
@@ -1730,7 +1891,7 @@ function extractText(el) {
1730
1891
  const t = clean(el.value);
1731
1892
  if (t) return t;
1732
1893
  }
1733
- const text = clean(el.textContent || "");
1894
+ const text = visibleLabel(el);
1734
1895
  if (text) return text;
1735
1896
  const title = el.getAttribute("title");
1736
1897
  if (title) {
@@ -3277,19 +3438,58 @@ var DEFAULT_ERROR_CAPTURE = {
3277
3438
  "ResizeObserver loop completed with undelivered notifications",
3278
3439
  "Non-Error promise rejection captured"
3279
3440
  // 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.
3441
+ // events here. The actionability principle (see denyUrls
3442
+ // comment below) draws the line at "developer cannot act":
3443
+ // cross-origin CORS opacity HAS a real, code-shaped fix (add
3444
+ // `crossorigin="anonymous"` to the script tag + CORS headers
3445
+ // on the script's origin). The dashboard surfaces these with a
3446
+ // `cross_origin` tag pointing at that fix. Apps that genuinely
3447
+ // want them muted can re-add "Script error" to ignoreErrors
3448
+ // via init config.
3289
3449
  ],
3290
3450
  allowUrls: [],
3291
3451
  denyUrls: [
3292
- // Common third-party extensions that pollute error streams.
3452
+ // The actionability principle
3453
+ // ---------------------------
3454
+ // Crossdeck's default philosophy is "classify, don't silently
3455
+ // drop" — surfacing errors the developer can fix is more useful
3456
+ // than hiding them. That's right for cross-origin "Script
3457
+ // error." (real, code-shaped CORS fix) and for plain
3458
+ // application bugs (obviously real).
3459
+ //
3460
+ // It's NOT right for events the developer cannot act on.
3461
+ // - A user's installed browser extension throwing inside
3462
+ // its own `chrome-extension://` URL: the developer can't
3463
+ // ship a fix for someone else's extension.
3464
+ // - An ad blocker preventing `googletagmanager.com` from
3465
+ // loading: the developer can't unblock the user's blocker.
3466
+ //
3467
+ // Capturing these creates a noise tab that's always non-empty
3468
+ // and never actionable, which trains the dev to ignore the
3469
+ // noise tab — and then the actionable noise (CORS opacity,
3470
+ // etc.) gets ignored along with it. Same lesson as Crossdeck's
3471
+ // "0-2 notifications a week" discipline: a signal that fires
3472
+ // constantly with nothing actionable behind it stops being a
3473
+ // signal.
3474
+ //
3475
+ // So we drop at the source for the unactionable category, and
3476
+ // keep capturing for the actionable category. Same principle
3477
+ // applied to two structurally different inputs — not a new
3478
+ // philosophy.
3479
+ //
3480
+ // The bootstrap list below is the minimum that ships in code.
3481
+ // The full, versioned list arrives at boot via /v1/config —
3482
+ // see `backend/src/lib/error-noise-deny-list.ts`. The SDK
3483
+ // applies the union of (bootstrap + remote), so a freshly-
3484
+ // installed SDK protects users immediately, and remote updates
3485
+ // (new ad-network domains, new pixel hosts) reach every
3486
+ // install without an SDK release.
3487
+ //
3488
+ // The backend Layer-2 classifier
3489
+ // (`backend/src/api/lib/noise-classifier.ts`) is the safety
3490
+ // net for events that slip past these patterns — older SDK
3491
+ // versions in the wild that haven't fetched the remote list
3492
+ // yet, or brand-new patterns the list hasn't named.
3293
3493
  /^chrome-extension:\/\//,
3294
3494
  /^moz-extension:\/\//,
3295
3495
  /^safari-extension:\/\//,
@@ -4082,7 +4282,12 @@ var CrossdeckClient = class {
4082
4282
  if (autoTrack.sessions || autoTrack.pageViews) {
4083
4283
  const tracker = new AutoTracker(
4084
4284
  autoTrack,
4085
- (name, properties) => this.track(name, properties)
4285
+ (name, properties) => this.track(name, properties),
4286
+ // Persist session continuity on the SAME adapter as identity, so
4287
+ // a visit survives full-page navigations (multi-page sites
4288
+ // re-install the SDK on every page) and honours the same consent
4289
+ // posture — MemoryStorage when identity persistence is off.
4290
+ { storage: effectiveStorage, storageKey: opts.storagePrefix + "session" }
4086
4291
  );
4087
4292
  this.state.autoTracker = tracker;
4088
4293
  tracker.install();
@@ -4547,6 +4752,12 @@ var CrossdeckClient = class {
4547
4752
  * true even before the session's first network round-trip. Returns
4548
4753
  * false only for a genuinely new install that has never completed a
4549
4754
  * getEntitlements(), or for an entitlement past its own validUntil.
4755
+ *
4756
+ * Throws `not_initialized` (CrossdeckError, type
4757
+ * `configuration_error`) if called before `Crossdeck.init()`. The
4758
+ * `useEntitlement` React hook and the Vue composable both swallow
4759
+ * this and return `false`; bare callers must guard for it (or call
4760
+ * after `init()` resolves).
4550
4761
  */
4551
4762
  isEntitled(key) {
4552
4763
  const s = this.requireStarted();
@@ -4562,8 +4773,21 @@ var CrossdeckClient = class {
4562
4773
  *
4563
4774
  * The listener is invoked AFTER the cache mutates — once after a
4564
4775
  * 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.
4776
+ * delivers fresh entitlements, once on `reset()` to fire the empty-
4777
+ * cache state for logout flows, AND once on `identify()` after the
4778
+ * per-user cache slot rotates and re-hydrates from device storage.
4779
+ *
4780
+ * IMPORTANT — the `identify()` fire is a TRAP if you treat it as
4781
+ * authoritative network state. `identify()` does NOT fetch entitlements;
4782
+ * it switches the per-user cache slot and rehydrates from device
4783
+ * storage (which is empty for a brand-new install, and last-known-good
4784
+ * — possibly stale — for a returning user). A listener that gates a
4785
+ * paywall on the first fire after an identity switch will read
4786
+ * `false` for a paying customer on a fresh device and let them past
4787
+ * the gate as free. The network-truth fire is the one that follows
4788
+ * the next `getEntitlements()` resolution. Either call
4789
+ * `getEntitlements()` explicitly after `identify()`, or have your
4790
+ * gating code tolerate the empty-then-populated transition.
4567
4791
  *
4568
4792
  * It is NOT invoked synchronously on subscribe. Callers that need
4569
4793
  * the current state should read it via `isEntitled()` / `listEntitlements()`
@@ -4672,6 +4896,7 @@ var CrossdeckClient = class {
4672
4896
  );
4673
4897
  }
4674
4898
  }
4899
+ s.autoTracker?.markActivity();
4675
4900
  const enriched = { ...s.deviceInfo };
4676
4901
  const sessionId = s.autoTracker?.currentSessionId;
4677
4902
  if (sessionId) enriched.sessionId = sessionId;
@@ -4926,6 +5151,25 @@ var CrossdeckClient = class {
4926
5151
  events: s.events.getStats()
4927
5152
  };
4928
5153
  }
5154
+ /**
5155
+ * The stable reference to hand Stripe at checkout so the resulting
5156
+ * purchase attributes to THIS person. Stamp it on the Checkout Session
5157
+ * as `metadata.crossdeck_ref` (see docs/connect-stripe) — the platform
5158
+ * webhook reads it back, validates it, and attaches the subscription to
5159
+ * the right customer instead of stranding it on an anonymous record.
5160
+ *
5161
+ * Returns the strongest identifier the SDK currently holds, in the same
5162
+ * precedence the server resolves by:
5163
+ * crossdeckCustomerId (cdcust_…) > developerUserId > anonymousId
5164
+ * anonymousId is always present, so this always returns a usable,
5165
+ * non-empty reference — even before identify(). Identify the user as
5166
+ * early as you can, though, so the reference is their stable identity
5167
+ * and not a per-device anon id.
5168
+ */
5169
+ getCheckoutReference() {
5170
+ const s = this.requireStarted();
5171
+ return s.identity.crossdeckCustomerId ?? s.developerUserId ?? s.identity.anonymousId;
5172
+ }
4929
5173
  // ---------- private helpers ----------
4930
5174
  requireStarted() {
4931
5175
  if (!this.state) {
@@ -5247,8 +5491,8 @@ function getErrorCode(code) {
5247
5491
  }
5248
5492
 
5249
5493
  // src/_contracts-bundled.ts
5250
- var BUNDLED_IN = "@cross-deck/web@1.5.7";
5251
- var SDK_VERSION2 = "1.5.7";
5494
+ var BUNDLED_IN = "@cross-deck/web@1.6.1";
5495
+ var SDK_VERSION2 = "1.6.1";
5252
5496
  var BUNDLED_CONTRACTS = Object.freeze([
5253
5497
  {
5254
5498
  "id": "contract-failed-payload-schema-lock",
@@ -5370,7 +5614,7 @@ var BUNDLED_CONTRACTS = Object.freeze([
5370
5614
  "legal/security/index.html#diagnostic",
5371
5615
  "legal/sdk-data/index.html#b-diagnostic"
5372
5616
  ],
5373
- "bundledIn": "@cross-deck/web@1.5.7"
5617
+ "bundledIn": "@cross-deck/web@1.6.1"
5374
5618
  },
5375
5619
  {
5376
5620
  "id": "error-envelope-shape",
@@ -5409,7 +5653,7 @@ var BUNDLED_CONTRACTS = Object.freeze([
5409
5653
  ],
5410
5654
  "registeredAt": "2026-05-26",
5411
5655
  "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 8 (codifies existing contract)",
5412
- "bundledIn": "@cross-deck/web@1.5.7"
5656
+ "bundledIn": "@cross-deck/web@1.6.1"
5413
5657
  },
5414
5658
  {
5415
5659
  "id": "flush-interval-parity",
@@ -5454,7 +5698,7 @@ var BUNDLED_CONTRACTS = Object.freeze([
5454
5698
  ],
5455
5699
  "registeredAt": "2026-05-26",
5456
5700
  "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 3.3",
5457
- "bundledIn": "@cross-deck/web@1.5.7"
5701
+ "bundledIn": "@cross-deck/web@1.6.1"
5458
5702
  },
5459
5703
  {
5460
5704
  "id": "idempotency-key-deterministic",
@@ -5559,7 +5803,7 @@ var BUNDLED_CONTRACTS = Object.freeze([
5559
5803
  ],
5560
5804
  "registeredAt": "2026-05-26",
5561
5805
  "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"
5806
+ "bundledIn": "@cross-deck/web@1.6.1"
5563
5807
  },
5564
5808
  {
5565
5809
  "id": "init-reentry-drains-prior-queue",
@@ -5586,7 +5830,7 @@ var BUNDLED_CONTRACTS = Object.freeze([
5586
5830
  ],
5587
5831
  "registeredAt": "2026-05-26",
5588
5832
  "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 5.5",
5589
- "bundledIn": "@cross-deck/web@1.5.7"
5833
+ "bundledIn": "@cross-deck/web@1.6.1"
5590
5834
  },
5591
5835
  {
5592
5836
  "id": "per-user-cache-isolation",
@@ -5665,7 +5909,7 @@ var BUNDLED_CONTRACTS = Object.freeze([
5665
5909
  ],
5666
5910
  "registeredAt": "2026-05-26",
5667
5911
  "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"
5912
+ "bundledIn": "@cross-deck/web@1.6.1"
5669
5913
  },
5670
5914
  {
5671
5915
  "id": "sdk-error-codes-catalogue",
@@ -5705,7 +5949,7 @@ var BUNDLED_CONTRACTS = Object.freeze([
5705
5949
  ],
5706
5950
  "registeredAt": "2026-05-26",
5707
5951
  "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 6.2",
5708
- "bundledIn": "@cross-deck/web@1.5.7"
5952
+ "bundledIn": "@cross-deck/web@1.6.1"
5709
5953
  },
5710
5954
  {
5711
5955
  "id": "sync-purchases-funnel-parity",
@@ -5738,7 +5982,7 @@ var BUNDLED_CONTRACTS = Object.freeze([
5738
5982
  ],
5739
5983
  "registeredAt": "2026-05-26",
5740
5984
  "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 3.5",
5741
- "bundledIn": "@cross-deck/web@1.5.7"
5985
+ "bundledIn": "@cross-deck/web@1.6.1"
5742
5986
  }
5743
5987
  ]);
5744
5988