@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/CHANGELOG.md +72 -0
- package/README.md +6 -4
- package/dist/contracts.json +58 -12
- package/dist/crossdeck.umd.min.js +2 -2
- package/dist/crossdeck.umd.min.js.map +1 -1
- package/dist/error-codes.json +1 -1
- package/dist/index.cjs +594 -238
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +55 -5
- package/dist/index.d.ts +55 -5
- package/dist/index.mjs +594 -238
- package/dist/index.mjs.map +1 -1
- package/dist/react.cjs +529 -24
- package/dist/react.cjs.map +1 -1
- package/dist/react.mjs +529 -24
- package/dist/react.mjs.map +1 -1
- package/dist/vue.cjs +529 -24
- package/dist/vue.cjs.map +1 -1
- package/dist/vue.mjs +529 -24
- package/dist/vue.mjs.map +1 -1
- package/package.json +1 -1
package/dist/vue.mjs
CHANGED
|
@@ -67,7 +67,7 @@ function typeMapForStatus(status) {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
// src/_version.ts
|
|
70
|
-
var SDK_VERSION = "1.
|
|
70
|
+
var SDK_VERSION = "1.6.2";
|
|
71
71
|
var SDK_NAME = "@cross-deck/web";
|
|
72
72
|
|
|
73
73
|
// src/http.ts
|
|
@@ -1416,6 +1416,8 @@ var DEFAULT_AUTO_TRACK = {
|
|
|
1416
1416
|
errors: true
|
|
1417
1417
|
};
|
|
1418
1418
|
var SESSION_RESUME_THRESHOLD_MS = 30 * 60 * 1e3;
|
|
1419
|
+
var SESSION_STORAGE_KEY = "crossdeck:session";
|
|
1420
|
+
var ACTIVITY_PERSIST_THROTTLE_MS = 5e3;
|
|
1419
1421
|
var EMPTY_ACQUISITION = {
|
|
1420
1422
|
utm_source: "",
|
|
1421
1423
|
utm_medium: "",
|
|
@@ -1431,11 +1433,13 @@ var EMPTY_ACQUISITION = {
|
|
|
1431
1433
|
twclid: ""
|
|
1432
1434
|
};
|
|
1433
1435
|
var AutoTracker = class {
|
|
1434
|
-
constructor(cfg, track) {
|
|
1436
|
+
constructor(cfg, track, opts) {
|
|
1435
1437
|
this.cfg = cfg;
|
|
1436
1438
|
this.track = track;
|
|
1437
1439
|
this.session = null;
|
|
1438
1440
|
this.cleanups = [];
|
|
1441
|
+
/** Last time we flushed lastActivityAt to storage (throttle gate). */
|
|
1442
|
+
this.lastPersistAt = 0;
|
|
1439
1443
|
/**
|
|
1440
1444
|
* Stable per-page-view identifier. Minted at every `page.viewed`
|
|
1441
1445
|
* emission and attached to every subsequent event until the next
|
|
@@ -1447,6 +1451,8 @@ var AutoTracker = class {
|
|
|
1447
1451
|
* install if `autoTrack.pageViews !== false`).
|
|
1448
1452
|
*/
|
|
1449
1453
|
this.pageviewId = null;
|
|
1454
|
+
this.storage = opts?.storage ?? null;
|
|
1455
|
+
this.sessionKey = opts?.storageKey ?? SESSION_STORAGE_KEY;
|
|
1450
1456
|
}
|
|
1451
1457
|
install() {
|
|
1452
1458
|
if (!isBrowserSafe()) return;
|
|
@@ -1465,6 +1471,7 @@ var AutoTracker = class {
|
|
|
1465
1471
|
if (this.session && !this.session.endedSent) {
|
|
1466
1472
|
this.emitSessionEnd();
|
|
1467
1473
|
}
|
|
1474
|
+
this.clearStoredSession();
|
|
1468
1475
|
this.session = null;
|
|
1469
1476
|
}
|
|
1470
1477
|
/** Exposed for tests + consumers that want to reset the session manually. */
|
|
@@ -1472,8 +1479,31 @@ var AutoTracker = class {
|
|
|
1472
1479
|
if (this.session && !this.session.endedSent) this.emitSessionEnd();
|
|
1473
1480
|
this.pageviewId = null;
|
|
1474
1481
|
this.session = this.startNewSession();
|
|
1482
|
+
this.persistSession();
|
|
1475
1483
|
this.emitSessionStart();
|
|
1476
1484
|
}
|
|
1485
|
+
/**
|
|
1486
|
+
* Keep the rolling session window alive. Called by the host on EVERY
|
|
1487
|
+
* tracked event (auto or custom) so any activity — not just the
|
|
1488
|
+
* pageviews/clicks AutoTracker emits itself — pushes the 30-min idle
|
|
1489
|
+
* boundary forward. In-memory time is updated exactly; the storage
|
|
1490
|
+
* flush is throttled (pagehide forces a final flush).
|
|
1491
|
+
*/
|
|
1492
|
+
markActivity() {
|
|
1493
|
+
if (!this.session) return;
|
|
1494
|
+
const now = Date.now();
|
|
1495
|
+
if (now - this.session.lastActivityAt >= SESSION_RESUME_THRESHOLD_MS) {
|
|
1496
|
+
this.pageviewId = null;
|
|
1497
|
+
this.session = this.startNewSession();
|
|
1498
|
+
this.persistSession();
|
|
1499
|
+
this.emitSessionStart();
|
|
1500
|
+
return;
|
|
1501
|
+
}
|
|
1502
|
+
this.session.lastActivityAt = now;
|
|
1503
|
+
if (now - this.lastPersistAt >= ACTIVITY_PERSIST_THROTTLE_MS) {
|
|
1504
|
+
this.persistSession();
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1477
1507
|
/** Exposed for inspection/tests — returns the current sessionId (or null if not in a session). */
|
|
1478
1508
|
get currentSessionId() {
|
|
1479
1509
|
return this.session?.sessionId ?? null;
|
|
@@ -1496,26 +1526,42 @@ var AutoTracker = class {
|
|
|
1496
1526
|
}
|
|
1497
1527
|
// ---------- sessions ----------
|
|
1498
1528
|
installSessionTracking() {
|
|
1499
|
-
|
|
1500
|
-
this.
|
|
1529
|
+
const now = Date.now();
|
|
1530
|
+
const stored = this.readStoredSession();
|
|
1531
|
+
if (stored && now - stored.lastActivityAt < SESSION_RESUME_THRESHOLD_MS) {
|
|
1532
|
+
this.session = {
|
|
1533
|
+
sessionId: stored.id,
|
|
1534
|
+
startedAt: stored.startedAt,
|
|
1535
|
+
lastActivityAt: now,
|
|
1536
|
+
hiddenAt: null,
|
|
1537
|
+
endedSent: false,
|
|
1538
|
+
acquisition: stored.acquisition
|
|
1539
|
+
};
|
|
1540
|
+
this.persistSession();
|
|
1541
|
+
} else {
|
|
1542
|
+
this.session = this.startNewSession();
|
|
1543
|
+
this.persistSession();
|
|
1544
|
+
this.emitSessionStart();
|
|
1545
|
+
}
|
|
1501
1546
|
const onVisChange = () => {
|
|
1502
1547
|
if (!this.session) return;
|
|
1503
1548
|
const doc2 = globalThis.document;
|
|
1504
1549
|
if (doc2.visibilityState === "hidden") {
|
|
1505
1550
|
this.session.hiddenAt = Date.now();
|
|
1551
|
+
this.persistSession();
|
|
1506
1552
|
} else if (doc2.visibilityState === "visible") {
|
|
1507
|
-
const
|
|
1508
|
-
if (
|
|
1509
|
-
this.emitSessionEnd();
|
|
1553
|
+
const idleFor = Date.now() - this.session.lastActivityAt;
|
|
1554
|
+
if (idleFor >= SESSION_RESUME_THRESHOLD_MS) {
|
|
1510
1555
|
this.pageviewId = null;
|
|
1511
1556
|
this.session = this.startNewSession();
|
|
1557
|
+
this.persistSession();
|
|
1512
1558
|
this.emitSessionStart();
|
|
1513
1559
|
} else {
|
|
1514
1560
|
this.session.hiddenAt = null;
|
|
1515
1561
|
}
|
|
1516
1562
|
}
|
|
1517
1563
|
};
|
|
1518
|
-
const onPageHide = () => this.
|
|
1564
|
+
const onPageHide = () => this.persistSession();
|
|
1519
1565
|
const w = globalThis.window;
|
|
1520
1566
|
const doc = globalThis.document;
|
|
1521
1567
|
doc.addEventListener("visibilitychange", onVisChange);
|
|
@@ -1528,14 +1574,63 @@ var AutoTracker = class {
|
|
|
1528
1574
|
});
|
|
1529
1575
|
}
|
|
1530
1576
|
startNewSession() {
|
|
1577
|
+
const now = Date.now();
|
|
1531
1578
|
return {
|
|
1532
1579
|
sessionId: mintSessionId(),
|
|
1533
|
-
startedAt:
|
|
1580
|
+
startedAt: now,
|
|
1581
|
+
lastActivityAt: now,
|
|
1534
1582
|
hiddenAt: null,
|
|
1535
1583
|
endedSent: false,
|
|
1536
1584
|
acquisition: captureAcquisition()
|
|
1537
1585
|
};
|
|
1538
1586
|
}
|
|
1587
|
+
/**
|
|
1588
|
+
* Read the persisted session continuity record. Returns null on no
|
|
1589
|
+
* storage, no record, malformed JSON, or a record missing its required
|
|
1590
|
+
* fields — every failure degrades to "no session to resume", which is
|
|
1591
|
+
* the safe (start-fresh) path.
|
|
1592
|
+
*/
|
|
1593
|
+
readStoredSession() {
|
|
1594
|
+
if (!this.storage) return null;
|
|
1595
|
+
try {
|
|
1596
|
+
const raw = this.storage.getItem(this.sessionKey);
|
|
1597
|
+
if (!raw) return null;
|
|
1598
|
+
const p = JSON.parse(raw);
|
|
1599
|
+
if (!p || typeof p.id !== "string" || typeof p.startedAt !== "number" || typeof p.lastActivityAt !== "number") {
|
|
1600
|
+
return null;
|
|
1601
|
+
}
|
|
1602
|
+
return {
|
|
1603
|
+
id: p.id,
|
|
1604
|
+
startedAt: p.startedAt,
|
|
1605
|
+
lastActivityAt: p.lastActivityAt,
|
|
1606
|
+
acquisition: p.acquisition && typeof p.acquisition === "object" ? p.acquisition : EMPTY_ACQUISITION
|
|
1607
|
+
};
|
|
1608
|
+
} catch {
|
|
1609
|
+
return null;
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
/** Flush the current session to storage. No-op without storage/session. */
|
|
1613
|
+
persistSession() {
|
|
1614
|
+
if (!this.storage || !this.session) return;
|
|
1615
|
+
this.lastPersistAt = Date.now();
|
|
1616
|
+
try {
|
|
1617
|
+
const rec = {
|
|
1618
|
+
id: this.session.sessionId,
|
|
1619
|
+
startedAt: this.session.startedAt,
|
|
1620
|
+
lastActivityAt: this.session.lastActivityAt,
|
|
1621
|
+
acquisition: this.session.acquisition
|
|
1622
|
+
};
|
|
1623
|
+
this.storage.setItem(this.sessionKey, JSON.stringify(rec));
|
|
1624
|
+
} catch {
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
clearStoredSession() {
|
|
1628
|
+
if (!this.storage) return;
|
|
1629
|
+
try {
|
|
1630
|
+
this.storage.removeItem(this.sessionKey);
|
|
1631
|
+
} catch {
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1539
1634
|
emitSessionStart() {
|
|
1540
1635
|
if (!this.session) return;
|
|
1541
1636
|
this.track("session.started", { sessionId: this.session.sessionId });
|
|
@@ -1707,6 +1802,78 @@ function isInsidePasswordField(el) {
|
|
|
1707
1802
|
if (el.closest('input[type="password"]')) return true;
|
|
1708
1803
|
return false;
|
|
1709
1804
|
}
|
|
1805
|
+
var INLINE_LABEL_TAGS = /* @__PURE__ */ new Set([
|
|
1806
|
+
"span",
|
|
1807
|
+
"b",
|
|
1808
|
+
"strong",
|
|
1809
|
+
"em",
|
|
1810
|
+
"i",
|
|
1811
|
+
"small",
|
|
1812
|
+
"mark",
|
|
1813
|
+
"u",
|
|
1814
|
+
"label",
|
|
1815
|
+
"abbr",
|
|
1816
|
+
"time",
|
|
1817
|
+
"bdi",
|
|
1818
|
+
"cite",
|
|
1819
|
+
"code",
|
|
1820
|
+
"kbd",
|
|
1821
|
+
"q",
|
|
1822
|
+
"sub",
|
|
1823
|
+
"sup"
|
|
1824
|
+
]);
|
|
1825
|
+
var NON_LABEL_SUBTREES = /* @__PURE__ */ new Set(["svg", "style", "script", "noscript"]);
|
|
1826
|
+
var CONTAINER_MARKERS = "a[href], button, input, select, textarea, [role='button'], [role='link'], h1, h2, h3, h4, h5, h6";
|
|
1827
|
+
var HEADING_SELECTOR = "h1, h2, h3, h4, h5, h6";
|
|
1828
|
+
function collapseWs(s) {
|
|
1829
|
+
return s.replace(/\s+/g, " ").trim();
|
|
1830
|
+
}
|
|
1831
|
+
function boundaryText(el) {
|
|
1832
|
+
let out = "";
|
|
1833
|
+
const walk = (node) => {
|
|
1834
|
+
const kids = node.childNodes;
|
|
1835
|
+
for (let i = 0; i < kids.length; i++) {
|
|
1836
|
+
const child = kids[i];
|
|
1837
|
+
if (!child) continue;
|
|
1838
|
+
if (child.nodeType === 3) {
|
|
1839
|
+
out += child.textContent || "";
|
|
1840
|
+
} else if (child.nodeType === 1) {
|
|
1841
|
+
if (NON_LABEL_SUBTREES.has(child.tagName.toLowerCase())) continue;
|
|
1842
|
+
out += " ";
|
|
1843
|
+
walk(child);
|
|
1844
|
+
out += " ";
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
};
|
|
1848
|
+
walk(el);
|
|
1849
|
+
return collapseWs(out);
|
|
1850
|
+
}
|
|
1851
|
+
function directLabel(el) {
|
|
1852
|
+
let out = "";
|
|
1853
|
+
const kids = el.childNodes;
|
|
1854
|
+
for (let i = 0; i < kids.length; i++) {
|
|
1855
|
+
const child = kids[i];
|
|
1856
|
+
if (!child) continue;
|
|
1857
|
+
if (child.nodeType === 3) {
|
|
1858
|
+
out += " " + (child.textContent || "");
|
|
1859
|
+
} else if (child.nodeType === 1 && INLINE_LABEL_TAGS.has(child.tagName.toLowerCase())) {
|
|
1860
|
+
out += " " + boundaryText(child);
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
return collapseWs(out);
|
|
1864
|
+
}
|
|
1865
|
+
function visibleLabel(el) {
|
|
1866
|
+
const isContainer = el.querySelector(CONTAINER_MARKERS) !== null;
|
|
1867
|
+
if (!isContainer) return boundaryText(el);
|
|
1868
|
+
const direct = directLabel(el);
|
|
1869
|
+
if (direct) return direct;
|
|
1870
|
+
const heading = el.querySelector(HEADING_SELECTOR);
|
|
1871
|
+
if (heading) {
|
|
1872
|
+
const h = boundaryText(heading);
|
|
1873
|
+
if (h) return h;
|
|
1874
|
+
}
|
|
1875
|
+
return "";
|
|
1876
|
+
}
|
|
1710
1877
|
function extractText(el) {
|
|
1711
1878
|
const clean = (s) => s.replace(/\s+/g, " ").trim();
|
|
1712
1879
|
const explicit = el.getAttribute("data-cd-track") || el.getAttribute("data-track") || el.getAttribute("data-testid");
|
|
@@ -1733,7 +1900,7 @@ function extractText(el) {
|
|
|
1733
1900
|
const t = clean(el.value);
|
|
1734
1901
|
if (t) return t;
|
|
1735
1902
|
}
|
|
1736
|
-
const text =
|
|
1903
|
+
const text = visibleLabel(el);
|
|
1737
1904
|
if (text) return text;
|
|
1738
1905
|
const title = el.getAttribute("title");
|
|
1739
1906
|
if (title) {
|
|
@@ -2446,6 +2613,210 @@ function sendDiagnosticTelemetry(payload) {
|
|
|
2446
2613
|
}
|
|
2447
2614
|
}
|
|
2448
2615
|
|
|
2616
|
+
// src/error-codes.ts
|
|
2617
|
+
var CROSSDECK_ERROR_CODES = Object.freeze([
|
|
2618
|
+
// ----- Configuration -----
|
|
2619
|
+
{
|
|
2620
|
+
code: "invalid_public_key",
|
|
2621
|
+
type: "configuration_error",
|
|
2622
|
+
description: "The publishable key passed to Crossdeck.init() doesn't start with cd_pub_.",
|
|
2623
|
+
resolution: "Copy the key from your Crossdeck dashboard \u2192 API keys page.",
|
|
2624
|
+
retryable: false
|
|
2625
|
+
},
|
|
2626
|
+
{
|
|
2627
|
+
code: "missing_app_id",
|
|
2628
|
+
type: "configuration_error",
|
|
2629
|
+
description: "Crossdeck.init() was called without an appId.",
|
|
2630
|
+
resolution: "Add appId to your init options \u2014 find it in the dashboard's Apps page.",
|
|
2631
|
+
retryable: false
|
|
2632
|
+
},
|
|
2633
|
+
{
|
|
2634
|
+
code: "invalid_environment",
|
|
2635
|
+
type: "configuration_error",
|
|
2636
|
+
description: "Crossdeck.init() requires environment: 'production' | 'sandbox'.",
|
|
2637
|
+
resolution: 'Pass the literal string "production" or "sandbox" \u2014 no other values are accepted.',
|
|
2638
|
+
retryable: false
|
|
2639
|
+
},
|
|
2640
|
+
{
|
|
2641
|
+
code: "environment_mismatch",
|
|
2642
|
+
type: "configuration_error",
|
|
2643
|
+
description: "The publishable key's env prefix doesn't match the declared environment option.",
|
|
2644
|
+
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.",
|
|
2645
|
+
retryable: false
|
|
2646
|
+
},
|
|
2647
|
+
{
|
|
2648
|
+
code: "not_initialized",
|
|
2649
|
+
type: "configuration_error",
|
|
2650
|
+
description: "An SDK method was called before Crossdeck.init().",
|
|
2651
|
+
resolution: "Call Crossdeck.init({ appId, publicKey, environment }) once at app startup before any other method.",
|
|
2652
|
+
retryable: false
|
|
2653
|
+
},
|
|
2654
|
+
// ----- Identify / track / purchase argument validation -----
|
|
2655
|
+
{
|
|
2656
|
+
code: "missing_user_id",
|
|
2657
|
+
type: "invalid_request_error",
|
|
2658
|
+
description: "identify() was called with an empty userId.",
|
|
2659
|
+
resolution: "Pass a stable, non-empty user identifier from your auth layer \u2014 never a hardcoded placeholder.",
|
|
2660
|
+
retryable: false
|
|
2661
|
+
},
|
|
2662
|
+
{
|
|
2663
|
+
code: "missing_event_name",
|
|
2664
|
+
type: "invalid_request_error",
|
|
2665
|
+
description: "track() was called without an event name.",
|
|
2666
|
+
resolution: "Pass a non-empty string as the first argument.",
|
|
2667
|
+
retryable: false
|
|
2668
|
+
},
|
|
2669
|
+
{
|
|
2670
|
+
code: "missing_group_type",
|
|
2671
|
+
type: "invalid_request_error",
|
|
2672
|
+
description: "group() was called without a group type.",
|
|
2673
|
+
resolution: 'Pass a non-empty type (e.g. "org", "team") as the first argument.',
|
|
2674
|
+
retryable: false
|
|
2675
|
+
},
|
|
2676
|
+
{
|
|
2677
|
+
code: "missing_signed_transaction_info",
|
|
2678
|
+
type: "invalid_request_error",
|
|
2679
|
+
description: "syncPurchases() was called without StoreKit 2 signed transaction info.",
|
|
2680
|
+
resolution: "Pass the JWS string from Transaction.currentEntitlements / Transaction.updates.",
|
|
2681
|
+
retryable: false
|
|
2682
|
+
},
|
|
2683
|
+
// ----- Network / transport -----
|
|
2684
|
+
{
|
|
2685
|
+
code: "fetch_failed",
|
|
2686
|
+
type: "network_error",
|
|
2687
|
+
description: "The underlying fetch() call failed (typically a network outage or DNS issue).",
|
|
2688
|
+
resolution: "Check the user's network. The SDK will retry automatically with exponential backoff.",
|
|
2689
|
+
retryable: true
|
|
2690
|
+
},
|
|
2691
|
+
{
|
|
2692
|
+
code: "request_timeout",
|
|
2693
|
+
type: "network_error",
|
|
2694
|
+
description: "A request was aborted after the configured timeoutMs (default 15s).",
|
|
2695
|
+
resolution: "Check the user's connection. Increase timeoutMs in init options if the user is on a known-slow network.",
|
|
2696
|
+
retryable: true
|
|
2697
|
+
},
|
|
2698
|
+
{
|
|
2699
|
+
code: "invalid_json_response",
|
|
2700
|
+
type: "internal_error",
|
|
2701
|
+
description: "The server returned a 2xx with an unparseable body.",
|
|
2702
|
+
resolution: "Likely a transient backend bug. Retry; if it persists, contact support with the requestId.",
|
|
2703
|
+
retryable: true
|
|
2704
|
+
},
|
|
2705
|
+
// ----- Backend-emitted codes (v1.4.0 Phase 6.2 backfill) -----
|
|
2706
|
+
// Mirror of backend/src/api/v1-errors.ts ApiErrorCode. A developer
|
|
2707
|
+
// hitting any of these on the wire can look them up via
|
|
2708
|
+
// getErrorCode(code) for a canonical remediation step instead of
|
|
2709
|
+
// hunting through Slack history.
|
|
2710
|
+
{
|
|
2711
|
+
code: "missing_api_key",
|
|
2712
|
+
type: "authentication_error",
|
|
2713
|
+
description: "No Authorization header (or Crossdeck-Api-Key header) on the request.",
|
|
2714
|
+
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.",
|
|
2715
|
+
retryable: false
|
|
2716
|
+
},
|
|
2717
|
+
{
|
|
2718
|
+
code: "invalid_api_key",
|
|
2719
|
+
type: "authentication_error",
|
|
2720
|
+
description: "The API key is malformed, unknown, or doesn't resolve to a project.",
|
|
2721
|
+
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.",
|
|
2722
|
+
retryable: false
|
|
2723
|
+
},
|
|
2724
|
+
{
|
|
2725
|
+
code: "key_revoked",
|
|
2726
|
+
type: "authentication_error",
|
|
2727
|
+
description: "The API key was revoked in the Crossdeck dashboard.",
|
|
2728
|
+
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.",
|
|
2729
|
+
retryable: false
|
|
2730
|
+
},
|
|
2731
|
+
{
|
|
2732
|
+
code: "identity_token_invalid",
|
|
2733
|
+
type: "authentication_error",
|
|
2734
|
+
description: "The Firebase / Apple / Google ID token supplied with the request didn't verify against the dashboard's configured signers.",
|
|
2735
|
+
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.",
|
|
2736
|
+
retryable: true
|
|
2737
|
+
},
|
|
2738
|
+
{
|
|
2739
|
+
code: "origin_not_allowed",
|
|
2740
|
+
type: "permission_error",
|
|
2741
|
+
description: "The Origin header isn't in the project's Allowed origins list.",
|
|
2742
|
+
resolution: "Add the origin (e.g. https://app.example.com) under dashboard \u2192 Settings \u2192 Allowed origins. Wildcards like https://*.example.com are supported.",
|
|
2743
|
+
retryable: false
|
|
2744
|
+
},
|
|
2745
|
+
{
|
|
2746
|
+
code: "bundle_id_not_allowed",
|
|
2747
|
+
type: "permission_error",
|
|
2748
|
+
description: "The iOS bundle ID sent via X-Crossdeck-Bundle-Id isn't registered under this app's Apple identity lock.",
|
|
2749
|
+
resolution: "Add the bundle ID under dashboard \u2192 Apps \u2192 <your app> \u2192 iOS bundle IDs.",
|
|
2750
|
+
retryable: false
|
|
2751
|
+
},
|
|
2752
|
+
{
|
|
2753
|
+
code: "package_name_not_allowed",
|
|
2754
|
+
type: "permission_error",
|
|
2755
|
+
description: "The Android package name sent via X-Crossdeck-Package-Name isn't registered under this app's Android identity lock.",
|
|
2756
|
+
resolution: "Add the package name under dashboard \u2192 Apps \u2192 <your app> \u2192 Android package names.",
|
|
2757
|
+
retryable: false
|
|
2758
|
+
},
|
|
2759
|
+
{
|
|
2760
|
+
code: "env_mismatch",
|
|
2761
|
+
type: "permission_error",
|
|
2762
|
+
description: "The request env (inferred from key prefix) doesn't match the resolved app's configured env.",
|
|
2763
|
+
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.",
|
|
2764
|
+
retryable: false
|
|
2765
|
+
},
|
|
2766
|
+
{
|
|
2767
|
+
code: "idempotency_key_in_use",
|
|
2768
|
+
type: "invalid_request_error",
|
|
2769
|
+
description: "An Idempotency-Key was reused for a request with a different body (Stripe-grade contract).",
|
|
2770
|
+
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.",
|
|
2771
|
+
retryable: false
|
|
2772
|
+
},
|
|
2773
|
+
{
|
|
2774
|
+
code: "rate_limited",
|
|
2775
|
+
type: "rate_limit_error",
|
|
2776
|
+
description: "Request rate exceeded the project's per-second cap.",
|
|
2777
|
+
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.",
|
|
2778
|
+
retryable: true
|
|
2779
|
+
},
|
|
2780
|
+
{
|
|
2781
|
+
code: "internal_error",
|
|
2782
|
+
type: "internal_error",
|
|
2783
|
+
description: "Server-side issue. Safe to retry with backoff.",
|
|
2784
|
+
resolution: "The SDK retries automatically. If your code paths through to this error, contact support with the requestId from the response envelope.",
|
|
2785
|
+
retryable: true
|
|
2786
|
+
},
|
|
2787
|
+
{
|
|
2788
|
+
code: "google_not_supported",
|
|
2789
|
+
type: "invalid_request_error",
|
|
2790
|
+
description: "POST /purchases/sync with rail=google is gated until the Play Developer API reconciliation worker ships.",
|
|
2791
|
+
resolution: "Until v1.5+, Google Play purchases verify via Real-time Developer Notifications. The SDK auto-track path handles this transparently for Android consumers.",
|
|
2792
|
+
retryable: false
|
|
2793
|
+
},
|
|
2794
|
+
{
|
|
2795
|
+
code: "stripe_not_supported",
|
|
2796
|
+
type: "invalid_request_error",
|
|
2797
|
+
description: "POST /purchases/sync with rail=stripe is unsupported \u2014 Stripe Checkout's redirect flow uses platform webhooks instead.",
|
|
2798
|
+
resolution: "Wire Stripe via the standard Checkout / Customer Portal flow; Crossdeck reconciles via the platform webhook automatically. No SDK call needed.",
|
|
2799
|
+
retryable: false
|
|
2800
|
+
},
|
|
2801
|
+
{
|
|
2802
|
+
code: "missing_required_param",
|
|
2803
|
+
type: "invalid_request_error",
|
|
2804
|
+
description: "A required field is absent from the request body.",
|
|
2805
|
+
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.",
|
|
2806
|
+
retryable: false
|
|
2807
|
+
},
|
|
2808
|
+
{
|
|
2809
|
+
code: "invalid_param_value",
|
|
2810
|
+
type: "invalid_request_error",
|
|
2811
|
+
description: "A field is present but the value failed validation (wrong shape, wrong length, wrong enum value).",
|
|
2812
|
+
resolution: "Read error.message for the specific field + reason. SDK-managed call sites should never emit this \u2014 file a bug if you do.",
|
|
2813
|
+
retryable: false
|
|
2814
|
+
}
|
|
2815
|
+
]);
|
|
2816
|
+
function getErrorCode(code) {
|
|
2817
|
+
return CROSSDECK_ERROR_CODES.find((e) => e.code === code);
|
|
2818
|
+
}
|
|
2819
|
+
|
|
2449
2820
|
// src/_contract-verifiers.ts
|
|
2450
2821
|
function buildVerifierContext(opts) {
|
|
2451
2822
|
return {
|
|
@@ -2970,13 +3341,64 @@ var VERIFIER_CONTRACT_FAILED_PAYLOAD_SCHEMA_LOCK = {
|
|
|
2970
3341
|
);
|
|
2971
3342
|
}
|
|
2972
3343
|
};
|
|
3344
|
+
var BACKEND_WIRE_CODES = Object.freeze([
|
|
3345
|
+
"missing_api_key",
|
|
3346
|
+
"invalid_api_key",
|
|
3347
|
+
"key_revoked",
|
|
3348
|
+
"identity_token_invalid",
|
|
3349
|
+
"origin_not_allowed",
|
|
3350
|
+
"bundle_id_not_allowed",
|
|
3351
|
+
"package_name_not_allowed",
|
|
3352
|
+
"env_mismatch",
|
|
3353
|
+
"idempotency_key_in_use",
|
|
3354
|
+
"rate_limited",
|
|
3355
|
+
"internal_error",
|
|
3356
|
+
"google_not_supported",
|
|
3357
|
+
"stripe_not_supported",
|
|
3358
|
+
"missing_required_param",
|
|
3359
|
+
"invalid_param_value"
|
|
3360
|
+
]);
|
|
3361
|
+
var VERIFIER_SDK_ERROR_CODES_CATALOGUE = {
|
|
3362
|
+
contractId: "sdk-error-codes-catalogue",
|
|
3363
|
+
bootTest() {
|
|
3364
|
+
const t0 = nowMs();
|
|
3365
|
+
try {
|
|
3366
|
+
const missing = [];
|
|
3367
|
+
for (const code of BACKEND_WIRE_CODES) {
|
|
3368
|
+
const entry = getErrorCode(code);
|
|
3369
|
+
if (!entry || !entry.description || entry.description.trim().length === 0 || !entry.resolution || entry.resolution.trim().length === 0) {
|
|
3370
|
+
missing.push(code);
|
|
3371
|
+
}
|
|
3372
|
+
}
|
|
3373
|
+
if (missing.length > 0) {
|
|
3374
|
+
return fail(
|
|
3375
|
+
"sdk-error-codes-catalogue",
|
|
3376
|
+
`catalogue missing description+resolution for backend code(s): ${missing.join(", ")}`,
|
|
3377
|
+
nowMs() - t0
|
|
3378
|
+
);
|
|
3379
|
+
}
|
|
3380
|
+
return pass(
|
|
3381
|
+
"sdk-error-codes-catalogue",
|
|
3382
|
+
`all ${BACKEND_WIRE_CODES.length} backend wire codes carry description + resolution`,
|
|
3383
|
+
nowMs() - t0
|
|
3384
|
+
);
|
|
3385
|
+
} catch (err) {
|
|
3386
|
+
return fail(
|
|
3387
|
+
"sdk-error-codes-catalogue",
|
|
3388
|
+
`boot test threw: ${err.message?.slice(0, 80) ?? "unknown error"}`,
|
|
3389
|
+
nowMs() - t0
|
|
3390
|
+
);
|
|
3391
|
+
}
|
|
3392
|
+
}
|
|
3393
|
+
};
|
|
2973
3394
|
var STATIC_VERIFIERS = Object.freeze([
|
|
2974
3395
|
VERIFIER_PER_USER_CACHE_ISOLATION,
|
|
2975
3396
|
VERIFIER_IDEMPOTENCY_KEY_DETERMINISTIC,
|
|
2976
3397
|
VERIFIER_ERROR_ENVELOPE_SHAPE,
|
|
2977
3398
|
VERIFIER_FLUSH_INTERVAL_PARITY,
|
|
2978
3399
|
VERIFIER_SUPER_PROPERTY_MERGE_PRECEDENCE,
|
|
2979
|
-
VERIFIER_CONTRACT_FAILED_PAYLOAD_SCHEMA_LOCK
|
|
3400
|
+
VERIFIER_CONTRACT_FAILED_PAYLOAD_SCHEMA_LOCK,
|
|
3401
|
+
VERIFIER_SDK_ERROR_CODES_CATALOGUE
|
|
2980
3402
|
]);
|
|
2981
3403
|
async function runBootSelfTest(verifiers, reporter, ctx) {
|
|
2982
3404
|
if (ctx.disableContractAssertions) {
|
|
@@ -3280,19 +3702,58 @@ var DEFAULT_ERROR_CAPTURE = {
|
|
|
3280
3702
|
"ResizeObserver loop completed with undelivered notifications",
|
|
3281
3703
|
"Non-Error promise rejection captured"
|
|
3282
3704
|
// NOTE: We deliberately do NOT drop cross-origin "Script error."
|
|
3283
|
-
// events here.
|
|
3284
|
-
//
|
|
3285
|
-
//
|
|
3286
|
-
// `
|
|
3287
|
-
//
|
|
3288
|
-
//
|
|
3289
|
-
//
|
|
3290
|
-
//
|
|
3291
|
-
// init config.
|
|
3705
|
+
// events here. The actionability principle (see denyUrls
|
|
3706
|
+
// comment below) draws the line at "developer cannot act":
|
|
3707
|
+
// cross-origin CORS opacity HAS a real, code-shaped fix (add
|
|
3708
|
+
// `crossorigin="anonymous"` to the script tag + CORS headers
|
|
3709
|
+
// on the script's origin). The dashboard surfaces these with a
|
|
3710
|
+
// `cross_origin` tag pointing at that fix. Apps that genuinely
|
|
3711
|
+
// want them muted can re-add "Script error" to ignoreErrors
|
|
3712
|
+
// via init config.
|
|
3292
3713
|
],
|
|
3293
3714
|
allowUrls: [],
|
|
3294
3715
|
denyUrls: [
|
|
3295
|
-
//
|
|
3716
|
+
// The actionability principle
|
|
3717
|
+
// ---------------------------
|
|
3718
|
+
// Crossdeck's default philosophy is "classify, don't silently
|
|
3719
|
+
// drop" — surfacing errors the developer can fix is more useful
|
|
3720
|
+
// than hiding them. That's right for cross-origin "Script
|
|
3721
|
+
// error." (real, code-shaped CORS fix) and for plain
|
|
3722
|
+
// application bugs (obviously real).
|
|
3723
|
+
//
|
|
3724
|
+
// It's NOT right for events the developer cannot act on.
|
|
3725
|
+
// - A user's installed browser extension throwing inside
|
|
3726
|
+
// its own `chrome-extension://` URL: the developer can't
|
|
3727
|
+
// ship a fix for someone else's extension.
|
|
3728
|
+
// - An ad blocker preventing `googletagmanager.com` from
|
|
3729
|
+
// loading: the developer can't unblock the user's blocker.
|
|
3730
|
+
//
|
|
3731
|
+
// Capturing these creates a noise tab that's always non-empty
|
|
3732
|
+
// and never actionable, which trains the dev to ignore the
|
|
3733
|
+
// noise tab — and then the actionable noise (CORS opacity,
|
|
3734
|
+
// etc.) gets ignored along with it. Same lesson as Crossdeck's
|
|
3735
|
+
// "0-2 notifications a week" discipline: a signal that fires
|
|
3736
|
+
// constantly with nothing actionable behind it stops being a
|
|
3737
|
+
// signal.
|
|
3738
|
+
//
|
|
3739
|
+
// So we drop at the source for the unactionable category, and
|
|
3740
|
+
// keep capturing for the actionable category. Same principle
|
|
3741
|
+
// applied to two structurally different inputs — not a new
|
|
3742
|
+
// philosophy.
|
|
3743
|
+
//
|
|
3744
|
+
// The bootstrap list below is the minimum that ships in code.
|
|
3745
|
+
// The full, versioned list arrives at boot via /v1/config —
|
|
3746
|
+
// see `backend/src/lib/error-noise-deny-list.ts`. The SDK
|
|
3747
|
+
// applies the union of (bootstrap + remote), so a freshly-
|
|
3748
|
+
// installed SDK protects users immediately, and remote updates
|
|
3749
|
+
// (new ad-network domains, new pixel hosts) reach every
|
|
3750
|
+
// install without an SDK release.
|
|
3751
|
+
//
|
|
3752
|
+
// The backend Layer-2 classifier
|
|
3753
|
+
// (`backend/src/api/lib/noise-classifier.ts`) is the safety
|
|
3754
|
+
// net for events that slip past these patterns — older SDK
|
|
3755
|
+
// versions in the wild that haven't fetched the remote list
|
|
3756
|
+
// yet, or brand-new patterns the list hasn't named.
|
|
3296
3757
|
/^chrome-extension:\/\//,
|
|
3297
3758
|
/^moz-extension:\/\//,
|
|
3298
3759
|
/^safari-extension:\/\//,
|
|
@@ -4085,7 +4546,12 @@ var CrossdeckClient = class {
|
|
|
4085
4546
|
if (autoTrack.sessions || autoTrack.pageViews) {
|
|
4086
4547
|
const tracker = new AutoTracker(
|
|
4087
4548
|
autoTrack,
|
|
4088
|
-
(name, properties) => this.track(name, properties)
|
|
4549
|
+
(name, properties) => this.track(name, properties),
|
|
4550
|
+
// Persist session continuity on the SAME adapter as identity, so
|
|
4551
|
+
// a visit survives full-page navigations (multi-page sites
|
|
4552
|
+
// re-install the SDK on every page) and honours the same consent
|
|
4553
|
+
// posture — MemoryStorage when identity persistence is off.
|
|
4554
|
+
{ storage: effectiveStorage, storageKey: opts.storagePrefix + "session" }
|
|
4089
4555
|
);
|
|
4090
4556
|
this.state.autoTracker = tracker;
|
|
4091
4557
|
tracker.install();
|
|
@@ -4550,6 +5016,12 @@ var CrossdeckClient = class {
|
|
|
4550
5016
|
* true even before the session's first network round-trip. Returns
|
|
4551
5017
|
* false only for a genuinely new install that has never completed a
|
|
4552
5018
|
* getEntitlements(), or for an entitlement past its own validUntil.
|
|
5019
|
+
*
|
|
5020
|
+
* Throws `not_initialized` (CrossdeckError, type
|
|
5021
|
+
* `configuration_error`) if called before `Crossdeck.init()`. The
|
|
5022
|
+
* `useEntitlement` React hook and the Vue composable both swallow
|
|
5023
|
+
* this and return `false`; bare callers must guard for it (or call
|
|
5024
|
+
* after `init()` resolves).
|
|
4553
5025
|
*/
|
|
4554
5026
|
isEntitled(key) {
|
|
4555
5027
|
const s = this.requireStarted();
|
|
@@ -4565,8 +5037,21 @@ var CrossdeckClient = class {
|
|
|
4565
5037
|
*
|
|
4566
5038
|
* The listener is invoked AFTER the cache mutates — once after a
|
|
4567
5039
|
* successful `getEntitlements()` warms it, again after `syncPurchases()`
|
|
4568
|
-
* delivers fresh entitlements,
|
|
4569
|
-
*
|
|
5040
|
+
* delivers fresh entitlements, once on `reset()` to fire the empty-
|
|
5041
|
+
* cache state for logout flows, AND once on `identify()` after the
|
|
5042
|
+
* per-user cache slot rotates and re-hydrates from device storage.
|
|
5043
|
+
*
|
|
5044
|
+
* IMPORTANT — the `identify()` fire is a TRAP if you treat it as
|
|
5045
|
+
* authoritative network state. `identify()` does NOT fetch entitlements;
|
|
5046
|
+
* it switches the per-user cache slot and rehydrates from device
|
|
5047
|
+
* storage (which is empty for a brand-new install, and last-known-good
|
|
5048
|
+
* — possibly stale — for a returning user). A listener that gates a
|
|
5049
|
+
* paywall on the first fire after an identity switch will read
|
|
5050
|
+
* `false` for a paying customer on a fresh device and let them past
|
|
5051
|
+
* the gate as free. The network-truth fire is the one that follows
|
|
5052
|
+
* the next `getEntitlements()` resolution. Either call
|
|
5053
|
+
* `getEntitlements()` explicitly after `identify()`, or have your
|
|
5054
|
+
* gating code tolerate the empty-then-populated transition.
|
|
4570
5055
|
*
|
|
4571
5056
|
* It is NOT invoked synchronously on subscribe. Callers that need
|
|
4572
5057
|
* the current state should read it via `isEntitled()` / `listEntitlements()`
|
|
@@ -4675,6 +5160,7 @@ var CrossdeckClient = class {
|
|
|
4675
5160
|
);
|
|
4676
5161
|
}
|
|
4677
5162
|
}
|
|
5163
|
+
s.autoTracker?.markActivity();
|
|
4678
5164
|
const enriched = { ...s.deviceInfo };
|
|
4679
5165
|
const sessionId = s.autoTracker?.currentSessionId;
|
|
4680
5166
|
if (sessionId) enriched.sessionId = sessionId;
|
|
@@ -4929,6 +5415,25 @@ var CrossdeckClient = class {
|
|
|
4929
5415
|
events: s.events.getStats()
|
|
4930
5416
|
};
|
|
4931
5417
|
}
|
|
5418
|
+
/**
|
|
5419
|
+
* The stable reference to hand Stripe at checkout so the resulting
|
|
5420
|
+
* purchase attributes to THIS person. Stamp it on the Checkout Session
|
|
5421
|
+
* as `metadata.crossdeck_ref` (see docs/connect-stripe) — the platform
|
|
5422
|
+
* webhook reads it back, validates it, and attaches the subscription to
|
|
5423
|
+
* the right customer instead of stranding it on an anonymous record.
|
|
5424
|
+
*
|
|
5425
|
+
* Returns the strongest identifier the SDK currently holds, in the same
|
|
5426
|
+
* precedence the server resolves by:
|
|
5427
|
+
* crossdeckCustomerId (cdcust_…) > developerUserId > anonymousId
|
|
5428
|
+
* anonymousId is always present, so this always returns a usable,
|
|
5429
|
+
* non-empty reference — even before identify(). Identify the user as
|
|
5430
|
+
* early as you can, though, so the reference is their stable identity
|
|
5431
|
+
* and not a per-device anon id.
|
|
5432
|
+
*/
|
|
5433
|
+
getCheckoutReference() {
|
|
5434
|
+
const s = this.requireStarted();
|
|
5435
|
+
return s.identity.crossdeckCustomerId ?? s.developerUserId ?? s.identity.anonymousId;
|
|
5436
|
+
}
|
|
4932
5437
|
// ---------- private helpers ----------
|
|
4933
5438
|
requireStarted() {
|
|
4934
5439
|
if (!this.state) {
|