@cross-deck/node 1.1.1 → 1.3.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/CHANGELOG.md +140 -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-DhnHvUhh.d.mts} +320 -21
- package/dist/{crossdeck-server-BXQaFjVx.d.ts → crossdeck-server-DhnHvUhh.d.ts} +320 -21
- package/dist/index.cjs +545 -61
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +37 -7
- package/dist/index.d.ts +37 -7
- package/dist/index.mjs +545 -61
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -363,9 +363,11 @@ function byteLength(s) {
|
|
|
363
363
|
return s.length * 4;
|
|
364
364
|
}
|
|
365
365
|
|
|
366
|
-
// src/
|
|
366
|
+
// src/_version.ts
|
|
367
|
+
var SDK_VERSION = "1.3.1";
|
|
367
368
|
var SDK_NAME = "@cross-deck/node";
|
|
368
|
-
|
|
369
|
+
|
|
370
|
+
// src/http.ts
|
|
369
371
|
var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
|
|
370
372
|
var DEFAULT_TIMEOUT_MS = 15e3;
|
|
371
373
|
var CROSSDECK_API_VERSION = "2025-01-01";
|
|
@@ -878,6 +880,7 @@ var EventQueue = class {
|
|
|
878
880
|
sdk: env.sdk
|
|
879
881
|
};
|
|
880
882
|
if (env.appId) body.appId = env.appId;
|
|
883
|
+
if (env.environment) body.environment = env.environment;
|
|
881
884
|
const result = await this.cfg.http.request("POST", "/events", {
|
|
882
885
|
body,
|
|
883
886
|
idempotencyKey: batchId
|
|
@@ -896,6 +899,20 @@ var EventQueue = class {
|
|
|
896
899
|
} catch (err) {
|
|
897
900
|
const message = err instanceof Error ? err.message : String(err);
|
|
898
901
|
this.lastError = message;
|
|
902
|
+
if (isPermanent4xx(err)) {
|
|
903
|
+
const droppedCount = batch.length;
|
|
904
|
+
this.pendingBatch = null;
|
|
905
|
+
this.pendingBatchId = null;
|
|
906
|
+
this.inFlight -= droppedCount;
|
|
907
|
+
this.dropped += droppedCount;
|
|
908
|
+
this.cfg.onDrop?.(droppedCount);
|
|
909
|
+
this.cfg.onPermanentFailure?.({
|
|
910
|
+
status: err.status ?? 0,
|
|
911
|
+
droppedCount,
|
|
912
|
+
lastError: message
|
|
913
|
+
});
|
|
914
|
+
return null;
|
|
915
|
+
}
|
|
899
916
|
const retryAfterMs = extractRetryAfterMs(err);
|
|
900
917
|
const delay = this.retry.nextDelay(retryAfterMs);
|
|
901
918
|
this.scheduleRetry(delay);
|
|
@@ -974,6 +991,14 @@ function extractRetryAfterMs(err) {
|
|
|
974
991
|
}
|
|
975
992
|
return void 0;
|
|
976
993
|
}
|
|
994
|
+
function isPermanent4xx(err) {
|
|
995
|
+
if (!err || typeof err !== "object") return false;
|
|
996
|
+
const status = err.status;
|
|
997
|
+
if (typeof status !== "number" || !Number.isFinite(status)) return false;
|
|
998
|
+
if (status < 400 || status >= 500) return false;
|
|
999
|
+
if (status === 408 || status === 429) return false;
|
|
1000
|
+
return true;
|
|
1001
|
+
}
|
|
977
1002
|
function defaultScheduler(fn, ms) {
|
|
978
1003
|
const id = setTimeout(fn, ms);
|
|
979
1004
|
if (typeof id.unref === "function") {
|
|
@@ -1263,16 +1288,18 @@ var ErrorTracker = class {
|
|
|
1263
1288
|
const url = typeof input === "string" ? input : input?.url ?? "";
|
|
1264
1289
|
const method = (init.method || "GET").toUpperCase();
|
|
1265
1290
|
const start = Date.now();
|
|
1266
|
-
tracker.opts.
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1291
|
+
if (!isSelfRequest(url, tracker.opts.selfHostname)) {
|
|
1292
|
+
tracker.opts.breadcrumbs.add({
|
|
1293
|
+
timestamp: start,
|
|
1294
|
+
category: "http",
|
|
1295
|
+
message: `${method} ${url}`,
|
|
1296
|
+
data: { url, method }
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1272
1299
|
try {
|
|
1273
1300
|
const response = await origFetch(...args);
|
|
1274
1301
|
if (response.status >= 500 && tracker.opts.isConsented()) {
|
|
1275
|
-
if (!url.
|
|
1302
|
+
if (!isSelfRequest(url, tracker.opts.selfHostname)) {
|
|
1276
1303
|
tracker.captureHttp({
|
|
1277
1304
|
url,
|
|
1278
1305
|
method,
|
|
@@ -1390,9 +1417,10 @@ var ErrorTracker = class {
|
|
|
1390
1417
|
if (!this.passesSample(err)) return;
|
|
1391
1418
|
if (!this.passesRateLimit(err)) return;
|
|
1392
1419
|
let finalErr = err;
|
|
1393
|
-
|
|
1420
|
+
const hook = this.opts.beforeSend?.();
|
|
1421
|
+
if (hook) {
|
|
1394
1422
|
try {
|
|
1395
|
-
finalErr =
|
|
1423
|
+
finalErr = hook(err);
|
|
1396
1424
|
} catch {
|
|
1397
1425
|
finalErr = err;
|
|
1398
1426
|
}
|
|
@@ -1618,9 +1646,35 @@ function safeClone(v) {
|
|
|
1618
1646
|
function safeStringify3(v) {
|
|
1619
1647
|
return coerceErrorPayload(v).message;
|
|
1620
1648
|
}
|
|
1649
|
+
function extractSelfHostname(baseUrl) {
|
|
1650
|
+
if (!baseUrl || typeof baseUrl !== "string") return null;
|
|
1651
|
+
try {
|
|
1652
|
+
return new URL(baseUrl).hostname.toLowerCase();
|
|
1653
|
+
} catch {
|
|
1654
|
+
return null;
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
function isSelfRequest(requestUrl, selfHostname) {
|
|
1658
|
+
if (!selfHostname || !requestUrl) return false;
|
|
1659
|
+
try {
|
|
1660
|
+
return new URL(requestUrl).hostname.toLowerCase() === selfHostname;
|
|
1661
|
+
} catch {
|
|
1662
|
+
return false;
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1621
1665
|
|
|
1622
1666
|
// src/runtime-info.ts
|
|
1623
1667
|
var import_node_os = require("os");
|
|
1668
|
+
var SERVERLESS_HOSTS = /* @__PURE__ */ new Set([
|
|
1669
|
+
"aws-lambda",
|
|
1670
|
+
"azure-functions",
|
|
1671
|
+
"google-app-engine",
|
|
1672
|
+
"firebase-functions-v1",
|
|
1673
|
+
"firebase-functions-v2",
|
|
1674
|
+
"cloud-run",
|
|
1675
|
+
"vercel",
|
|
1676
|
+
"netlify"
|
|
1677
|
+
]);
|
|
1624
1678
|
var cached = null;
|
|
1625
1679
|
function collectRuntimeInfo(options = {}) {
|
|
1626
1680
|
if (cached) return cached;
|
|
@@ -1636,6 +1690,7 @@ function detect(options) {
|
|
|
1636
1690
|
platformRelease: safeRelease(),
|
|
1637
1691
|
hostname: safeHostname(),
|
|
1638
1692
|
host: detected.host,
|
|
1693
|
+
isServerless: SERVERLESS_HOSTS.has(detected.host),
|
|
1639
1694
|
region: detected.region,
|
|
1640
1695
|
serviceName: options.serviceName ?? detected.serviceName,
|
|
1641
1696
|
serviceVersion: options.serviceVersion ?? detected.serviceVersion,
|
|
@@ -2039,9 +2094,11 @@ var SuperPropertyStore = class {
|
|
|
2039
2094
|
|
|
2040
2095
|
// src/entitlement-cache.ts
|
|
2041
2096
|
var DEFAULT_MAX_CUSTOMERS = 1e4;
|
|
2097
|
+
var DEFAULT_STALE_AFTER_MS = 24 * 60 * 60 * 1e3;
|
|
2042
2098
|
var EntitlementCache = class {
|
|
2043
2099
|
ttlMs;
|
|
2044
2100
|
maxCustomers;
|
|
2101
|
+
staleAfterMs;
|
|
2045
2102
|
byCustomer = /* @__PURE__ */ new Map();
|
|
2046
2103
|
listeners = /* @__PURE__ */ new Set();
|
|
2047
2104
|
listenerErrorCount = 0;
|
|
@@ -2049,52 +2106,95 @@ var EntitlementCache = class {
|
|
|
2049
2106
|
constructor(options = {}) {
|
|
2050
2107
|
this.ttlMs = options.ttlMs ?? 6e4;
|
|
2051
2108
|
this.maxCustomers = options.maxCustomers ?? DEFAULT_MAX_CUSTOMERS;
|
|
2109
|
+
this.staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
|
|
2052
2110
|
}
|
|
2053
2111
|
/**
|
|
2054
|
-
* Synchronous lookup
|
|
2055
|
-
* entitlement
|
|
2056
|
-
*
|
|
2112
|
+
* Synchronous lookup — true iff the customer currently has the
|
|
2113
|
+
* entitlement granting access. Pure in-memory `Map` read, ZERO I/O.
|
|
2114
|
+
*
|
|
2115
|
+
* Served from last-known-good: a stale entry (Crossdeck unreachable
|
|
2116
|
+
* since the last successful fetch, or past `ttlMs`) STILL answers true
|
|
2117
|
+
* for a still-valid entitlement. Cache staleness alone never makes
|
|
2118
|
+
* this `false` — the central durability fix. The only things that
|
|
2119
|
+
* turn it false:
|
|
2120
|
+
* - the customer has no cached entry at all (genuine cold miss)
|
|
2121
|
+
* - no matching `key` in the customer's entitlement set
|
|
2122
|
+
* - the matching entitlement is `isActive: false`
|
|
2123
|
+
* - the matching entitlement is past its OWN `validUntil` — a
|
|
2124
|
+
* time-based trial expiry still applies mid-outage (mirrors the
|
|
2125
|
+
* web SDK's `validUntil` check exactly).
|
|
2126
|
+
*
|
|
2127
|
+
* An entry being past `ttlMs`, or marked refresh-failed, does NOT
|
|
2128
|
+
* affect the answer — `getEntitlements()` re-fetches on the TTL hint,
|
|
2129
|
+
* but until it succeeds the customer keeps their access.
|
|
2057
2130
|
*/
|
|
2058
2131
|
isEntitled(customerId, key) {
|
|
2059
2132
|
const entry = this.byCustomer.get(customerId);
|
|
2060
2133
|
if (!entry) return false;
|
|
2061
|
-
|
|
2062
|
-
return entry.
|
|
2134
|
+
const nowSec = Date.now() / 1e3;
|
|
2135
|
+
return entry.all.some(
|
|
2136
|
+
(e) => e.key === key && e.isActive && (e.validUntil == null || e.validUntil > nowSec)
|
|
2137
|
+
);
|
|
2063
2138
|
}
|
|
2064
2139
|
/**
|
|
2065
|
-
* Full snapshot for callers that need source / validUntil.
|
|
2066
|
-
* `[]` when the customer has no cached entry
|
|
2067
|
-
*
|
|
2140
|
+
* Full snapshot for callers that need source / validUntil details.
|
|
2141
|
+
* Returns `[]` ONLY when the customer has no cached entry — a stale
|
|
2142
|
+
* or past-TTL entry still returns its last-known-good entitlements
|
|
2143
|
+
* (same durability posture as `isEntitled()`; per-entitlement
|
|
2144
|
+
* `validUntil` is the caller's to honour from the returned objects).
|
|
2068
2145
|
*/
|
|
2069
2146
|
list(customerId) {
|
|
2070
2147
|
const entry = this.byCustomer.get(customerId);
|
|
2071
2148
|
if (!entry) return [];
|
|
2072
|
-
if (Date.now() > entry.expiresAt) return [];
|
|
2073
2149
|
return entry.all.slice();
|
|
2074
2150
|
}
|
|
2075
2151
|
/**
|
|
2076
|
-
* Whether
|
|
2077
|
-
* deciding whether to warm before
|
|
2152
|
+
* Whether the customer's entry is still within `ttlMs` — i.e. a
|
|
2153
|
+
* re-fetch is NOT yet due. Useful for deciding whether to warm before
|
|
2154
|
+
* a hot path. A `false` result does NOT mean the cache is empty or
|
|
2155
|
+
* that `isEntitled()` will return false — it only means the data is
|
|
2156
|
+
* past its refresh hint. See `needsRefresh()` for the inverse.
|
|
2078
2157
|
*/
|
|
2079
2158
|
isFresh(customerId) {
|
|
2080
2159
|
const entry = this.byCustomer.get(customerId);
|
|
2081
|
-
return Boolean(entry && Date.now() <= entry.
|
|
2160
|
+
return Boolean(entry && Date.now() <= entry.refreshDueAt);
|
|
2161
|
+
}
|
|
2162
|
+
/**
|
|
2163
|
+
* Whether the customer should be re-fetched: either there is no
|
|
2164
|
+
* cached entry, or the entry is past its `ttlMs` refresh hint, or the
|
|
2165
|
+
* most recent refresh attempt for them failed (retry it).
|
|
2166
|
+
*
|
|
2167
|
+
* This is purely advisory — `getEntitlements()` decides when to act
|
|
2168
|
+
* on it. It NEVER gates `isEntitled()`, which serves last-known-good
|
|
2169
|
+
* regardless.
|
|
2170
|
+
*/
|
|
2171
|
+
needsRefresh(customerId) {
|
|
2172
|
+
const entry = this.byCustomer.get(customerId);
|
|
2173
|
+
if (!entry) return true;
|
|
2174
|
+
if (entry.refreshFailedAt > 0) return true;
|
|
2175
|
+
return Date.now() > entry.refreshDueAt;
|
|
2082
2176
|
}
|
|
2083
2177
|
/**
|
|
2084
|
-
* Replace (or insert) the cache entry for a customer
|
|
2085
|
-
* `
|
|
2086
|
-
*
|
|
2087
|
-
*
|
|
2088
|
-
*
|
|
2178
|
+
* Replace (or insert) the cache entry for a customer with a fresh
|
|
2179
|
+
* server response. Sets `refreshDueAt` to `now + ttlMs` and CLEARS
|
|
2180
|
+
* any failed-refresh marker — a success ends the stale state.
|
|
2181
|
+
*
|
|
2182
|
+
* Called ONLY after a SUCCESSFUL server read — a failed fetch is
|
|
2183
|
+
* routed to `markRefreshFailed` instead and never reaches here, so
|
|
2184
|
+
* last-known-good is preserved through an outage.
|
|
2185
|
+
*
|
|
2186
|
+
* Re-inserting an existing customerId "touches" it — the entry moves
|
|
2187
|
+
* to the end of insertion order (Map semantics) so it's treated as
|
|
2188
|
+
* most-recently-used for LRU eviction. Fires listeners.
|
|
2089
2189
|
*/
|
|
2090
2190
|
setForCustomer(customerId, entitlements) {
|
|
2091
2191
|
const now = Date.now();
|
|
2092
2192
|
this.byCustomer.delete(customerId);
|
|
2093
2193
|
this.byCustomer.set(customerId, {
|
|
2094
2194
|
all: entitlements.slice(),
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2195
|
+
refreshDueAt: now + this.ttlMs,
|
|
2196
|
+
populatedAt: now,
|
|
2197
|
+
refreshFailedAt: 0
|
|
2098
2198
|
});
|
|
2099
2199
|
while (this.byCustomer.size > this.maxCustomers) {
|
|
2100
2200
|
const oldestKey = this.byCustomer.keys().next().value;
|
|
@@ -2104,6 +2204,68 @@ var EntitlementCache = class {
|
|
|
2104
2204
|
}
|
|
2105
2205
|
this.notify(customerId, entitlements);
|
|
2106
2206
|
}
|
|
2207
|
+
/**
|
|
2208
|
+
* Record that a refresh attempt for a customer FAILED (Crossdeck
|
|
2209
|
+
* unreachable / transient error). `getEntitlements()` calls this in
|
|
2210
|
+
* its catch path.
|
|
2211
|
+
*
|
|
2212
|
+
* It does NOT touch the customer's cached entitlements — last-known-
|
|
2213
|
+
* good keeps serving — it only stamps `refreshFailedAt` so the
|
|
2214
|
+
* customer shows up as stale in `diagnostics()` rather than the
|
|
2215
|
+
* staleness being a silent unbounded window.
|
|
2216
|
+
*
|
|
2217
|
+
* If the customer has no entry yet (a genuine cold miss whose first
|
|
2218
|
+
* fetch failed) a stub entry with no entitlements is created purely
|
|
2219
|
+
* to carry the failed-refresh marker — so "we tried and Crossdeck was
|
|
2220
|
+
* down" is observable even before any successful warm. The stub holds
|
|
2221
|
+
* an empty entitlement set, so `isEntitled()` still correctly returns
|
|
2222
|
+
* false for it; there is genuinely nothing to serve.
|
|
2223
|
+
*/
|
|
2224
|
+
markRefreshFailed(customerId) {
|
|
2225
|
+
const now = Date.now();
|
|
2226
|
+
const entry = this.byCustomer.get(customerId);
|
|
2227
|
+
if (entry) {
|
|
2228
|
+
entry.refreshFailedAt = now;
|
|
2229
|
+
return;
|
|
2230
|
+
}
|
|
2231
|
+
this.byCustomer.set(customerId, {
|
|
2232
|
+
all: [],
|
|
2233
|
+
refreshDueAt: now + this.ttlMs,
|
|
2234
|
+
populatedAt: 0,
|
|
2235
|
+
refreshFailedAt: now
|
|
2236
|
+
});
|
|
2237
|
+
while (this.byCustomer.size > this.maxCustomers) {
|
|
2238
|
+
const oldestKey = this.byCustomer.keys().next().value;
|
|
2239
|
+
if (oldestKey === void 0) break;
|
|
2240
|
+
this.byCustomer.delete(oldestKey);
|
|
2241
|
+
this.evicted += 1;
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
/**
|
|
2245
|
+
* Whether a customer is knowingly serving older-than-trustworthy
|
|
2246
|
+
* data. True when the most recent refresh ATTEMPT for them failed
|
|
2247
|
+
* (Crossdeck unreachable since the last success — the outage case,
|
|
2248
|
+
* distinct from a benign idle customer simply past `ttlMs`), OR when
|
|
2249
|
+
* their last-known-good has aged past `staleAfterMs`.
|
|
2250
|
+
*
|
|
2251
|
+
* `isStale` NEVER changes what `isEntitled()` returns — the cache
|
|
2252
|
+
* still serves last-known-good. It exists so the staleness is
|
|
2253
|
+
* observable via `diagnostics()` instead of an unbounded silent
|
|
2254
|
+
* window where a revoked customer (event-based revoke, no
|
|
2255
|
+
* `validUntil`) holds access with nobody able to see it.
|
|
2256
|
+
*
|
|
2257
|
+
* Returns false for an unknown customer — nothing cached, nothing
|
|
2258
|
+
* stale.
|
|
2259
|
+
*/
|
|
2260
|
+
isStale(customerId) {
|
|
2261
|
+
const entry = this.byCustomer.get(customerId);
|
|
2262
|
+
if (!entry) return false;
|
|
2263
|
+
return this.entryIsStale(entry);
|
|
2264
|
+
}
|
|
2265
|
+
/** Epoch ms of a customer's last failed refresh, or 0 if none / unknown. */
|
|
2266
|
+
refreshFailedAt(customerId) {
|
|
2267
|
+
return this.byCustomer.get(customerId)?.refreshFailedAt ?? 0;
|
|
2268
|
+
}
|
|
2107
2269
|
/**
|
|
2108
2270
|
* Drop a single customer's entry. Fires listeners with an empty
|
|
2109
2271
|
* list so subscribers know that customer's cache is gone.
|
|
@@ -2169,7 +2331,57 @@ var EntitlementCache = class {
|
|
|
2169
2331
|
get maxSize() {
|
|
2170
2332
|
return this.maxCustomers;
|
|
2171
2333
|
}
|
|
2334
|
+
/** Configured staleness window in ms. */
|
|
2335
|
+
get staleWindowMs() {
|
|
2336
|
+
return this.staleAfterMs;
|
|
2337
|
+
}
|
|
2338
|
+
/**
|
|
2339
|
+
* Count of cached customers currently flagged stale — most recent
|
|
2340
|
+
* refresh failed, or data aged past `staleAfterMs`. The cache keeps
|
|
2341
|
+
* serving last-known-good for them; this is the observability number
|
|
2342
|
+
* `diagnostics()` surfaces.
|
|
2343
|
+
*/
|
|
2344
|
+
get staleCustomerCount() {
|
|
2345
|
+
let count = 0;
|
|
2346
|
+
for (const entry of this.byCustomer.values()) {
|
|
2347
|
+
if (this.entryIsStale(entry)) count += 1;
|
|
2348
|
+
}
|
|
2349
|
+
return count;
|
|
2350
|
+
}
|
|
2351
|
+
/** Whether ANY cached customer is currently stale. */
|
|
2352
|
+
get isAnyStale() {
|
|
2353
|
+
for (const entry of this.byCustomer.values()) {
|
|
2354
|
+
if (this.entryIsStale(entry)) return true;
|
|
2355
|
+
}
|
|
2356
|
+
return false;
|
|
2357
|
+
}
|
|
2358
|
+
/**
|
|
2359
|
+
* Most recent failed-refresh timestamp across all customers (epoch
|
|
2360
|
+
* ms), or 0 if every cached customer's last refresh succeeded.
|
|
2361
|
+
*/
|
|
2362
|
+
get lastRefreshFailedAt() {
|
|
2363
|
+
let max = 0;
|
|
2364
|
+
for (const entry of this.byCustomer.values()) {
|
|
2365
|
+
if (entry.refreshFailedAt > max) max = entry.refreshFailedAt;
|
|
2366
|
+
}
|
|
2367
|
+
return max;
|
|
2368
|
+
}
|
|
2172
2369
|
// ---------- internals ----------
|
|
2370
|
+
/**
|
|
2371
|
+
* Stale iff the entry's most recent refresh attempt failed, OR its
|
|
2372
|
+
* last-known-good has aged past `staleAfterMs`.
|
|
2373
|
+
*
|
|
2374
|
+
* `refreshFailedAt` is non-zero ONLY between a failed refresh and the
|
|
2375
|
+
* next successful one (`setForCustomer` zeroes it), so `> 0` alone
|
|
2376
|
+
* means "a failure occurred since the last success" — no need to
|
|
2377
|
+
* compare against `populatedAt`, which would mis-fire when a failure
|
|
2378
|
+
* and a populate land in the same millisecond. A marker-only stub
|
|
2379
|
+
* (populatedAt 0, failure stamped) is stale via this first clause.
|
|
2380
|
+
*/
|
|
2381
|
+
entryIsStale(entry) {
|
|
2382
|
+
if (entry.refreshFailedAt > 0) return true;
|
|
2383
|
+
return entry.populatedAt > 0 && Date.now() - entry.populatedAt > this.staleAfterMs;
|
|
2384
|
+
}
|
|
2173
2385
|
notify(customerId, snapshot) {
|
|
2174
2386
|
if (this.listeners.size === 0) return;
|
|
2175
2387
|
const snap = snapshot.slice();
|
|
@@ -2260,6 +2472,16 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
2260
2472
|
flushOnExit;
|
|
2261
2473
|
superProps;
|
|
2262
2474
|
entitlementCache;
|
|
2475
|
+
/**
|
|
2476
|
+
* Optional developer-supplied durable store for last-known-good
|
|
2477
|
+
* entitlements (Redis / their DB / a KV). `undefined` when not
|
|
2478
|
+
* configured — the SDK then has no cold-start durability on
|
|
2479
|
+
* serverless, which it states explicitly at boot.
|
|
2480
|
+
*
|
|
2481
|
+
* Touched ONLY from the async `getEntitlements()` — never from the
|
|
2482
|
+
* synchronous `isEntitled()`.
|
|
2483
|
+
*/
|
|
2484
|
+
entitlementStore;
|
|
2263
2485
|
debug;
|
|
2264
2486
|
/**
|
|
2265
2487
|
* Alias map — `developerUserId` / `anonymousId` → canonical
|
|
@@ -2313,8 +2535,10 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
2313
2535
|
this.breadcrumbs = new BreadcrumbBuffer(options.breadcrumbsMaxSize ?? 50);
|
|
2314
2536
|
this.superProps = new SuperPropertyStore();
|
|
2315
2537
|
this.entitlementCache = new EntitlementCache({
|
|
2316
|
-
ttlMs: options.entitlementCacheTtlMs ?? 6e4
|
|
2538
|
+
ttlMs: options.entitlementCacheTtlMs ?? 6e4,
|
|
2539
|
+
staleAfterMs: options.entitlementStaleAfterMs
|
|
2317
2540
|
});
|
|
2541
|
+
this.entitlementStore = options.entitlementStore ?? null;
|
|
2318
2542
|
this.debug = options.debug === true ? new ConsoleDebugLogger() : new NullDebugLogger();
|
|
2319
2543
|
if (options.debug === true) this.debug.enabled = true;
|
|
2320
2544
|
this.debug.emit(
|
|
@@ -2332,6 +2556,11 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
2332
2556
|
intervalMs: options.eventFlushIntervalMs ?? 1500,
|
|
2333
2557
|
envelope: () => ({
|
|
2334
2558
|
appId: this.appId,
|
|
2559
|
+
// Ship env on every batch so the backend can cross-check
|
|
2560
|
+
// against the API-key-derived env and reject mismatches
|
|
2561
|
+
// loudly (env_mismatch). Web has always done this; node now
|
|
2562
|
+
// matches so defence-in-depth is symmetric across SDKs.
|
|
2563
|
+
environment: this.env,
|
|
2335
2564
|
sdk: { name: SDK_NAME, version: this.sdkVersion }
|
|
2336
2565
|
}),
|
|
2337
2566
|
onDrop: (count) => {
|
|
@@ -2347,6 +2576,20 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
2347
2576
|
nextRetryMs: info.delayMs
|
|
2348
2577
|
});
|
|
2349
2578
|
},
|
|
2579
|
+
onPermanentFailure: (info) => {
|
|
2580
|
+
const headline = `[crossdeck] Event batch DROPPED (status ${info.status}): ${info.lastError}. ${info.droppedCount} event(s) lost \u2014 check your secret key + app config.`;
|
|
2581
|
+
console.error(headline);
|
|
2582
|
+
this.debug.emit(
|
|
2583
|
+
"sdk.flush_permanent_failure",
|
|
2584
|
+
headline,
|
|
2585
|
+
{ ...info }
|
|
2586
|
+
);
|
|
2587
|
+
this.emit("queue.permanent_failure", {
|
|
2588
|
+
status: info.status,
|
|
2589
|
+
droppedCount: info.droppedCount,
|
|
2590
|
+
error: info.lastError
|
|
2591
|
+
});
|
|
2592
|
+
},
|
|
2350
2593
|
onFirstFlushSuccess: () => {
|
|
2351
2594
|
this.debug.emit("sdk.first_event_sent", "First batch landed.");
|
|
2352
2595
|
}
|
|
@@ -2361,14 +2604,20 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
2361
2604
|
report: (err) => this.reportCapturedError(err),
|
|
2362
2605
|
getContext: () => ({ ...this.errorContext }),
|
|
2363
2606
|
getTags: () => ({ ...this.errorTags }),
|
|
2364
|
-
|
|
2365
|
-
//
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2607
|
+
// GETTER, not a captured value — `setErrorBeforeSend()` mutates
|
|
2608
|
+
// `this.errorBeforeSend` after init() and the tracker MUST pick
|
|
2609
|
+
// up the new hook on the next error. Pre-fix we worked around
|
|
2610
|
+
// a captured-by-value field with `Object.defineProperty` on the
|
|
2611
|
+
// tracker's private opts; the contract is now a real getter so
|
|
2612
|
+
// we just hand it the closure and the hack is gone.
|
|
2613
|
+
beforeSend: () => this.errorBeforeSend,
|
|
2614
|
+
isConsented: () => true,
|
|
2615
|
+
// Derived from the configured baseUrl at construction time.
|
|
2616
|
+
// Used by the fetch wrapper to skip captureHttp on Crossdeck's
|
|
2617
|
+
// own requests — pre-fix the skip was hardcoded to
|
|
2618
|
+
// `api.cross-deck.com` and broke for customers on staging /
|
|
2619
|
+
// regional / self-hosted base URLs (recursive capture loop).
|
|
2620
|
+
selfHostname: extractSelfHostname(this.baseUrl)
|
|
2372
2621
|
});
|
|
2373
2622
|
this.errorTracker.install();
|
|
2374
2623
|
}
|
|
@@ -2381,6 +2630,7 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
2381
2630
|
});
|
|
2382
2631
|
this.flushOnExit.install();
|
|
2383
2632
|
}
|
|
2633
|
+
this.emitDurabilityWarning();
|
|
2384
2634
|
if (options.testMode !== true && options.bootHeartbeat !== false) {
|
|
2385
2635
|
setImmediate(() => {
|
|
2386
2636
|
void this.heartbeat().catch((err) => {
|
|
@@ -2390,7 +2640,72 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
2390
2640
|
{ message: err instanceof Error ? err.message : String(err) }
|
|
2391
2641
|
);
|
|
2392
2642
|
});
|
|
2643
|
+
this.emitBootTelemetryEvent();
|
|
2644
|
+
});
|
|
2645
|
+
}
|
|
2646
|
+
}
|
|
2647
|
+
/**
|
|
2648
|
+
* Emit the honest "no cold-start durability" warning when the runtime
|
|
2649
|
+
* is serverless AND no `entitlementStore` is wired. Local-only debug
|
|
2650
|
+
* signal — no network call, no phone-home. Safe to fire from the
|
|
2651
|
+
* constructor before `setImmediate` because there is no I/O on this
|
|
2652
|
+
* path.
|
|
2653
|
+
*
|
|
2654
|
+
* `isServerless` AND no store is the gap: a cold start begins with an
|
|
2655
|
+
* empty in-memory cache and a brief Crossdeck outage in that window
|
|
2656
|
+
* would read a paying customer as un-entitled. That gap is
|
|
2657
|
+
* unavoidable without a store — so the SDK STATES it (a
|
|
2658
|
+
* `sdk.no_durable_store` debug warning) rather than hiding it.
|
|
2659
|
+
*
|
|
2660
|
+
* Audit P1 #9: this used to live INSIDE `emitBootTelemetry()` which
|
|
2661
|
+
* itself sat inside the `bootHeartbeat` gate, so any developer who
|
|
2662
|
+
* set `bootHeartbeat: false` silently disabled the entire reason
|
|
2663
|
+
* `entitlementStore` exists. Now split: warning fires
|
|
2664
|
+
* unconditionally; the boot phone-home stays gated.
|
|
2665
|
+
*/
|
|
2666
|
+
emitDurabilityWarning() {
|
|
2667
|
+
const isServerless = this.runtime.isServerless;
|
|
2668
|
+
const hasStore = this.entitlementStore !== null;
|
|
2669
|
+
if (isServerless && !hasStore) {
|
|
2670
|
+
this.debug.emit(
|
|
2671
|
+
"sdk.no_durable_store",
|
|
2672
|
+
`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.`,
|
|
2673
|
+
{ host: this.runtime.host, isServerless, durableStore: false }
|
|
2674
|
+
);
|
|
2675
|
+
}
|
|
2676
|
+
}
|
|
2677
|
+
/**
|
|
2678
|
+
* Emit the one-time `sdk.boot` telemetry event — the aggregatable
|
|
2679
|
+
* fact the backend pivots on (compute fleet-wide
|
|
2680
|
+
* "% serverless-with-no-durable-store"). Rides the batched + retried
|
|
2681
|
+
* + idempotent queue and is drained by flush-on-exit, so it survives
|
|
2682
|
+
* a serverless teardown.
|
|
2683
|
+
*
|
|
2684
|
+
* Why a `track()` event and not the heartbeat: `GET /v1/sdk/heartbeat`
|
|
2685
|
+
* carries no request body, so it cannot transport a structured
|
|
2686
|
+
* `durability` fact.
|
|
2687
|
+
*
|
|
2688
|
+
* Gated by `bootHeartbeat` (and `testMode`) because it IS a phone-
|
|
2689
|
+
* home — the unconditional surface is `emitDurabilityWarning()`,
|
|
2690
|
+
* which has no network call.
|
|
2691
|
+
*/
|
|
2692
|
+
emitBootTelemetryEvent() {
|
|
2693
|
+
const isServerless = this.runtime.isServerless;
|
|
2694
|
+
const hasStore = this.entitlementStore !== null;
|
|
2695
|
+
const coldStartDurable = hasStore || !isServerless;
|
|
2696
|
+
try {
|
|
2697
|
+
this.track({
|
|
2698
|
+
name: "sdk.boot",
|
|
2699
|
+
anonymousId: this.processAnonymousId,
|
|
2700
|
+
properties: {
|
|
2701
|
+
"durability.entitlementStore": hasStore,
|
|
2702
|
+
"durability.coldStartDurable": coldStartDurable,
|
|
2703
|
+
"durability.runtimeIsServerless": isServerless,
|
|
2704
|
+
"durability.runtimeHost": this.runtime.host,
|
|
2705
|
+
"durability.entitlementCacheTtlMs": this.entitlementCache.ttl
|
|
2706
|
+
}
|
|
2393
2707
|
});
|
|
2708
|
+
} catch {
|
|
2394
2709
|
}
|
|
2395
2710
|
}
|
|
2396
2711
|
// ============================================================
|
|
@@ -2448,13 +2763,77 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
2448
2763
|
// alias so a subsequent `isEntitled({ userId }, "pro")` resolves
|
|
2449
2764
|
// to the same cache entry.
|
|
2450
2765
|
// ============================================================
|
|
2766
|
+
/**
|
|
2767
|
+
* Fetch a customer's entitlements from Crossdeck and warm the cache.
|
|
2768
|
+
*
|
|
2769
|
+
* Durability — this is where last-known-good lives, NOT in the
|
|
2770
|
+
* synchronous `isEntitled()`:
|
|
2771
|
+
* - On a SUCCESSFUL fetch: the entitlement cache is populated and,
|
|
2772
|
+
* if an `entitlementStore` is configured, the result is persisted
|
|
2773
|
+
* to it (`await store.save(...)`). The cache + store now hold
|
|
2774
|
+
* server-confirmed truth.
|
|
2775
|
+
* - On a network FAILURE: the cache is marked refresh-failed for the
|
|
2776
|
+
* customer (so `diagnostics()` shows the staleness), then — if a
|
|
2777
|
+
* store is configured — last-known-good is loaded back from it
|
|
2778
|
+
* (`await store.load(...)`). If the store yields a snapshot, the
|
|
2779
|
+
* cache is populated from it and that snapshot is RETURNED as a
|
|
2780
|
+
* normal `EntitlementsListResponse` — a cold-start / outage no
|
|
2781
|
+
* longer fails a paying customer. If there is no store, or the
|
|
2782
|
+
* store is empty, the network error is rethrown unchanged so the
|
|
2783
|
+
* caller still sees the failure.
|
|
2784
|
+
*
|
|
2785
|
+
* The store is touched only here, inside the `await` that already
|
|
2786
|
+
* existed. `isEntitled()` remains a pure synchronous `Map` read.
|
|
2787
|
+
*/
|
|
2451
2788
|
async getEntitlements(hints, options) {
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2789
|
+
let response;
|
|
2790
|
+
try {
|
|
2791
|
+
response = await this.http.request("GET", "/entitlements", {
|
|
2792
|
+
query: this.identityPayload(hints),
|
|
2793
|
+
signal: options?.signal,
|
|
2794
|
+
timeoutMs: options?.timeoutMs
|
|
2795
|
+
});
|
|
2796
|
+
} catch (err) {
|
|
2797
|
+
const failedCustomerId = this.resolveFailedRefreshCustomerId(hints);
|
|
2798
|
+
if (failedCustomerId) {
|
|
2799
|
+
this.entitlementCache.markRefreshFailed(failedCustomerId);
|
|
2800
|
+
}
|
|
2801
|
+
const recovered = await this.loadEntitlementsFromStore(hints);
|
|
2802
|
+
if (recovered) {
|
|
2803
|
+
const recoveredResponse = {
|
|
2804
|
+
object: "list",
|
|
2805
|
+
data: recovered.entitlements,
|
|
2806
|
+
crossdeckCustomerId: recovered.crossdeckCustomerId,
|
|
2807
|
+
env: recovered.env
|
|
2808
|
+
};
|
|
2809
|
+
this.populateEntitlementCache(hints, recoveredResponse);
|
|
2810
|
+
this.entitlementCache.markRefreshFailed(recovered.crossdeckCustomerId);
|
|
2811
|
+
this.debug.emit(
|
|
2812
|
+
"sdk.entitlement_store_recovered",
|
|
2813
|
+
`Crossdeck unreachable \u2014 served ${recovered.crossdeckCustomerId} from the durable store (${recovered.entitlements.length} entitlement(s), last refreshed ${new Date(recovered.savedAt).toISOString()}).`,
|
|
2814
|
+
{
|
|
2815
|
+
customerId: recovered.crossdeckCustomerId,
|
|
2816
|
+
savedAt: recovered.savedAt,
|
|
2817
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2818
|
+
}
|
|
2819
|
+
);
|
|
2820
|
+
return recoveredResponse;
|
|
2821
|
+
}
|
|
2822
|
+
if (failedCustomerId && this.entitlementCache.isStale(failedCustomerId)) {
|
|
2823
|
+
this.debug.emit(
|
|
2824
|
+
"sdk.entitlement_cache_stale",
|
|
2825
|
+
`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().",
|
|
2826
|
+
{
|
|
2827
|
+
customerId: failedCustomerId,
|
|
2828
|
+
durableStore: this.entitlementStore !== null,
|
|
2829
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2830
|
+
}
|
|
2831
|
+
);
|
|
2832
|
+
}
|
|
2833
|
+
throw err;
|
|
2834
|
+
}
|
|
2457
2835
|
this.populateEntitlementCache(hints, response);
|
|
2836
|
+
await this.saveEntitlementsToStore(hints, response);
|
|
2458
2837
|
return response;
|
|
2459
2838
|
}
|
|
2460
2839
|
async getCustomerEntitlements(customerId, options) {
|
|
@@ -2625,7 +3004,13 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
2625
3004
|
const normalized = events.map((event) => this.normalizeIngestEvent(event));
|
|
2626
3005
|
const body = {
|
|
2627
3006
|
events: normalized,
|
|
2628
|
-
sdk: { name: SDK_NAME, version: this.sdkVersion }
|
|
3007
|
+
sdk: { name: SDK_NAME, version: this.sdkVersion },
|
|
3008
|
+
// Match the queue's batch envelope (see event-queue.ts) — backend
|
|
3009
|
+
// cross-checks `environment` against the API-key-derived env and
|
|
3010
|
+
// rejects mismatches loudly (env_mismatch). Pre-fix this direct
|
|
3011
|
+
// ingest path skipped env, so a "live key, env: sandbox"
|
|
3012
|
+
// misconfig fell through silently for the bulk-import path.
|
|
3013
|
+
environment: this.env
|
|
2629
3014
|
};
|
|
2630
3015
|
if (this.appId) body.appId = this.appId;
|
|
2631
3016
|
return this.http.request("POST", "/events", {
|
|
@@ -2690,8 +3075,9 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
2690
3075
|
message: "syncPurchases requires a signedTransactionInfo string."
|
|
2691
3076
|
});
|
|
2692
3077
|
}
|
|
3078
|
+
const rail = input.rail ?? "apple";
|
|
2693
3079
|
return this.http.request("POST", "/purchases/sync", {
|
|
2694
|
-
body: {
|
|
3080
|
+
body: { ...input, rail },
|
|
2695
3081
|
signal: options?.signal,
|
|
2696
3082
|
timeoutMs: options?.timeoutMs
|
|
2697
3083
|
});
|
|
@@ -2968,7 +3354,14 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
2968
3354
|
count: this.entitlementCache.customerCount,
|
|
2969
3355
|
lastUpdated: this.entitlementCache.lastUpdated,
|
|
2970
3356
|
ttlMs: this.entitlementCache.ttl,
|
|
2971
|
-
listenerErrors: this.entitlementCache.listenerErrors
|
|
3357
|
+
listenerErrors: this.entitlementCache.listenerErrors,
|
|
3358
|
+
staleCustomers: this.entitlementCache.staleCustomerCount,
|
|
3359
|
+
isStale: this.entitlementCache.isAnyStale,
|
|
3360
|
+
lastRefreshFailedAt: this.entitlementCache.lastRefreshFailedAt,
|
|
3361
|
+
durableStore: this.entitlementStore !== null,
|
|
3362
|
+
// Cold-start durable iff a store is wired, OR the host is
|
|
3363
|
+
// long-lived (the process, hence the in-memory cache, survives).
|
|
3364
|
+
coldStartDurable: this.entitlementStore !== null || !this.runtime.isServerless
|
|
2972
3365
|
},
|
|
2973
3366
|
events: this.eventQueue.getStats(),
|
|
2974
3367
|
errors: {
|
|
@@ -3182,6 +3575,90 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
3182
3575
|
} catch {
|
|
3183
3576
|
}
|
|
3184
3577
|
}
|
|
3578
|
+
/**
|
|
3579
|
+
* Persist a successful entitlements fetch to the durable store, if
|
|
3580
|
+
* one is configured. No-op when there is no store.
|
|
3581
|
+
*
|
|
3582
|
+
* Saved under EVERY identity the caller might later look up by — the
|
|
3583
|
+
* canonical `crossdeckCustomerId` plus any `userId` / `anonymousId`
|
|
3584
|
+
* hint. The Node cache resolves a hint to a canonical ID via an
|
|
3585
|
+
* in-memory alias map; on a cold start that map is empty, so a
|
|
3586
|
+
* failure-path `load()` must be able to hit the store with the raw
|
|
3587
|
+
* hint the caller passed. Saving under all keys makes that work.
|
|
3588
|
+
*
|
|
3589
|
+
* Best-effort: a store `save()` that throws is swallowed (logged in
|
|
3590
|
+
* debug) — it weakens durability for that customer but must never
|
|
3591
|
+
* fail an otherwise-successful `getEntitlements()`.
|
|
3592
|
+
*/
|
|
3593
|
+
async saveEntitlementsToStore(hints, response) {
|
|
3594
|
+
if (!this.entitlementStore) return;
|
|
3595
|
+
const customerId = response.crossdeckCustomerId;
|
|
3596
|
+
if (!customerId) return;
|
|
3597
|
+
const snapshot = {
|
|
3598
|
+
v: 1,
|
|
3599
|
+
crossdeckCustomerId: customerId,
|
|
3600
|
+
entitlements: response.data,
|
|
3601
|
+
env: response.env,
|
|
3602
|
+
savedAt: Date.now()
|
|
3603
|
+
};
|
|
3604
|
+
const keys = /* @__PURE__ */ new Set([customerId]);
|
|
3605
|
+
if (hints.customerId) keys.add(hints.customerId);
|
|
3606
|
+
if (hints.userId) keys.add(hints.userId);
|
|
3607
|
+
if (hints.anonymousId) keys.add(hints.anonymousId);
|
|
3608
|
+
for (const key of keys) {
|
|
3609
|
+
try {
|
|
3610
|
+
await this.entitlementStore.save(key, snapshot);
|
|
3611
|
+
} catch (err) {
|
|
3612
|
+
this.debug.emit(
|
|
3613
|
+
"sdk.entitlement_store_recovered",
|
|
3614
|
+
`entitlementStore.save failed for key ${key} \u2014 durability weakened for this customer.`,
|
|
3615
|
+
{ key, error: err instanceof Error ? err.message : String(err) }
|
|
3616
|
+
);
|
|
3617
|
+
}
|
|
3618
|
+
}
|
|
3619
|
+
}
|
|
3620
|
+
/**
|
|
3621
|
+
* Load last-known-good entitlements from the durable store on a
|
|
3622
|
+
* network-failure path. Returns the first snapshot found across the
|
|
3623
|
+
* caller's identity keys, or `null` if there is no store / no stored
|
|
3624
|
+
* snapshot / every read failed.
|
|
3625
|
+
*
|
|
3626
|
+
* Tries the canonical `customerId` hint first, then `userId`, then
|
|
3627
|
+
* `anonymousId` — the order callers most commonly key by. A corrupt
|
|
3628
|
+
* or wrong-shaped blob is treated as a miss (the store is developer-
|
|
3629
|
+
* supplied; the SDK validates rather than trusts).
|
|
3630
|
+
*/
|
|
3631
|
+
async loadEntitlementsFromStore(hints) {
|
|
3632
|
+
if (!this.entitlementStore) return null;
|
|
3633
|
+
const keys = [];
|
|
3634
|
+
if (hints.customerId) keys.push(hints.customerId);
|
|
3635
|
+
if (hints.userId) keys.push(hints.userId);
|
|
3636
|
+
if (hints.anonymousId) keys.push(hints.anonymousId);
|
|
3637
|
+
for (const key of keys) {
|
|
3638
|
+
let loaded = null;
|
|
3639
|
+
try {
|
|
3640
|
+
loaded = await this.entitlementStore.load(key);
|
|
3641
|
+
} catch {
|
|
3642
|
+
continue;
|
|
3643
|
+
}
|
|
3644
|
+
if (isValidStoredEntitlements(loaded)) return loaded;
|
|
3645
|
+
}
|
|
3646
|
+
return null;
|
|
3647
|
+
}
|
|
3648
|
+
/**
|
|
3649
|
+
* Resolve the customer ID to stamp a failed-refresh marker against.
|
|
3650
|
+
*
|
|
3651
|
+
* Prefers a canonical ID the cache already knows (so the marker lands
|
|
3652
|
+
* on the existing warm entry), then falls back to whatever raw hint
|
|
3653
|
+
* the caller supplied — on a true cold-start failure there is no
|
|
3654
|
+
* cache entry yet, and marking under the hint still makes "we tried
|
|
3655
|
+
* for this customer and Crossdeck was down" observable.
|
|
3656
|
+
*/
|
|
3657
|
+
resolveFailedRefreshCustomerId(hints) {
|
|
3658
|
+
const known = this.resolveCacheCustomerId(hints);
|
|
3659
|
+
if (known) return known;
|
|
3660
|
+
return hints.customerId ?? hints.userId ?? hints.anonymousId ?? null;
|
|
3661
|
+
}
|
|
3185
3662
|
touchAlias(alias, customerId) {
|
|
3186
3663
|
this.customerIdAliases.delete(alias);
|
|
3187
3664
|
this.customerIdAliases.set(alias, customerId);
|
|
@@ -3195,10 +3672,21 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
3195
3672
|
* Resolve any hint shape (canonical customerId / userId hint /
|
|
3196
3673
|
* anonymousId hint / raw string) to a `crossdeckCustomerId` if we
|
|
3197
3674
|
* have a cache entry for it.
|
|
3675
|
+
*
|
|
3676
|
+
* String overload is STRICT on the canonical-id shape. Pre-fix
|
|
3677
|
+
* `isFresh(raw)` treated any string with a cache entry as a valid
|
|
3678
|
+
* canonical id — if tenant A's userId happened to collide with
|
|
3679
|
+
* tenant B's crossdeckCustomerId, A's call would resolve to B's
|
|
3680
|
+
* cached entitlements. Bounded by the `cdcust_` prefix convention
|
|
3681
|
+
* (which both SDKs and the backend mint, see
|
|
3682
|
+
* backend/src/lib/customers.ts) — anything else is treated purely
|
|
3683
|
+
* as an alias lookup, never as a canonical id. Audit P1 #19.
|
|
3198
3684
|
*/
|
|
3199
3685
|
resolveCacheCustomerId(hint) {
|
|
3200
3686
|
if (typeof hint === "string") {
|
|
3201
|
-
if (this.entitlementCache.isFresh(hint))
|
|
3687
|
+
if (hint.startsWith("cdcust_") && this.entitlementCache.isFresh(hint)) {
|
|
3688
|
+
return hint;
|
|
3689
|
+
}
|
|
3202
3690
|
return this.customerIdAliases.get(hint) ?? null;
|
|
3203
3691
|
}
|
|
3204
3692
|
if (hint.customerId) return hint.customerId;
|
|
@@ -3283,6 +3771,11 @@ function sanitizePropertyBag(input, fieldName) {
|
|
|
3283
3771
|
});
|
|
3284
3772
|
}
|
|
3285
3773
|
}
|
|
3774
|
+
function isValidStoredEntitlements(value) {
|
|
3775
|
+
if (typeof value !== "object" || value === null) return false;
|
|
3776
|
+
const v = value;
|
|
3777
|
+
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";
|
|
3778
|
+
}
|
|
3286
3779
|
function categoryFor(name) {
|
|
3287
3780
|
if (name.startsWith("page.") || name.startsWith("navigation.")) return "navigation";
|
|
3288
3781
|
if (name.startsWith("element.") || name.startsWith("ui.click")) return "ui.click";
|
|
@@ -3569,20 +4062,11 @@ function normaliseSecrets(input) {
|
|
|
3569
4062
|
// src/consent.ts
|
|
3570
4063
|
var EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
|
|
3571
4064
|
var CARD_PATTERN = /\b\d(?:[ -]?\d){12,18}\b/g;
|
|
3572
|
-
var REPLACEMENT_EMAIL = "
|
|
3573
|
-
var REPLACEMENT_CARD = "
|
|
4065
|
+
var REPLACEMENT_EMAIL = "<email>";
|
|
4066
|
+
var REPLACEMENT_CARD = "<card>";
|
|
3574
4067
|
function scrubPii(value) {
|
|
3575
4068
|
if (!value) return value;
|
|
3576
|
-
|
|
3577
|
-
if (EMAIL_PATTERN.test(out)) {
|
|
3578
|
-
out = out.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL);
|
|
3579
|
-
}
|
|
3580
|
-
EMAIL_PATTERN.lastIndex = 0;
|
|
3581
|
-
if (CARD_PATTERN.test(out)) {
|
|
3582
|
-
out = out.replace(CARD_PATTERN, REPLACEMENT_CARD);
|
|
3583
|
-
}
|
|
3584
|
-
CARD_PATTERN.lastIndex = 0;
|
|
3585
|
-
return out;
|
|
4069
|
+
return value.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL).replace(CARD_PATTERN, REPLACEMENT_CARD);
|
|
3586
4070
|
}
|
|
3587
4071
|
function scrubPiiFromProperties(properties) {
|
|
3588
4072
|
const out = {};
|