@cross-deck/node 1.1.0 → 1.2.0
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 +38 -0
- package/dist/auto-events/index.d.mts +1 -1
- package/dist/auto-events/index.d.ts +1 -1
- package/dist/{crossdeck-server-BXQaFjVx.d.mts → crossdeck-server-BZVZEuS-.d.mts} +303 -20
- package/dist/{crossdeck-server-BXQaFjVx.d.ts → crossdeck-server-BZVZEuS-.d.ts} +303 -20
- package/dist/index.cjs +596 -61
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +5 -2
- package/dist/index.d.ts +5 -2
- package/dist/index.mjs +596 -61
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -365,7 +365,7 @@ function byteLength(s) {
|
|
|
365
365
|
|
|
366
366
|
// src/http.ts
|
|
367
367
|
var SDK_NAME = "@cross-deck/node";
|
|
368
|
-
var SDK_VERSION = "1.
|
|
368
|
+
var SDK_VERSION = "1.2.0";
|
|
369
369
|
var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
|
|
370
370
|
var DEFAULT_TIMEOUT_MS = 15e3;
|
|
371
371
|
var CROSSDECK_API_VERSION = "2025-01-01";
|
|
@@ -1084,13 +1084,21 @@ function isInAppFrame(filename) {
|
|
|
1084
1084
|
if (/^internal[\\/]/.test(filename)) return false;
|
|
1085
1085
|
return true;
|
|
1086
1086
|
}
|
|
1087
|
-
function fingerprintError(message, frames) {
|
|
1087
|
+
function fingerprintError(message, frames, location) {
|
|
1088
1088
|
const inAppFrames = frames.filter((f) => f.in_app).slice(0, 3);
|
|
1089
|
-
const
|
|
1089
|
+
const parts = [
|
|
1090
1090
|
(message || "").slice(0, 200),
|
|
1091
1091
|
...inAppFrames.map((f) => `${f.function}@${f.filename}:${f.lineno}`)
|
|
1092
|
-
]
|
|
1093
|
-
|
|
1092
|
+
];
|
|
1093
|
+
if (inAppFrames.length === 0 && location) {
|
|
1094
|
+
const loc = [
|
|
1095
|
+
location.errorType ?? "",
|
|
1096
|
+
location.filename ?? "",
|
|
1097
|
+
location.lineno ?? ""
|
|
1098
|
+
].join(":");
|
|
1099
|
+
if (loc !== "::") parts.push(loc);
|
|
1100
|
+
}
|
|
1101
|
+
return djb2Hex(parts.join("|"));
|
|
1094
1102
|
}
|
|
1095
1103
|
function djb2Hex(input) {
|
|
1096
1104
|
let h = 5381;
|
|
@@ -1321,34 +1329,30 @@ var ErrorTracker = class {
|
|
|
1321
1329
|
* runtime-agnostic.
|
|
1322
1330
|
*/
|
|
1323
1331
|
buildFromUnknown(err, kind, level) {
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
message: String(err.message).slice(0, 1024),
|
|
1331
|
-
errorType: err.name,
|
|
1332
|
-
frames,
|
|
1333
|
-
rawStack: err.stack ?? null,
|
|
1334
|
-
fingerprint: fingerprintError(err.message, frames),
|
|
1335
|
-
breadcrumbs: this.opts.breadcrumbs.snapshot(),
|
|
1336
|
-
context: this.opts.getContext(),
|
|
1337
|
-
tags: this.opts.getTags()
|
|
1338
|
-
};
|
|
1339
|
-
}
|
|
1340
|
-
const message = safeStringify3(err).slice(0, 1024);
|
|
1332
|
+
const payload = coerceErrorPayload(err);
|
|
1333
|
+
const message = (payload.message || "Unknown error").slice(0, 1024);
|
|
1334
|
+
const stack = err instanceof Error ? err.stack ?? null : null;
|
|
1335
|
+
const frames = parseStack(stack);
|
|
1336
|
+
const errorType = payload.errorType ?? null;
|
|
1337
|
+
const context = payload.extras ? { ...this.opts.getContext(), __error_extras: payload.extras } : this.opts.getContext();
|
|
1341
1338
|
return {
|
|
1342
1339
|
timestamp: Date.now(),
|
|
1343
1340
|
kind,
|
|
1344
1341
|
level,
|
|
1345
1342
|
message,
|
|
1346
|
-
errorType
|
|
1347
|
-
frames
|
|
1348
|
-
rawStack:
|
|
1349
|
-
|
|
1343
|
+
errorType,
|
|
1344
|
+
frames,
|
|
1345
|
+
rawStack: stack,
|
|
1346
|
+
// Location fallback ensures distinct call sites stay separate
|
|
1347
|
+
// even when the message is generic and there are no parseable
|
|
1348
|
+
// frames (e.g. `throw "boom"` from a middleware).
|
|
1349
|
+
fingerprint: fingerprintError(message, frames, {
|
|
1350
|
+
filename: frames[0]?.filename ?? null,
|
|
1351
|
+
lineno: frames[0]?.lineno ?? null,
|
|
1352
|
+
errorType
|
|
1353
|
+
}),
|
|
1350
1354
|
breadcrumbs: this.opts.breadcrumbs.snapshot(),
|
|
1351
|
-
context
|
|
1355
|
+
context,
|
|
1352
1356
|
tags: this.opts.getTags()
|
|
1353
1357
|
};
|
|
1354
1358
|
}
|
|
@@ -1363,7 +1367,10 @@ var ErrorTracker = class {
|
|
|
1363
1367
|
errorType: "HTTPError",
|
|
1364
1368
|
frames: [],
|
|
1365
1369
|
rawStack: null,
|
|
1366
|
-
fingerprint: fingerprintError(`HTTP ${info.status} ${info.method}`, []
|
|
1370
|
+
fingerprint: fingerprintError(`HTTP ${info.status} ${info.method}`, [], {
|
|
1371
|
+
filename: info.url,
|
|
1372
|
+
errorType: "HTTPError"
|
|
1373
|
+
}),
|
|
1367
1374
|
breadcrumbs: this.opts.breadcrumbs.snapshot(),
|
|
1368
1375
|
context: this.opts.getContext(),
|
|
1369
1376
|
tags: this.opts.getTags(),
|
|
@@ -1466,19 +1473,164 @@ var ErrorTracker = class {
|
|
|
1466
1473
|
}
|
|
1467
1474
|
}
|
|
1468
1475
|
};
|
|
1469
|
-
function
|
|
1470
|
-
if (v
|
|
1471
|
-
if (
|
|
1472
|
-
if (typeof v === "
|
|
1476
|
+
function coerceErrorPayload(v) {
|
|
1477
|
+
if (v === null) return { message: "(thrown: null)", errorType: null, extras: null };
|
|
1478
|
+
if (v === void 0) return { message: "(thrown: undefined)", errorType: null, extras: null };
|
|
1479
|
+
if (typeof v === "string") {
|
|
1480
|
+
return { message: v, errorType: null, extras: null };
|
|
1481
|
+
}
|
|
1482
|
+
if (typeof v === "number" || typeof v === "boolean" || typeof v === "bigint") {
|
|
1483
|
+
return { message: String(v), errorType: typeof v, extras: null };
|
|
1484
|
+
}
|
|
1485
|
+
if (typeof v === "symbol") {
|
|
1486
|
+
return { message: v.toString(), errorType: "symbol", extras: null };
|
|
1487
|
+
}
|
|
1488
|
+
if (typeof v === "function") {
|
|
1489
|
+
return { message: `(thrown function: ${v.name || "anonymous"})`, errorType: "function", extras: null };
|
|
1490
|
+
}
|
|
1491
|
+
if (v instanceof Error) {
|
|
1492
|
+
const errorType = v.name || v.constructor?.name || "Error";
|
|
1493
|
+
const message = typeof v.message === "string" && v.message.length > 0 ? v.message : safeToString(v) || errorType;
|
|
1494
|
+
const extras = {};
|
|
1495
|
+
const causeChain = collectCauseChain(v);
|
|
1496
|
+
if (causeChain.length > 0) extras.cause = causeChain;
|
|
1497
|
+
const aggErrors = v.errors;
|
|
1498
|
+
if (Array.isArray(aggErrors)) {
|
|
1499
|
+
extras.aggregatedErrors = aggErrors.slice(0, 10).map((inner) => {
|
|
1500
|
+
if (inner instanceof Error) {
|
|
1501
|
+
return { name: inner.name || "Error", message: inner.message || "" };
|
|
1502
|
+
}
|
|
1503
|
+
return { name: "non-Error", message: safeToString(inner) };
|
|
1504
|
+
});
|
|
1505
|
+
}
|
|
1506
|
+
for (const key of [
|
|
1507
|
+
"code",
|
|
1508
|
+
"errno",
|
|
1509
|
+
"syscall",
|
|
1510
|
+
"path",
|
|
1511
|
+
"status",
|
|
1512
|
+
"statusCode",
|
|
1513
|
+
"response",
|
|
1514
|
+
"data",
|
|
1515
|
+
"detail",
|
|
1516
|
+
"details"
|
|
1517
|
+
]) {
|
|
1518
|
+
const val = v[key];
|
|
1519
|
+
if (val !== void 0 && typeof val !== "function") {
|
|
1520
|
+
extras[key] = safeClone(val);
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
for (const key of Object.keys(v)) {
|
|
1524
|
+
if (key === "message" || key === "stack" || key === "name" || key === "cause" || key === "errors") continue;
|
|
1525
|
+
if (key in extras) continue;
|
|
1526
|
+
const val = v[key];
|
|
1527
|
+
if (typeof val === "function") continue;
|
|
1528
|
+
extras[key] = safeClone(val);
|
|
1529
|
+
}
|
|
1530
|
+
return {
|
|
1531
|
+
message,
|
|
1532
|
+
errorType,
|
|
1533
|
+
extras: Object.keys(extras).length > 0 ? extras : null
|
|
1534
|
+
};
|
|
1535
|
+
}
|
|
1536
|
+
if (typeof Response !== "undefined" && v instanceof Response) {
|
|
1537
|
+
return {
|
|
1538
|
+
message: `HTTP ${v.status} ${v.statusText || ""}${v.url ? ` ${v.url}` : ""}`.trim(),
|
|
1539
|
+
errorType: "Response",
|
|
1540
|
+
extras: { status: v.status, statusText: v.statusText, url: v.url, type: v.type }
|
|
1541
|
+
};
|
|
1542
|
+
}
|
|
1543
|
+
if (typeof v === "object") {
|
|
1544
|
+
const obj = v;
|
|
1545
|
+
const ctorName = obj.constructor && typeof obj.constructor === "function" && obj.constructor.name || null;
|
|
1546
|
+
const ownMessage = typeof obj.message === "string" && obj.message ? obj.message : null;
|
|
1547
|
+
const ownName = typeof obj.name === "string" && obj.name ? obj.name : null;
|
|
1548
|
+
let jsonForm = null;
|
|
1549
|
+
try {
|
|
1550
|
+
const serialised = JSON.stringify(obj);
|
|
1551
|
+
jsonForm = serialised === "{}" ? null : serialised;
|
|
1552
|
+
} catch {
|
|
1553
|
+
jsonForm = null;
|
|
1554
|
+
}
|
|
1555
|
+
const fallbackString = safeToString(obj);
|
|
1556
|
+
const message = ownMessage ?? jsonForm ?? (fallbackString && fallbackString !== "[object Object]" ? fallbackString : null) ?? (ctorName ? `(thrown ${ctorName} with no message)` : "(thrown object with no message)");
|
|
1557
|
+
const errorType = ownName ?? ctorName ?? null;
|
|
1558
|
+
const extras = {};
|
|
1559
|
+
let count = 0;
|
|
1560
|
+
for (const key of Object.keys(obj)) {
|
|
1561
|
+
if (count >= 20) break;
|
|
1562
|
+
if (key === "message" || key === "name") continue;
|
|
1563
|
+
const val = obj[key];
|
|
1564
|
+
if (typeof val === "function") continue;
|
|
1565
|
+
extras[key] = safeClone(val);
|
|
1566
|
+
count++;
|
|
1567
|
+
}
|
|
1568
|
+
return {
|
|
1569
|
+
message,
|
|
1570
|
+
errorType,
|
|
1571
|
+
extras: Object.keys(extras).length > 0 ? extras : null
|
|
1572
|
+
};
|
|
1573
|
+
}
|
|
1574
|
+
return { message: safeToString(v) || "(unstringifiable thrown value)", errorType: null, extras: null };
|
|
1575
|
+
}
|
|
1576
|
+
function collectCauseChain(err) {
|
|
1577
|
+
const out = [];
|
|
1578
|
+
let cur = err.cause;
|
|
1579
|
+
let depth = 0;
|
|
1580
|
+
while (cur != null && depth < 5) {
|
|
1581
|
+
if (cur instanceof Error) {
|
|
1582
|
+
out.push({ name: cur.name || "Error", message: cur.message || "" });
|
|
1583
|
+
cur = cur.cause;
|
|
1584
|
+
} else {
|
|
1585
|
+
out.push({ name: "non-Error", message: safeToString(cur) });
|
|
1586
|
+
cur = null;
|
|
1587
|
+
}
|
|
1588
|
+
depth++;
|
|
1589
|
+
}
|
|
1590
|
+
return out;
|
|
1591
|
+
}
|
|
1592
|
+
function safeToString(v) {
|
|
1473
1593
|
try {
|
|
1474
|
-
|
|
1594
|
+
const s = Object.prototype.toString.call(v);
|
|
1595
|
+
if (s !== "[object Object]") return s;
|
|
1596
|
+
const own = v?.toString;
|
|
1597
|
+
if (typeof own === "function" && own !== Object.prototype.toString) {
|
|
1598
|
+
const r = own.call(v);
|
|
1599
|
+
if (typeof r === "string") return r;
|
|
1600
|
+
}
|
|
1601
|
+
return s;
|
|
1475
1602
|
} catch {
|
|
1476
|
-
return
|
|
1603
|
+
return "(throwing toString)";
|
|
1477
1604
|
}
|
|
1478
1605
|
}
|
|
1606
|
+
function safeClone(v) {
|
|
1607
|
+
if (v == null) return v;
|
|
1608
|
+
const t = typeof v;
|
|
1609
|
+
if (t === "string" || t === "number" || t === "boolean") return v;
|
|
1610
|
+
if (t === "bigint") return String(v);
|
|
1611
|
+
try {
|
|
1612
|
+
const s = JSON.stringify(v);
|
|
1613
|
+
return s === void 0 ? safeToString(v) : JSON.parse(s);
|
|
1614
|
+
} catch {
|
|
1615
|
+
return safeToString(v);
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
function safeStringify3(v) {
|
|
1619
|
+
return coerceErrorPayload(v).message;
|
|
1620
|
+
}
|
|
1479
1621
|
|
|
1480
1622
|
// src/runtime-info.ts
|
|
1481
1623
|
var import_node_os = require("os");
|
|
1624
|
+
var SERVERLESS_HOSTS = /* @__PURE__ */ new Set([
|
|
1625
|
+
"aws-lambda",
|
|
1626
|
+
"azure-functions",
|
|
1627
|
+
"google-app-engine",
|
|
1628
|
+
"firebase-functions-v1",
|
|
1629
|
+
"firebase-functions-v2",
|
|
1630
|
+
"cloud-run",
|
|
1631
|
+
"vercel",
|
|
1632
|
+
"netlify"
|
|
1633
|
+
]);
|
|
1482
1634
|
var cached = null;
|
|
1483
1635
|
function collectRuntimeInfo(options = {}) {
|
|
1484
1636
|
if (cached) return cached;
|
|
@@ -1494,6 +1646,7 @@ function detect(options) {
|
|
|
1494
1646
|
platformRelease: safeRelease(),
|
|
1495
1647
|
hostname: safeHostname(),
|
|
1496
1648
|
host: detected.host,
|
|
1649
|
+
isServerless: SERVERLESS_HOSTS.has(detected.host),
|
|
1497
1650
|
region: detected.region,
|
|
1498
1651
|
serviceName: options.serviceName ?? detected.serviceName,
|
|
1499
1652
|
serviceVersion: options.serviceVersion ?? detected.serviceVersion,
|
|
@@ -1897,9 +2050,11 @@ var SuperPropertyStore = class {
|
|
|
1897
2050
|
|
|
1898
2051
|
// src/entitlement-cache.ts
|
|
1899
2052
|
var DEFAULT_MAX_CUSTOMERS = 1e4;
|
|
2053
|
+
var DEFAULT_STALE_AFTER_MS = 24 * 60 * 60 * 1e3;
|
|
1900
2054
|
var EntitlementCache = class {
|
|
1901
2055
|
ttlMs;
|
|
1902
2056
|
maxCustomers;
|
|
2057
|
+
staleAfterMs;
|
|
1903
2058
|
byCustomer = /* @__PURE__ */ new Map();
|
|
1904
2059
|
listeners = /* @__PURE__ */ new Set();
|
|
1905
2060
|
listenerErrorCount = 0;
|
|
@@ -1907,52 +2062,95 @@ var EntitlementCache = class {
|
|
|
1907
2062
|
constructor(options = {}) {
|
|
1908
2063
|
this.ttlMs = options.ttlMs ?? 6e4;
|
|
1909
2064
|
this.maxCustomers = options.maxCustomers ?? DEFAULT_MAX_CUSTOMERS;
|
|
2065
|
+
this.staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
|
|
1910
2066
|
}
|
|
1911
2067
|
/**
|
|
1912
|
-
* Synchronous lookup
|
|
1913
|
-
* entitlement
|
|
1914
|
-
*
|
|
2068
|
+
* Synchronous lookup — true iff the customer currently has the
|
|
2069
|
+
* entitlement granting access. Pure in-memory `Map` read, ZERO I/O.
|
|
2070
|
+
*
|
|
2071
|
+
* Served from last-known-good: a stale entry (Crossdeck unreachable
|
|
2072
|
+
* since the last successful fetch, or past `ttlMs`) STILL answers true
|
|
2073
|
+
* for a still-valid entitlement. Cache staleness alone never makes
|
|
2074
|
+
* this `false` — the central durability fix. The only things that
|
|
2075
|
+
* turn it false:
|
|
2076
|
+
* - the customer has no cached entry at all (genuine cold miss)
|
|
2077
|
+
* - no matching `key` in the customer's entitlement set
|
|
2078
|
+
* - the matching entitlement is `isActive: false`
|
|
2079
|
+
* - the matching entitlement is past its OWN `validUntil` — a
|
|
2080
|
+
* time-based trial expiry still applies mid-outage (mirrors the
|
|
2081
|
+
* web SDK's `validUntil` check exactly).
|
|
2082
|
+
*
|
|
2083
|
+
* An entry being past `ttlMs`, or marked refresh-failed, does NOT
|
|
2084
|
+
* affect the answer — `getEntitlements()` re-fetches on the TTL hint,
|
|
2085
|
+
* but until it succeeds the customer keeps their access.
|
|
1915
2086
|
*/
|
|
1916
2087
|
isEntitled(customerId, key) {
|
|
1917
2088
|
const entry = this.byCustomer.get(customerId);
|
|
1918
2089
|
if (!entry) return false;
|
|
1919
|
-
|
|
1920
|
-
return entry.
|
|
2090
|
+
const nowSec = Date.now() / 1e3;
|
|
2091
|
+
return entry.all.some(
|
|
2092
|
+
(e) => e.key === key && e.isActive && (e.validUntil == null || e.validUntil > nowSec)
|
|
2093
|
+
);
|
|
1921
2094
|
}
|
|
1922
2095
|
/**
|
|
1923
|
-
* Full snapshot for callers that need source / validUntil.
|
|
1924
|
-
* `[]` when the customer has no cached entry
|
|
1925
|
-
*
|
|
2096
|
+
* Full snapshot for callers that need source / validUntil details.
|
|
2097
|
+
* Returns `[]` ONLY when the customer has no cached entry — a stale
|
|
2098
|
+
* or past-TTL entry still returns its last-known-good entitlements
|
|
2099
|
+
* (same durability posture as `isEntitled()`; per-entitlement
|
|
2100
|
+
* `validUntil` is the caller's to honour from the returned objects).
|
|
1926
2101
|
*/
|
|
1927
2102
|
list(customerId) {
|
|
1928
2103
|
const entry = this.byCustomer.get(customerId);
|
|
1929
2104
|
if (!entry) return [];
|
|
1930
|
-
if (Date.now() > entry.expiresAt) return [];
|
|
1931
2105
|
return entry.all.slice();
|
|
1932
2106
|
}
|
|
1933
2107
|
/**
|
|
1934
|
-
* Whether
|
|
1935
|
-
* deciding whether to warm before
|
|
2108
|
+
* Whether the customer's entry is still within `ttlMs` — i.e. a
|
|
2109
|
+
* re-fetch is NOT yet due. Useful for deciding whether to warm before
|
|
2110
|
+
* a hot path. A `false` result does NOT mean the cache is empty or
|
|
2111
|
+
* that `isEntitled()` will return false — it only means the data is
|
|
2112
|
+
* past its refresh hint. See `needsRefresh()` for the inverse.
|
|
1936
2113
|
*/
|
|
1937
2114
|
isFresh(customerId) {
|
|
1938
2115
|
const entry = this.byCustomer.get(customerId);
|
|
1939
|
-
return Boolean(entry && Date.now() <= entry.
|
|
2116
|
+
return Boolean(entry && Date.now() <= entry.refreshDueAt);
|
|
1940
2117
|
}
|
|
1941
2118
|
/**
|
|
1942
|
-
*
|
|
1943
|
-
*
|
|
1944
|
-
*
|
|
1945
|
-
*
|
|
1946
|
-
*
|
|
2119
|
+
* Whether the customer should be re-fetched: either there is no
|
|
2120
|
+
* cached entry, or the entry is past its `ttlMs` refresh hint, or the
|
|
2121
|
+
* most recent refresh attempt for them failed (retry it).
|
|
2122
|
+
*
|
|
2123
|
+
* This is purely advisory — `getEntitlements()` decides when to act
|
|
2124
|
+
* on it. It NEVER gates `isEntitled()`, which serves last-known-good
|
|
2125
|
+
* regardless.
|
|
2126
|
+
*/
|
|
2127
|
+
needsRefresh(customerId) {
|
|
2128
|
+
const entry = this.byCustomer.get(customerId);
|
|
2129
|
+
if (!entry) return true;
|
|
2130
|
+
if (entry.refreshFailedAt > 0) return true;
|
|
2131
|
+
return Date.now() > entry.refreshDueAt;
|
|
2132
|
+
}
|
|
2133
|
+
/**
|
|
2134
|
+
* Replace (or insert) the cache entry for a customer with a fresh
|
|
2135
|
+
* server response. Sets `refreshDueAt` to `now + ttlMs` and CLEARS
|
|
2136
|
+
* any failed-refresh marker — a success ends the stale state.
|
|
2137
|
+
*
|
|
2138
|
+
* Called ONLY after a SUCCESSFUL server read — a failed fetch is
|
|
2139
|
+
* routed to `markRefreshFailed` instead and never reaches here, so
|
|
2140
|
+
* last-known-good is preserved through an outage.
|
|
2141
|
+
*
|
|
2142
|
+
* Re-inserting an existing customerId "touches" it — the entry moves
|
|
2143
|
+
* to the end of insertion order (Map semantics) so it's treated as
|
|
2144
|
+
* most-recently-used for LRU eviction. Fires listeners.
|
|
1947
2145
|
*/
|
|
1948
2146
|
setForCustomer(customerId, entitlements) {
|
|
1949
2147
|
const now = Date.now();
|
|
1950
2148
|
this.byCustomer.delete(customerId);
|
|
1951
2149
|
this.byCustomer.set(customerId, {
|
|
1952
2150
|
all: entitlements.slice(),
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
2151
|
+
refreshDueAt: now + this.ttlMs,
|
|
2152
|
+
populatedAt: now,
|
|
2153
|
+
refreshFailedAt: 0
|
|
1956
2154
|
});
|
|
1957
2155
|
while (this.byCustomer.size > this.maxCustomers) {
|
|
1958
2156
|
const oldestKey = this.byCustomer.keys().next().value;
|
|
@@ -1962,6 +2160,68 @@ var EntitlementCache = class {
|
|
|
1962
2160
|
}
|
|
1963
2161
|
this.notify(customerId, entitlements);
|
|
1964
2162
|
}
|
|
2163
|
+
/**
|
|
2164
|
+
* Record that a refresh attempt for a customer FAILED (Crossdeck
|
|
2165
|
+
* unreachable / transient error). `getEntitlements()` calls this in
|
|
2166
|
+
* its catch path.
|
|
2167
|
+
*
|
|
2168
|
+
* It does NOT touch the customer's cached entitlements — last-known-
|
|
2169
|
+
* good keeps serving — it only stamps `refreshFailedAt` so the
|
|
2170
|
+
* customer shows up as stale in `diagnostics()` rather than the
|
|
2171
|
+
* staleness being a silent unbounded window.
|
|
2172
|
+
*
|
|
2173
|
+
* If the customer has no entry yet (a genuine cold miss whose first
|
|
2174
|
+
* fetch failed) a stub entry with no entitlements is created purely
|
|
2175
|
+
* to carry the failed-refresh marker — so "we tried and Crossdeck was
|
|
2176
|
+
* down" is observable even before any successful warm. The stub holds
|
|
2177
|
+
* an empty entitlement set, so `isEntitled()` still correctly returns
|
|
2178
|
+
* false for it; there is genuinely nothing to serve.
|
|
2179
|
+
*/
|
|
2180
|
+
markRefreshFailed(customerId) {
|
|
2181
|
+
const now = Date.now();
|
|
2182
|
+
const entry = this.byCustomer.get(customerId);
|
|
2183
|
+
if (entry) {
|
|
2184
|
+
entry.refreshFailedAt = now;
|
|
2185
|
+
return;
|
|
2186
|
+
}
|
|
2187
|
+
this.byCustomer.set(customerId, {
|
|
2188
|
+
all: [],
|
|
2189
|
+
refreshDueAt: now + this.ttlMs,
|
|
2190
|
+
populatedAt: 0,
|
|
2191
|
+
refreshFailedAt: now
|
|
2192
|
+
});
|
|
2193
|
+
while (this.byCustomer.size > this.maxCustomers) {
|
|
2194
|
+
const oldestKey = this.byCustomer.keys().next().value;
|
|
2195
|
+
if (oldestKey === void 0) break;
|
|
2196
|
+
this.byCustomer.delete(oldestKey);
|
|
2197
|
+
this.evicted += 1;
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
/**
|
|
2201
|
+
* Whether a customer is knowingly serving older-than-trustworthy
|
|
2202
|
+
* data. True when the most recent refresh ATTEMPT for them failed
|
|
2203
|
+
* (Crossdeck unreachable since the last success — the outage case,
|
|
2204
|
+
* distinct from a benign idle customer simply past `ttlMs`), OR when
|
|
2205
|
+
* their last-known-good has aged past `staleAfterMs`.
|
|
2206
|
+
*
|
|
2207
|
+
* `isStale` NEVER changes what `isEntitled()` returns — the cache
|
|
2208
|
+
* still serves last-known-good. It exists so the staleness is
|
|
2209
|
+
* observable via `diagnostics()` instead of an unbounded silent
|
|
2210
|
+
* window where a revoked customer (event-based revoke, no
|
|
2211
|
+
* `validUntil`) holds access with nobody able to see it.
|
|
2212
|
+
*
|
|
2213
|
+
* Returns false for an unknown customer — nothing cached, nothing
|
|
2214
|
+
* stale.
|
|
2215
|
+
*/
|
|
2216
|
+
isStale(customerId) {
|
|
2217
|
+
const entry = this.byCustomer.get(customerId);
|
|
2218
|
+
if (!entry) return false;
|
|
2219
|
+
return this.entryIsStale(entry);
|
|
2220
|
+
}
|
|
2221
|
+
/** Epoch ms of a customer's last failed refresh, or 0 if none / unknown. */
|
|
2222
|
+
refreshFailedAt(customerId) {
|
|
2223
|
+
return this.byCustomer.get(customerId)?.refreshFailedAt ?? 0;
|
|
2224
|
+
}
|
|
1965
2225
|
/**
|
|
1966
2226
|
* Drop a single customer's entry. Fires listeners with an empty
|
|
1967
2227
|
* list so subscribers know that customer's cache is gone.
|
|
@@ -2027,7 +2287,57 @@ var EntitlementCache = class {
|
|
|
2027
2287
|
get maxSize() {
|
|
2028
2288
|
return this.maxCustomers;
|
|
2029
2289
|
}
|
|
2290
|
+
/** Configured staleness window in ms. */
|
|
2291
|
+
get staleWindowMs() {
|
|
2292
|
+
return this.staleAfterMs;
|
|
2293
|
+
}
|
|
2294
|
+
/**
|
|
2295
|
+
* Count of cached customers currently flagged stale — most recent
|
|
2296
|
+
* refresh failed, or data aged past `staleAfterMs`. The cache keeps
|
|
2297
|
+
* serving last-known-good for them; this is the observability number
|
|
2298
|
+
* `diagnostics()` surfaces.
|
|
2299
|
+
*/
|
|
2300
|
+
get staleCustomerCount() {
|
|
2301
|
+
let count = 0;
|
|
2302
|
+
for (const entry of this.byCustomer.values()) {
|
|
2303
|
+
if (this.entryIsStale(entry)) count += 1;
|
|
2304
|
+
}
|
|
2305
|
+
return count;
|
|
2306
|
+
}
|
|
2307
|
+
/** Whether ANY cached customer is currently stale. */
|
|
2308
|
+
get isAnyStale() {
|
|
2309
|
+
for (const entry of this.byCustomer.values()) {
|
|
2310
|
+
if (this.entryIsStale(entry)) return true;
|
|
2311
|
+
}
|
|
2312
|
+
return false;
|
|
2313
|
+
}
|
|
2314
|
+
/**
|
|
2315
|
+
* Most recent failed-refresh timestamp across all customers (epoch
|
|
2316
|
+
* ms), or 0 if every cached customer's last refresh succeeded.
|
|
2317
|
+
*/
|
|
2318
|
+
get lastRefreshFailedAt() {
|
|
2319
|
+
let max = 0;
|
|
2320
|
+
for (const entry of this.byCustomer.values()) {
|
|
2321
|
+
if (entry.refreshFailedAt > max) max = entry.refreshFailedAt;
|
|
2322
|
+
}
|
|
2323
|
+
return max;
|
|
2324
|
+
}
|
|
2030
2325
|
// ---------- internals ----------
|
|
2326
|
+
/**
|
|
2327
|
+
* Stale iff the entry's most recent refresh attempt failed, OR its
|
|
2328
|
+
* last-known-good has aged past `staleAfterMs`.
|
|
2329
|
+
*
|
|
2330
|
+
* `refreshFailedAt` is non-zero ONLY between a failed refresh and the
|
|
2331
|
+
* next successful one (`setForCustomer` zeroes it), so `> 0` alone
|
|
2332
|
+
* means "a failure occurred since the last success" — no need to
|
|
2333
|
+
* compare against `populatedAt`, which would mis-fire when a failure
|
|
2334
|
+
* and a populate land in the same millisecond. A marker-only stub
|
|
2335
|
+
* (populatedAt 0, failure stamped) is stale via this first clause.
|
|
2336
|
+
*/
|
|
2337
|
+
entryIsStale(entry) {
|
|
2338
|
+
if (entry.refreshFailedAt > 0) return true;
|
|
2339
|
+
return entry.populatedAt > 0 && Date.now() - entry.populatedAt > this.staleAfterMs;
|
|
2340
|
+
}
|
|
2031
2341
|
notify(customerId, snapshot) {
|
|
2032
2342
|
if (this.listeners.size === 0) return;
|
|
2033
2343
|
const snap = snapshot.slice();
|
|
@@ -2118,6 +2428,16 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
2118
2428
|
flushOnExit;
|
|
2119
2429
|
superProps;
|
|
2120
2430
|
entitlementCache;
|
|
2431
|
+
/**
|
|
2432
|
+
* Optional developer-supplied durable store for last-known-good
|
|
2433
|
+
* entitlements (Redis / their DB / a KV). `undefined` when not
|
|
2434
|
+
* configured — the SDK then has no cold-start durability on
|
|
2435
|
+
* serverless, which it states explicitly at boot.
|
|
2436
|
+
*
|
|
2437
|
+
* Touched ONLY from the async `getEntitlements()` — never from the
|
|
2438
|
+
* synchronous `isEntitled()`.
|
|
2439
|
+
*/
|
|
2440
|
+
entitlementStore;
|
|
2121
2441
|
debug;
|
|
2122
2442
|
/**
|
|
2123
2443
|
* Alias map — `developerUserId` / `anonymousId` → canonical
|
|
@@ -2171,8 +2491,10 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
2171
2491
|
this.breadcrumbs = new BreadcrumbBuffer(options.breadcrumbsMaxSize ?? 50);
|
|
2172
2492
|
this.superProps = new SuperPropertyStore();
|
|
2173
2493
|
this.entitlementCache = new EntitlementCache({
|
|
2174
|
-
ttlMs: options.entitlementCacheTtlMs ?? 6e4
|
|
2494
|
+
ttlMs: options.entitlementCacheTtlMs ?? 6e4,
|
|
2495
|
+
staleAfterMs: options.entitlementStaleAfterMs
|
|
2175
2496
|
});
|
|
2497
|
+
this.entitlementStore = options.entitlementStore ?? null;
|
|
2176
2498
|
this.debug = options.debug === true ? new ConsoleDebugLogger() : new NullDebugLogger();
|
|
2177
2499
|
if (options.debug === true) this.debug.enabled = true;
|
|
2178
2500
|
this.debug.emit(
|
|
@@ -2248,7 +2570,60 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
2248
2570
|
{ message: err instanceof Error ? err.message : String(err) }
|
|
2249
2571
|
);
|
|
2250
2572
|
});
|
|
2573
|
+
this.emitBootTelemetry();
|
|
2574
|
+
});
|
|
2575
|
+
}
|
|
2576
|
+
}
|
|
2577
|
+
/**
|
|
2578
|
+
* Emit the one-time `sdk.boot` telemetry event and, when the runtime
|
|
2579
|
+
* is serverless with no `entitlementStore`, the honest "no cold-start
|
|
2580
|
+
* durability" warning.
|
|
2581
|
+
*
|
|
2582
|
+
* Why a `track()` event and not the heartbeat: `GET /v1/sdk/heartbeat`
|
|
2583
|
+
* carries no request body, so it cannot transport a structured
|
|
2584
|
+
* `durability` fact. The event pipeline can — every `track()` event
|
|
2585
|
+
* lands as an aggregatable document the backend can query, so
|
|
2586
|
+
* Crossdeck can compute fleet-wide "% serverless-with-no-durable-
|
|
2587
|
+
* store" from `sdk.boot` events (denominator = all `sdk.boot`,
|
|
2588
|
+
* numerator = those with `durability.coldStartDurable === false`).
|
|
2589
|
+
* The event rides the existing batched + retried + idempotent queue
|
|
2590
|
+
* and is drained by flush-on-exit, so it survives a serverless
|
|
2591
|
+
* teardown — it is NOT a local-only debug log.
|
|
2592
|
+
*
|
|
2593
|
+
* `isServerless` AND no store is the gap: a cold start begins with an
|
|
2594
|
+
* empty in-memory cache and a brief Crossdeck outage in that window
|
|
2595
|
+
* would read a paying customer as un-entitled. That gap is
|
|
2596
|
+
* unavoidable without a store — so the SDK STATES it (a
|
|
2597
|
+
* `sdk.no_durable_store` debug warning) rather than hiding it.
|
|
2598
|
+
*
|
|
2599
|
+
* Called once, from the deferred boot block — so it inherits the
|
|
2600
|
+
* `testMode` / `bootHeartbeat:false` opt-outs and never fires before
|
|
2601
|
+
* the constructor returns.
|
|
2602
|
+
*/
|
|
2603
|
+
emitBootTelemetry() {
|
|
2604
|
+
const isServerless = this.runtime.isServerless;
|
|
2605
|
+
const hasStore = this.entitlementStore !== null;
|
|
2606
|
+
const coldStartDurable = hasStore || !isServerless;
|
|
2607
|
+
if (isServerless && !hasStore) {
|
|
2608
|
+
this.debug.emit(
|
|
2609
|
+
"sdk.no_durable_store",
|
|
2610
|
+
`Running on a serverless host (${this.runtime.host}) with no entitlementStore. The entitlement cache is in-memory only, so a cold start begins empty: if Crossdeck is briefly unreachable during that window, isEntitled() can read a paying customer as un-entitled. Wire \`entitlementStore\` (Redis / your DB / a KV) to close this gap.`,
|
|
2611
|
+
{ host: this.runtime.host, isServerless, durableStore: false }
|
|
2612
|
+
);
|
|
2613
|
+
}
|
|
2614
|
+
try {
|
|
2615
|
+
this.track({
|
|
2616
|
+
name: "sdk.boot",
|
|
2617
|
+
anonymousId: this.processAnonymousId,
|
|
2618
|
+
properties: {
|
|
2619
|
+
"durability.entitlementStore": hasStore,
|
|
2620
|
+
"durability.coldStartDurable": coldStartDurable,
|
|
2621
|
+
"durability.runtimeIsServerless": isServerless,
|
|
2622
|
+
"durability.runtimeHost": this.runtime.host,
|
|
2623
|
+
"durability.entitlementCacheTtlMs": this.entitlementCache.ttl
|
|
2624
|
+
}
|
|
2251
2625
|
});
|
|
2626
|
+
} catch {
|
|
2252
2627
|
}
|
|
2253
2628
|
}
|
|
2254
2629
|
// ============================================================
|
|
@@ -2306,13 +2681,77 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
2306
2681
|
// alias so a subsequent `isEntitled({ userId }, "pro")` resolves
|
|
2307
2682
|
// to the same cache entry.
|
|
2308
2683
|
// ============================================================
|
|
2684
|
+
/**
|
|
2685
|
+
* Fetch a customer's entitlements from Crossdeck and warm the cache.
|
|
2686
|
+
*
|
|
2687
|
+
* Durability — this is where last-known-good lives, NOT in the
|
|
2688
|
+
* synchronous `isEntitled()`:
|
|
2689
|
+
* - On a SUCCESSFUL fetch: the entitlement cache is populated and,
|
|
2690
|
+
* if an `entitlementStore` is configured, the result is persisted
|
|
2691
|
+
* to it (`await store.save(...)`). The cache + store now hold
|
|
2692
|
+
* server-confirmed truth.
|
|
2693
|
+
* - On a network FAILURE: the cache is marked refresh-failed for the
|
|
2694
|
+
* customer (so `diagnostics()` shows the staleness), then — if a
|
|
2695
|
+
* store is configured — last-known-good is loaded back from it
|
|
2696
|
+
* (`await store.load(...)`). If the store yields a snapshot, the
|
|
2697
|
+
* cache is populated from it and that snapshot is RETURNED as a
|
|
2698
|
+
* normal `EntitlementsListResponse` — a cold-start / outage no
|
|
2699
|
+
* longer fails a paying customer. If there is no store, or the
|
|
2700
|
+
* store is empty, the network error is rethrown unchanged so the
|
|
2701
|
+
* caller still sees the failure.
|
|
2702
|
+
*
|
|
2703
|
+
* The store is touched only here, inside the `await` that already
|
|
2704
|
+
* existed. `isEntitled()` remains a pure synchronous `Map` read.
|
|
2705
|
+
*/
|
|
2309
2706
|
async getEntitlements(hints, options) {
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2707
|
+
let response;
|
|
2708
|
+
try {
|
|
2709
|
+
response = await this.http.request("GET", "/entitlements", {
|
|
2710
|
+
query: this.identityPayload(hints),
|
|
2711
|
+
signal: options?.signal,
|
|
2712
|
+
timeoutMs: options?.timeoutMs
|
|
2713
|
+
});
|
|
2714
|
+
} catch (err) {
|
|
2715
|
+
const failedCustomerId = this.resolveFailedRefreshCustomerId(hints);
|
|
2716
|
+
if (failedCustomerId) {
|
|
2717
|
+
this.entitlementCache.markRefreshFailed(failedCustomerId);
|
|
2718
|
+
}
|
|
2719
|
+
const recovered = await this.loadEntitlementsFromStore(hints);
|
|
2720
|
+
if (recovered) {
|
|
2721
|
+
const recoveredResponse = {
|
|
2722
|
+
object: "list",
|
|
2723
|
+
data: recovered.entitlements,
|
|
2724
|
+
crossdeckCustomerId: recovered.crossdeckCustomerId,
|
|
2725
|
+
env: recovered.env
|
|
2726
|
+
};
|
|
2727
|
+
this.populateEntitlementCache(hints, recoveredResponse);
|
|
2728
|
+
this.entitlementCache.markRefreshFailed(recovered.crossdeckCustomerId);
|
|
2729
|
+
this.debug.emit(
|
|
2730
|
+
"sdk.entitlement_store_recovered",
|
|
2731
|
+
`Crossdeck unreachable \u2014 served ${recovered.crossdeckCustomerId} from the durable store (${recovered.entitlements.length} entitlement(s), last refreshed ${new Date(recovered.savedAt).toISOString()}).`,
|
|
2732
|
+
{
|
|
2733
|
+
customerId: recovered.crossdeckCustomerId,
|
|
2734
|
+
savedAt: recovered.savedAt,
|
|
2735
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2736
|
+
}
|
|
2737
|
+
);
|
|
2738
|
+
return recoveredResponse;
|
|
2739
|
+
}
|
|
2740
|
+
if (failedCustomerId && this.entitlementCache.isStale(failedCustomerId)) {
|
|
2741
|
+
this.debug.emit(
|
|
2742
|
+
"sdk.entitlement_cache_stale",
|
|
2743
|
+
`Crossdeck unreachable \u2014 entitlement cache for ${failedCustomerId} is now stale. ` + (this.entitlementStore ? "No durable snapshot was available to recover from." : "No entitlementStore is configured, so there is no durable fallback.") + " isEntitled() keeps serving last-known-good; staleness is visible in diagnostics().",
|
|
2744
|
+
{
|
|
2745
|
+
customerId: failedCustomerId,
|
|
2746
|
+
durableStore: this.entitlementStore !== null,
|
|
2747
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2748
|
+
}
|
|
2749
|
+
);
|
|
2750
|
+
}
|
|
2751
|
+
throw err;
|
|
2752
|
+
}
|
|
2315
2753
|
this.populateEntitlementCache(hints, response);
|
|
2754
|
+
await this.saveEntitlementsToStore(hints, response);
|
|
2316
2755
|
return response;
|
|
2317
2756
|
}
|
|
2318
2757
|
async getCustomerEntitlements(customerId, options) {
|
|
@@ -2826,7 +3265,14 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
2826
3265
|
count: this.entitlementCache.customerCount,
|
|
2827
3266
|
lastUpdated: this.entitlementCache.lastUpdated,
|
|
2828
3267
|
ttlMs: this.entitlementCache.ttl,
|
|
2829
|
-
listenerErrors: this.entitlementCache.listenerErrors
|
|
3268
|
+
listenerErrors: this.entitlementCache.listenerErrors,
|
|
3269
|
+
staleCustomers: this.entitlementCache.staleCustomerCount,
|
|
3270
|
+
isStale: this.entitlementCache.isAnyStale,
|
|
3271
|
+
lastRefreshFailedAt: this.entitlementCache.lastRefreshFailedAt,
|
|
3272
|
+
durableStore: this.entitlementStore !== null,
|
|
3273
|
+
// Cold-start durable iff a store is wired, OR the host is
|
|
3274
|
+
// long-lived (the process, hence the in-memory cache, survives).
|
|
3275
|
+
coldStartDurable: this.entitlementStore !== null || !this.runtime.isServerless
|
|
2830
3276
|
},
|
|
2831
3277
|
events: this.eventQueue.getStats(),
|
|
2832
3278
|
errors: {
|
|
@@ -3040,6 +3486,90 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
3040
3486
|
} catch {
|
|
3041
3487
|
}
|
|
3042
3488
|
}
|
|
3489
|
+
/**
|
|
3490
|
+
* Persist a successful entitlements fetch to the durable store, if
|
|
3491
|
+
* one is configured. No-op when there is no store.
|
|
3492
|
+
*
|
|
3493
|
+
* Saved under EVERY identity the caller might later look up by — the
|
|
3494
|
+
* canonical `crossdeckCustomerId` plus any `userId` / `anonymousId`
|
|
3495
|
+
* hint. The Node cache resolves a hint to a canonical ID via an
|
|
3496
|
+
* in-memory alias map; on a cold start that map is empty, so a
|
|
3497
|
+
* failure-path `load()` must be able to hit the store with the raw
|
|
3498
|
+
* hint the caller passed. Saving under all keys makes that work.
|
|
3499
|
+
*
|
|
3500
|
+
* Best-effort: a store `save()` that throws is swallowed (logged in
|
|
3501
|
+
* debug) — it weakens durability for that customer but must never
|
|
3502
|
+
* fail an otherwise-successful `getEntitlements()`.
|
|
3503
|
+
*/
|
|
3504
|
+
async saveEntitlementsToStore(hints, response) {
|
|
3505
|
+
if (!this.entitlementStore) return;
|
|
3506
|
+
const customerId = response.crossdeckCustomerId;
|
|
3507
|
+
if (!customerId) return;
|
|
3508
|
+
const snapshot = {
|
|
3509
|
+
v: 1,
|
|
3510
|
+
crossdeckCustomerId: customerId,
|
|
3511
|
+
entitlements: response.data,
|
|
3512
|
+
env: response.env,
|
|
3513
|
+
savedAt: Date.now()
|
|
3514
|
+
};
|
|
3515
|
+
const keys = /* @__PURE__ */ new Set([customerId]);
|
|
3516
|
+
if (hints.customerId) keys.add(hints.customerId);
|
|
3517
|
+
if (hints.userId) keys.add(hints.userId);
|
|
3518
|
+
if (hints.anonymousId) keys.add(hints.anonymousId);
|
|
3519
|
+
for (const key of keys) {
|
|
3520
|
+
try {
|
|
3521
|
+
await this.entitlementStore.save(key, snapshot);
|
|
3522
|
+
} catch (err) {
|
|
3523
|
+
this.debug.emit(
|
|
3524
|
+
"sdk.entitlement_store_recovered",
|
|
3525
|
+
`entitlementStore.save failed for key ${key} \u2014 durability weakened for this customer.`,
|
|
3526
|
+
{ key, error: err instanceof Error ? err.message : String(err) }
|
|
3527
|
+
);
|
|
3528
|
+
}
|
|
3529
|
+
}
|
|
3530
|
+
}
|
|
3531
|
+
/**
|
|
3532
|
+
* Load last-known-good entitlements from the durable store on a
|
|
3533
|
+
* network-failure path. Returns the first snapshot found across the
|
|
3534
|
+
* caller's identity keys, or `null` if there is no store / no stored
|
|
3535
|
+
* snapshot / every read failed.
|
|
3536
|
+
*
|
|
3537
|
+
* Tries the canonical `customerId` hint first, then `userId`, then
|
|
3538
|
+
* `anonymousId` — the order callers most commonly key by. A corrupt
|
|
3539
|
+
* or wrong-shaped blob is treated as a miss (the store is developer-
|
|
3540
|
+
* supplied; the SDK validates rather than trusts).
|
|
3541
|
+
*/
|
|
3542
|
+
async loadEntitlementsFromStore(hints) {
|
|
3543
|
+
if (!this.entitlementStore) return null;
|
|
3544
|
+
const keys = [];
|
|
3545
|
+
if (hints.customerId) keys.push(hints.customerId);
|
|
3546
|
+
if (hints.userId) keys.push(hints.userId);
|
|
3547
|
+
if (hints.anonymousId) keys.push(hints.anonymousId);
|
|
3548
|
+
for (const key of keys) {
|
|
3549
|
+
let loaded = null;
|
|
3550
|
+
try {
|
|
3551
|
+
loaded = await this.entitlementStore.load(key);
|
|
3552
|
+
} catch {
|
|
3553
|
+
continue;
|
|
3554
|
+
}
|
|
3555
|
+
if (isValidStoredEntitlements(loaded)) return loaded;
|
|
3556
|
+
}
|
|
3557
|
+
return null;
|
|
3558
|
+
}
|
|
3559
|
+
/**
|
|
3560
|
+
* Resolve the customer ID to stamp a failed-refresh marker against.
|
|
3561
|
+
*
|
|
3562
|
+
* Prefers a canonical ID the cache already knows (so the marker lands
|
|
3563
|
+
* on the existing warm entry), then falls back to whatever raw hint
|
|
3564
|
+
* the caller supplied — on a true cold-start failure there is no
|
|
3565
|
+
* cache entry yet, and marking under the hint still makes "we tried
|
|
3566
|
+
* for this customer and Crossdeck was down" observable.
|
|
3567
|
+
*/
|
|
3568
|
+
resolveFailedRefreshCustomerId(hints) {
|
|
3569
|
+
const known = this.resolveCacheCustomerId(hints);
|
|
3570
|
+
if (known) return known;
|
|
3571
|
+
return hints.customerId ?? hints.userId ?? hints.anonymousId ?? null;
|
|
3572
|
+
}
|
|
3043
3573
|
touchAlias(alias, customerId) {
|
|
3044
3574
|
this.customerIdAliases.delete(alias);
|
|
3045
3575
|
this.customerIdAliases.set(alias, customerId);
|
|
@@ -3141,6 +3671,11 @@ function sanitizePropertyBag(input, fieldName) {
|
|
|
3141
3671
|
});
|
|
3142
3672
|
}
|
|
3143
3673
|
}
|
|
3674
|
+
function isValidStoredEntitlements(value) {
|
|
3675
|
+
if (typeof value !== "object" || value === null) return false;
|
|
3676
|
+
const v = value;
|
|
3677
|
+
return v.v === 1 && typeof v.crossdeckCustomerId === "string" && v.crossdeckCustomerId.length > 0 && Array.isArray(v.entitlements) && (v.env === "production" || v.env === "sandbox") && typeof v.savedAt === "number";
|
|
3678
|
+
}
|
|
3144
3679
|
function categoryFor(name) {
|
|
3145
3680
|
if (name.startsWith("page.") || name.startsWith("navigation.")) return "navigation";
|
|
3146
3681
|
if (name.startsWith("element.") || name.startsWith("ui.click")) return "ui.click";
|