@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.mjs
CHANGED
|
@@ -316,9 +316,11 @@ function byteLength(s) {
|
|
|
316
316
|
return s.length * 4;
|
|
317
317
|
}
|
|
318
318
|
|
|
319
|
-
// src/
|
|
319
|
+
// src/_version.ts
|
|
320
|
+
var SDK_VERSION = "1.3.1";
|
|
320
321
|
var SDK_NAME = "@cross-deck/node";
|
|
321
|
-
|
|
322
|
+
|
|
323
|
+
// src/http.ts
|
|
322
324
|
var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
|
|
323
325
|
var DEFAULT_TIMEOUT_MS = 15e3;
|
|
324
326
|
var CROSSDECK_API_VERSION = "2025-01-01";
|
|
@@ -831,6 +833,7 @@ var EventQueue = class {
|
|
|
831
833
|
sdk: env.sdk
|
|
832
834
|
};
|
|
833
835
|
if (env.appId) body.appId = env.appId;
|
|
836
|
+
if (env.environment) body.environment = env.environment;
|
|
834
837
|
const result = await this.cfg.http.request("POST", "/events", {
|
|
835
838
|
body,
|
|
836
839
|
idempotencyKey: batchId
|
|
@@ -849,6 +852,20 @@ var EventQueue = class {
|
|
|
849
852
|
} catch (err) {
|
|
850
853
|
const message = err instanceof Error ? err.message : String(err);
|
|
851
854
|
this.lastError = message;
|
|
855
|
+
if (isPermanent4xx(err)) {
|
|
856
|
+
const droppedCount = batch.length;
|
|
857
|
+
this.pendingBatch = null;
|
|
858
|
+
this.pendingBatchId = null;
|
|
859
|
+
this.inFlight -= droppedCount;
|
|
860
|
+
this.dropped += droppedCount;
|
|
861
|
+
this.cfg.onDrop?.(droppedCount);
|
|
862
|
+
this.cfg.onPermanentFailure?.({
|
|
863
|
+
status: err.status ?? 0,
|
|
864
|
+
droppedCount,
|
|
865
|
+
lastError: message
|
|
866
|
+
});
|
|
867
|
+
return null;
|
|
868
|
+
}
|
|
852
869
|
const retryAfterMs = extractRetryAfterMs(err);
|
|
853
870
|
const delay = this.retry.nextDelay(retryAfterMs);
|
|
854
871
|
this.scheduleRetry(delay);
|
|
@@ -927,6 +944,14 @@ function extractRetryAfterMs(err) {
|
|
|
927
944
|
}
|
|
928
945
|
return void 0;
|
|
929
946
|
}
|
|
947
|
+
function isPermanent4xx(err) {
|
|
948
|
+
if (!err || typeof err !== "object") return false;
|
|
949
|
+
const status = err.status;
|
|
950
|
+
if (typeof status !== "number" || !Number.isFinite(status)) return false;
|
|
951
|
+
if (status < 400 || status >= 500) return false;
|
|
952
|
+
if (status === 408 || status === 429) return false;
|
|
953
|
+
return true;
|
|
954
|
+
}
|
|
930
955
|
function defaultScheduler(fn, ms) {
|
|
931
956
|
const id = setTimeout(fn, ms);
|
|
932
957
|
if (typeof id.unref === "function") {
|
|
@@ -1216,16 +1241,18 @@ var ErrorTracker = class {
|
|
|
1216
1241
|
const url = typeof input === "string" ? input : input?.url ?? "";
|
|
1217
1242
|
const method = (init.method || "GET").toUpperCase();
|
|
1218
1243
|
const start = Date.now();
|
|
1219
|
-
tracker.opts.
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1244
|
+
if (!isSelfRequest(url, tracker.opts.selfHostname)) {
|
|
1245
|
+
tracker.opts.breadcrumbs.add({
|
|
1246
|
+
timestamp: start,
|
|
1247
|
+
category: "http",
|
|
1248
|
+
message: `${method} ${url}`,
|
|
1249
|
+
data: { url, method }
|
|
1250
|
+
});
|
|
1251
|
+
}
|
|
1225
1252
|
try {
|
|
1226
1253
|
const response = await origFetch(...args);
|
|
1227
1254
|
if (response.status >= 500 && tracker.opts.isConsented()) {
|
|
1228
|
-
if (!url.
|
|
1255
|
+
if (!isSelfRequest(url, tracker.opts.selfHostname)) {
|
|
1229
1256
|
tracker.captureHttp({
|
|
1230
1257
|
url,
|
|
1231
1258
|
method,
|
|
@@ -1343,9 +1370,10 @@ var ErrorTracker = class {
|
|
|
1343
1370
|
if (!this.passesSample(err)) return;
|
|
1344
1371
|
if (!this.passesRateLimit(err)) return;
|
|
1345
1372
|
let finalErr = err;
|
|
1346
|
-
|
|
1373
|
+
const hook = this.opts.beforeSend?.();
|
|
1374
|
+
if (hook) {
|
|
1347
1375
|
try {
|
|
1348
|
-
finalErr =
|
|
1376
|
+
finalErr = hook(err);
|
|
1349
1377
|
} catch {
|
|
1350
1378
|
finalErr = err;
|
|
1351
1379
|
}
|
|
@@ -1571,9 +1599,35 @@ function safeClone(v) {
|
|
|
1571
1599
|
function safeStringify3(v) {
|
|
1572
1600
|
return coerceErrorPayload(v).message;
|
|
1573
1601
|
}
|
|
1602
|
+
function extractSelfHostname(baseUrl) {
|
|
1603
|
+
if (!baseUrl || typeof baseUrl !== "string") return null;
|
|
1604
|
+
try {
|
|
1605
|
+
return new URL(baseUrl).hostname.toLowerCase();
|
|
1606
|
+
} catch {
|
|
1607
|
+
return null;
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
function isSelfRequest(requestUrl, selfHostname) {
|
|
1611
|
+
if (!selfHostname || !requestUrl) return false;
|
|
1612
|
+
try {
|
|
1613
|
+
return new URL(requestUrl).hostname.toLowerCase() === selfHostname;
|
|
1614
|
+
} catch {
|
|
1615
|
+
return false;
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1574
1618
|
|
|
1575
1619
|
// src/runtime-info.ts
|
|
1576
1620
|
import { hostname as osHostname, platform as osPlatform, release as osRelease } from "os";
|
|
1621
|
+
var SERVERLESS_HOSTS = /* @__PURE__ */ new Set([
|
|
1622
|
+
"aws-lambda",
|
|
1623
|
+
"azure-functions",
|
|
1624
|
+
"google-app-engine",
|
|
1625
|
+
"firebase-functions-v1",
|
|
1626
|
+
"firebase-functions-v2",
|
|
1627
|
+
"cloud-run",
|
|
1628
|
+
"vercel",
|
|
1629
|
+
"netlify"
|
|
1630
|
+
]);
|
|
1577
1631
|
var cached = null;
|
|
1578
1632
|
function collectRuntimeInfo(options = {}) {
|
|
1579
1633
|
if (cached) return cached;
|
|
@@ -1589,6 +1643,7 @@ function detect(options) {
|
|
|
1589
1643
|
platformRelease: safeRelease(),
|
|
1590
1644
|
hostname: safeHostname(),
|
|
1591
1645
|
host: detected.host,
|
|
1646
|
+
isServerless: SERVERLESS_HOSTS.has(detected.host),
|
|
1592
1647
|
region: detected.region,
|
|
1593
1648
|
serviceName: options.serviceName ?? detected.serviceName,
|
|
1594
1649
|
serviceVersion: options.serviceVersion ?? detected.serviceVersion,
|
|
@@ -1992,9 +2047,11 @@ var SuperPropertyStore = class {
|
|
|
1992
2047
|
|
|
1993
2048
|
// src/entitlement-cache.ts
|
|
1994
2049
|
var DEFAULT_MAX_CUSTOMERS = 1e4;
|
|
2050
|
+
var DEFAULT_STALE_AFTER_MS = 24 * 60 * 60 * 1e3;
|
|
1995
2051
|
var EntitlementCache = class {
|
|
1996
2052
|
ttlMs;
|
|
1997
2053
|
maxCustomers;
|
|
2054
|
+
staleAfterMs;
|
|
1998
2055
|
byCustomer = /* @__PURE__ */ new Map();
|
|
1999
2056
|
listeners = /* @__PURE__ */ new Set();
|
|
2000
2057
|
listenerErrorCount = 0;
|
|
@@ -2002,52 +2059,95 @@ var EntitlementCache = class {
|
|
|
2002
2059
|
constructor(options = {}) {
|
|
2003
2060
|
this.ttlMs = options.ttlMs ?? 6e4;
|
|
2004
2061
|
this.maxCustomers = options.maxCustomers ?? DEFAULT_MAX_CUSTOMERS;
|
|
2062
|
+
this.staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
|
|
2005
2063
|
}
|
|
2006
2064
|
/**
|
|
2007
|
-
* Synchronous lookup
|
|
2008
|
-
* entitlement
|
|
2009
|
-
*
|
|
2065
|
+
* Synchronous lookup — true iff the customer currently has the
|
|
2066
|
+
* entitlement granting access. Pure in-memory `Map` read, ZERO I/O.
|
|
2067
|
+
*
|
|
2068
|
+
* Served from last-known-good: a stale entry (Crossdeck unreachable
|
|
2069
|
+
* since the last successful fetch, or past `ttlMs`) STILL answers true
|
|
2070
|
+
* for a still-valid entitlement. Cache staleness alone never makes
|
|
2071
|
+
* this `false` — the central durability fix. The only things that
|
|
2072
|
+
* turn it false:
|
|
2073
|
+
* - the customer has no cached entry at all (genuine cold miss)
|
|
2074
|
+
* - no matching `key` in the customer's entitlement set
|
|
2075
|
+
* - the matching entitlement is `isActive: false`
|
|
2076
|
+
* - the matching entitlement is past its OWN `validUntil` — a
|
|
2077
|
+
* time-based trial expiry still applies mid-outage (mirrors the
|
|
2078
|
+
* web SDK's `validUntil` check exactly).
|
|
2079
|
+
*
|
|
2080
|
+
* An entry being past `ttlMs`, or marked refresh-failed, does NOT
|
|
2081
|
+
* affect the answer — `getEntitlements()` re-fetches on the TTL hint,
|
|
2082
|
+
* but until it succeeds the customer keeps their access.
|
|
2010
2083
|
*/
|
|
2011
2084
|
isEntitled(customerId, key) {
|
|
2012
2085
|
const entry = this.byCustomer.get(customerId);
|
|
2013
2086
|
if (!entry) return false;
|
|
2014
|
-
|
|
2015
|
-
return entry.
|
|
2087
|
+
const nowSec = Date.now() / 1e3;
|
|
2088
|
+
return entry.all.some(
|
|
2089
|
+
(e) => e.key === key && e.isActive && (e.validUntil == null || e.validUntil > nowSec)
|
|
2090
|
+
);
|
|
2016
2091
|
}
|
|
2017
2092
|
/**
|
|
2018
|
-
* Full snapshot for callers that need source / validUntil.
|
|
2019
|
-
* `[]` when the customer has no cached entry
|
|
2020
|
-
*
|
|
2093
|
+
* Full snapshot for callers that need source / validUntil details.
|
|
2094
|
+
* Returns `[]` ONLY when the customer has no cached entry — a stale
|
|
2095
|
+
* or past-TTL entry still returns its last-known-good entitlements
|
|
2096
|
+
* (same durability posture as `isEntitled()`; per-entitlement
|
|
2097
|
+
* `validUntil` is the caller's to honour from the returned objects).
|
|
2021
2098
|
*/
|
|
2022
2099
|
list(customerId) {
|
|
2023
2100
|
const entry = this.byCustomer.get(customerId);
|
|
2024
2101
|
if (!entry) return [];
|
|
2025
|
-
if (Date.now() > entry.expiresAt) return [];
|
|
2026
2102
|
return entry.all.slice();
|
|
2027
2103
|
}
|
|
2028
2104
|
/**
|
|
2029
|
-
* Whether
|
|
2030
|
-
* deciding whether to warm before
|
|
2105
|
+
* Whether the customer's entry is still within `ttlMs` — i.e. a
|
|
2106
|
+
* re-fetch is NOT yet due. Useful for deciding whether to warm before
|
|
2107
|
+
* a hot path. A `false` result does NOT mean the cache is empty or
|
|
2108
|
+
* that `isEntitled()` will return false — it only means the data is
|
|
2109
|
+
* past its refresh hint. See `needsRefresh()` for the inverse.
|
|
2031
2110
|
*/
|
|
2032
2111
|
isFresh(customerId) {
|
|
2033
2112
|
const entry = this.byCustomer.get(customerId);
|
|
2034
|
-
return Boolean(entry && Date.now() <= entry.
|
|
2113
|
+
return Boolean(entry && Date.now() <= entry.refreshDueAt);
|
|
2114
|
+
}
|
|
2115
|
+
/**
|
|
2116
|
+
* Whether the customer should be re-fetched: either there is no
|
|
2117
|
+
* cached entry, or the entry is past its `ttlMs` refresh hint, or the
|
|
2118
|
+
* most recent refresh attempt for them failed (retry it).
|
|
2119
|
+
*
|
|
2120
|
+
* This is purely advisory — `getEntitlements()` decides when to act
|
|
2121
|
+
* on it. It NEVER gates `isEntitled()`, which serves last-known-good
|
|
2122
|
+
* regardless.
|
|
2123
|
+
*/
|
|
2124
|
+
needsRefresh(customerId) {
|
|
2125
|
+
const entry = this.byCustomer.get(customerId);
|
|
2126
|
+
if (!entry) return true;
|
|
2127
|
+
if (entry.refreshFailedAt > 0) return true;
|
|
2128
|
+
return Date.now() > entry.refreshDueAt;
|
|
2035
2129
|
}
|
|
2036
2130
|
/**
|
|
2037
|
-
* Replace (or insert) the cache entry for a customer
|
|
2038
|
-
* `
|
|
2039
|
-
*
|
|
2040
|
-
*
|
|
2041
|
-
*
|
|
2131
|
+
* Replace (or insert) the cache entry for a customer with a fresh
|
|
2132
|
+
* server response. Sets `refreshDueAt` to `now + ttlMs` and CLEARS
|
|
2133
|
+
* any failed-refresh marker — a success ends the stale state.
|
|
2134
|
+
*
|
|
2135
|
+
* Called ONLY after a SUCCESSFUL server read — a failed fetch is
|
|
2136
|
+
* routed to `markRefreshFailed` instead and never reaches here, so
|
|
2137
|
+
* last-known-good is preserved through an outage.
|
|
2138
|
+
*
|
|
2139
|
+
* Re-inserting an existing customerId "touches" it — the entry moves
|
|
2140
|
+
* to the end of insertion order (Map semantics) so it's treated as
|
|
2141
|
+
* most-recently-used for LRU eviction. Fires listeners.
|
|
2042
2142
|
*/
|
|
2043
2143
|
setForCustomer(customerId, entitlements) {
|
|
2044
2144
|
const now = Date.now();
|
|
2045
2145
|
this.byCustomer.delete(customerId);
|
|
2046
2146
|
this.byCustomer.set(customerId, {
|
|
2047
2147
|
all: entitlements.slice(),
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2148
|
+
refreshDueAt: now + this.ttlMs,
|
|
2149
|
+
populatedAt: now,
|
|
2150
|
+
refreshFailedAt: 0
|
|
2051
2151
|
});
|
|
2052
2152
|
while (this.byCustomer.size > this.maxCustomers) {
|
|
2053
2153
|
const oldestKey = this.byCustomer.keys().next().value;
|
|
@@ -2057,6 +2157,68 @@ var EntitlementCache = class {
|
|
|
2057
2157
|
}
|
|
2058
2158
|
this.notify(customerId, entitlements);
|
|
2059
2159
|
}
|
|
2160
|
+
/**
|
|
2161
|
+
* Record that a refresh attempt for a customer FAILED (Crossdeck
|
|
2162
|
+
* unreachable / transient error). `getEntitlements()` calls this in
|
|
2163
|
+
* its catch path.
|
|
2164
|
+
*
|
|
2165
|
+
* It does NOT touch the customer's cached entitlements — last-known-
|
|
2166
|
+
* good keeps serving — it only stamps `refreshFailedAt` so the
|
|
2167
|
+
* customer shows up as stale in `diagnostics()` rather than the
|
|
2168
|
+
* staleness being a silent unbounded window.
|
|
2169
|
+
*
|
|
2170
|
+
* If the customer has no entry yet (a genuine cold miss whose first
|
|
2171
|
+
* fetch failed) a stub entry with no entitlements is created purely
|
|
2172
|
+
* to carry the failed-refresh marker — so "we tried and Crossdeck was
|
|
2173
|
+
* down" is observable even before any successful warm. The stub holds
|
|
2174
|
+
* an empty entitlement set, so `isEntitled()` still correctly returns
|
|
2175
|
+
* false for it; there is genuinely nothing to serve.
|
|
2176
|
+
*/
|
|
2177
|
+
markRefreshFailed(customerId) {
|
|
2178
|
+
const now = Date.now();
|
|
2179
|
+
const entry = this.byCustomer.get(customerId);
|
|
2180
|
+
if (entry) {
|
|
2181
|
+
entry.refreshFailedAt = now;
|
|
2182
|
+
return;
|
|
2183
|
+
}
|
|
2184
|
+
this.byCustomer.set(customerId, {
|
|
2185
|
+
all: [],
|
|
2186
|
+
refreshDueAt: now + this.ttlMs,
|
|
2187
|
+
populatedAt: 0,
|
|
2188
|
+
refreshFailedAt: now
|
|
2189
|
+
});
|
|
2190
|
+
while (this.byCustomer.size > this.maxCustomers) {
|
|
2191
|
+
const oldestKey = this.byCustomer.keys().next().value;
|
|
2192
|
+
if (oldestKey === void 0) break;
|
|
2193
|
+
this.byCustomer.delete(oldestKey);
|
|
2194
|
+
this.evicted += 1;
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
/**
|
|
2198
|
+
* Whether a customer is knowingly serving older-than-trustworthy
|
|
2199
|
+
* data. True when the most recent refresh ATTEMPT for them failed
|
|
2200
|
+
* (Crossdeck unreachable since the last success — the outage case,
|
|
2201
|
+
* distinct from a benign idle customer simply past `ttlMs`), OR when
|
|
2202
|
+
* their last-known-good has aged past `staleAfterMs`.
|
|
2203
|
+
*
|
|
2204
|
+
* `isStale` NEVER changes what `isEntitled()` returns — the cache
|
|
2205
|
+
* still serves last-known-good. It exists so the staleness is
|
|
2206
|
+
* observable via `diagnostics()` instead of an unbounded silent
|
|
2207
|
+
* window where a revoked customer (event-based revoke, no
|
|
2208
|
+
* `validUntil`) holds access with nobody able to see it.
|
|
2209
|
+
*
|
|
2210
|
+
* Returns false for an unknown customer — nothing cached, nothing
|
|
2211
|
+
* stale.
|
|
2212
|
+
*/
|
|
2213
|
+
isStale(customerId) {
|
|
2214
|
+
const entry = this.byCustomer.get(customerId);
|
|
2215
|
+
if (!entry) return false;
|
|
2216
|
+
return this.entryIsStale(entry);
|
|
2217
|
+
}
|
|
2218
|
+
/** Epoch ms of a customer's last failed refresh, or 0 if none / unknown. */
|
|
2219
|
+
refreshFailedAt(customerId) {
|
|
2220
|
+
return this.byCustomer.get(customerId)?.refreshFailedAt ?? 0;
|
|
2221
|
+
}
|
|
2060
2222
|
/**
|
|
2061
2223
|
* Drop a single customer's entry. Fires listeners with an empty
|
|
2062
2224
|
* list so subscribers know that customer's cache is gone.
|
|
@@ -2122,7 +2284,57 @@ var EntitlementCache = class {
|
|
|
2122
2284
|
get maxSize() {
|
|
2123
2285
|
return this.maxCustomers;
|
|
2124
2286
|
}
|
|
2287
|
+
/** Configured staleness window in ms. */
|
|
2288
|
+
get staleWindowMs() {
|
|
2289
|
+
return this.staleAfterMs;
|
|
2290
|
+
}
|
|
2291
|
+
/**
|
|
2292
|
+
* Count of cached customers currently flagged stale — most recent
|
|
2293
|
+
* refresh failed, or data aged past `staleAfterMs`. The cache keeps
|
|
2294
|
+
* serving last-known-good for them; this is the observability number
|
|
2295
|
+
* `diagnostics()` surfaces.
|
|
2296
|
+
*/
|
|
2297
|
+
get staleCustomerCount() {
|
|
2298
|
+
let count = 0;
|
|
2299
|
+
for (const entry of this.byCustomer.values()) {
|
|
2300
|
+
if (this.entryIsStale(entry)) count += 1;
|
|
2301
|
+
}
|
|
2302
|
+
return count;
|
|
2303
|
+
}
|
|
2304
|
+
/** Whether ANY cached customer is currently stale. */
|
|
2305
|
+
get isAnyStale() {
|
|
2306
|
+
for (const entry of this.byCustomer.values()) {
|
|
2307
|
+
if (this.entryIsStale(entry)) return true;
|
|
2308
|
+
}
|
|
2309
|
+
return false;
|
|
2310
|
+
}
|
|
2311
|
+
/**
|
|
2312
|
+
* Most recent failed-refresh timestamp across all customers (epoch
|
|
2313
|
+
* ms), or 0 if every cached customer's last refresh succeeded.
|
|
2314
|
+
*/
|
|
2315
|
+
get lastRefreshFailedAt() {
|
|
2316
|
+
let max = 0;
|
|
2317
|
+
for (const entry of this.byCustomer.values()) {
|
|
2318
|
+
if (entry.refreshFailedAt > max) max = entry.refreshFailedAt;
|
|
2319
|
+
}
|
|
2320
|
+
return max;
|
|
2321
|
+
}
|
|
2125
2322
|
// ---------- internals ----------
|
|
2323
|
+
/**
|
|
2324
|
+
* Stale iff the entry's most recent refresh attempt failed, OR its
|
|
2325
|
+
* last-known-good has aged past `staleAfterMs`.
|
|
2326
|
+
*
|
|
2327
|
+
* `refreshFailedAt` is non-zero ONLY between a failed refresh and the
|
|
2328
|
+
* next successful one (`setForCustomer` zeroes it), so `> 0` alone
|
|
2329
|
+
* means "a failure occurred since the last success" — no need to
|
|
2330
|
+
* compare against `populatedAt`, which would mis-fire when a failure
|
|
2331
|
+
* and a populate land in the same millisecond. A marker-only stub
|
|
2332
|
+
* (populatedAt 0, failure stamped) is stale via this first clause.
|
|
2333
|
+
*/
|
|
2334
|
+
entryIsStale(entry) {
|
|
2335
|
+
if (entry.refreshFailedAt > 0) return true;
|
|
2336
|
+
return entry.populatedAt > 0 && Date.now() - entry.populatedAt > this.staleAfterMs;
|
|
2337
|
+
}
|
|
2126
2338
|
notify(customerId, snapshot) {
|
|
2127
2339
|
if (this.listeners.size === 0) return;
|
|
2128
2340
|
const snap = snapshot.slice();
|
|
@@ -2213,6 +2425,16 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
2213
2425
|
flushOnExit;
|
|
2214
2426
|
superProps;
|
|
2215
2427
|
entitlementCache;
|
|
2428
|
+
/**
|
|
2429
|
+
* Optional developer-supplied durable store for last-known-good
|
|
2430
|
+
* entitlements (Redis / their DB / a KV). `undefined` when not
|
|
2431
|
+
* configured — the SDK then has no cold-start durability on
|
|
2432
|
+
* serverless, which it states explicitly at boot.
|
|
2433
|
+
*
|
|
2434
|
+
* Touched ONLY from the async `getEntitlements()` — never from the
|
|
2435
|
+
* synchronous `isEntitled()`.
|
|
2436
|
+
*/
|
|
2437
|
+
entitlementStore;
|
|
2216
2438
|
debug;
|
|
2217
2439
|
/**
|
|
2218
2440
|
* Alias map — `developerUserId` / `anonymousId` → canonical
|
|
@@ -2266,8 +2488,10 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
2266
2488
|
this.breadcrumbs = new BreadcrumbBuffer(options.breadcrumbsMaxSize ?? 50);
|
|
2267
2489
|
this.superProps = new SuperPropertyStore();
|
|
2268
2490
|
this.entitlementCache = new EntitlementCache({
|
|
2269
|
-
ttlMs: options.entitlementCacheTtlMs ?? 6e4
|
|
2491
|
+
ttlMs: options.entitlementCacheTtlMs ?? 6e4,
|
|
2492
|
+
staleAfterMs: options.entitlementStaleAfterMs
|
|
2270
2493
|
});
|
|
2494
|
+
this.entitlementStore = options.entitlementStore ?? null;
|
|
2271
2495
|
this.debug = options.debug === true ? new ConsoleDebugLogger() : new NullDebugLogger();
|
|
2272
2496
|
if (options.debug === true) this.debug.enabled = true;
|
|
2273
2497
|
this.debug.emit(
|
|
@@ -2285,6 +2509,11 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
2285
2509
|
intervalMs: options.eventFlushIntervalMs ?? 1500,
|
|
2286
2510
|
envelope: () => ({
|
|
2287
2511
|
appId: this.appId,
|
|
2512
|
+
// Ship env on every batch so the backend can cross-check
|
|
2513
|
+
// against the API-key-derived env and reject mismatches
|
|
2514
|
+
// loudly (env_mismatch). Web has always done this; node now
|
|
2515
|
+
// matches so defence-in-depth is symmetric across SDKs.
|
|
2516
|
+
environment: this.env,
|
|
2288
2517
|
sdk: { name: SDK_NAME, version: this.sdkVersion }
|
|
2289
2518
|
}),
|
|
2290
2519
|
onDrop: (count) => {
|
|
@@ -2300,6 +2529,20 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
2300
2529
|
nextRetryMs: info.delayMs
|
|
2301
2530
|
});
|
|
2302
2531
|
},
|
|
2532
|
+
onPermanentFailure: (info) => {
|
|
2533
|
+
const headline = `[crossdeck] Event batch DROPPED (status ${info.status}): ${info.lastError}. ${info.droppedCount} event(s) lost \u2014 check your secret key + app config.`;
|
|
2534
|
+
console.error(headline);
|
|
2535
|
+
this.debug.emit(
|
|
2536
|
+
"sdk.flush_permanent_failure",
|
|
2537
|
+
headline,
|
|
2538
|
+
{ ...info }
|
|
2539
|
+
);
|
|
2540
|
+
this.emit("queue.permanent_failure", {
|
|
2541
|
+
status: info.status,
|
|
2542
|
+
droppedCount: info.droppedCount,
|
|
2543
|
+
error: info.lastError
|
|
2544
|
+
});
|
|
2545
|
+
},
|
|
2303
2546
|
onFirstFlushSuccess: () => {
|
|
2304
2547
|
this.debug.emit("sdk.first_event_sent", "First batch landed.");
|
|
2305
2548
|
}
|
|
@@ -2314,14 +2557,20 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
2314
2557
|
report: (err) => this.reportCapturedError(err),
|
|
2315
2558
|
getContext: () => ({ ...this.errorContext }),
|
|
2316
2559
|
getTags: () => ({ ...this.errorTags }),
|
|
2317
|
-
|
|
2318
|
-
//
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2560
|
+
// GETTER, not a captured value — `setErrorBeforeSend()` mutates
|
|
2561
|
+
// `this.errorBeforeSend` after init() and the tracker MUST pick
|
|
2562
|
+
// up the new hook on the next error. Pre-fix we worked around
|
|
2563
|
+
// a captured-by-value field with `Object.defineProperty` on the
|
|
2564
|
+
// tracker's private opts; the contract is now a real getter so
|
|
2565
|
+
// we just hand it the closure and the hack is gone.
|
|
2566
|
+
beforeSend: () => this.errorBeforeSend,
|
|
2567
|
+
isConsented: () => true,
|
|
2568
|
+
// Derived from the configured baseUrl at construction time.
|
|
2569
|
+
// Used by the fetch wrapper to skip captureHttp on Crossdeck's
|
|
2570
|
+
// own requests — pre-fix the skip was hardcoded to
|
|
2571
|
+
// `api.cross-deck.com` and broke for customers on staging /
|
|
2572
|
+
// regional / self-hosted base URLs (recursive capture loop).
|
|
2573
|
+
selfHostname: extractSelfHostname(this.baseUrl)
|
|
2325
2574
|
});
|
|
2326
2575
|
this.errorTracker.install();
|
|
2327
2576
|
}
|
|
@@ -2334,6 +2583,7 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
2334
2583
|
});
|
|
2335
2584
|
this.flushOnExit.install();
|
|
2336
2585
|
}
|
|
2586
|
+
this.emitDurabilityWarning();
|
|
2337
2587
|
if (options.testMode !== true && options.bootHeartbeat !== false) {
|
|
2338
2588
|
setImmediate(() => {
|
|
2339
2589
|
void this.heartbeat().catch((err) => {
|
|
@@ -2343,7 +2593,72 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
2343
2593
|
{ message: err instanceof Error ? err.message : String(err) }
|
|
2344
2594
|
);
|
|
2345
2595
|
});
|
|
2596
|
+
this.emitBootTelemetryEvent();
|
|
2597
|
+
});
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
2600
|
+
/**
|
|
2601
|
+
* Emit the honest "no cold-start durability" warning when the runtime
|
|
2602
|
+
* is serverless AND no `entitlementStore` is wired. Local-only debug
|
|
2603
|
+
* signal — no network call, no phone-home. Safe to fire from the
|
|
2604
|
+
* constructor before `setImmediate` because there is no I/O on this
|
|
2605
|
+
* path.
|
|
2606
|
+
*
|
|
2607
|
+
* `isServerless` AND no store is the gap: a cold start begins with an
|
|
2608
|
+
* empty in-memory cache and a brief Crossdeck outage in that window
|
|
2609
|
+
* would read a paying customer as un-entitled. That gap is
|
|
2610
|
+
* unavoidable without a store — so the SDK STATES it (a
|
|
2611
|
+
* `sdk.no_durable_store` debug warning) rather than hiding it.
|
|
2612
|
+
*
|
|
2613
|
+
* Audit P1 #9: this used to live INSIDE `emitBootTelemetry()` which
|
|
2614
|
+
* itself sat inside the `bootHeartbeat` gate, so any developer who
|
|
2615
|
+
* set `bootHeartbeat: false` silently disabled the entire reason
|
|
2616
|
+
* `entitlementStore` exists. Now split: warning fires
|
|
2617
|
+
* unconditionally; the boot phone-home stays gated.
|
|
2618
|
+
*/
|
|
2619
|
+
emitDurabilityWarning() {
|
|
2620
|
+
const isServerless = this.runtime.isServerless;
|
|
2621
|
+
const hasStore = this.entitlementStore !== null;
|
|
2622
|
+
if (isServerless && !hasStore) {
|
|
2623
|
+
this.debug.emit(
|
|
2624
|
+
"sdk.no_durable_store",
|
|
2625
|
+
`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.`,
|
|
2626
|
+
{ host: this.runtime.host, isServerless, durableStore: false }
|
|
2627
|
+
);
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
/**
|
|
2631
|
+
* Emit the one-time `sdk.boot` telemetry event — the aggregatable
|
|
2632
|
+
* fact the backend pivots on (compute fleet-wide
|
|
2633
|
+
* "% serverless-with-no-durable-store"). Rides the batched + retried
|
|
2634
|
+
* + idempotent queue and is drained by flush-on-exit, so it survives
|
|
2635
|
+
* a serverless teardown.
|
|
2636
|
+
*
|
|
2637
|
+
* Why a `track()` event and not the heartbeat: `GET /v1/sdk/heartbeat`
|
|
2638
|
+
* carries no request body, so it cannot transport a structured
|
|
2639
|
+
* `durability` fact.
|
|
2640
|
+
*
|
|
2641
|
+
* Gated by `bootHeartbeat` (and `testMode`) because it IS a phone-
|
|
2642
|
+
* home — the unconditional surface is `emitDurabilityWarning()`,
|
|
2643
|
+
* which has no network call.
|
|
2644
|
+
*/
|
|
2645
|
+
emitBootTelemetryEvent() {
|
|
2646
|
+
const isServerless = this.runtime.isServerless;
|
|
2647
|
+
const hasStore = this.entitlementStore !== null;
|
|
2648
|
+
const coldStartDurable = hasStore || !isServerless;
|
|
2649
|
+
try {
|
|
2650
|
+
this.track({
|
|
2651
|
+
name: "sdk.boot",
|
|
2652
|
+
anonymousId: this.processAnonymousId,
|
|
2653
|
+
properties: {
|
|
2654
|
+
"durability.entitlementStore": hasStore,
|
|
2655
|
+
"durability.coldStartDurable": coldStartDurable,
|
|
2656
|
+
"durability.runtimeIsServerless": isServerless,
|
|
2657
|
+
"durability.runtimeHost": this.runtime.host,
|
|
2658
|
+
"durability.entitlementCacheTtlMs": this.entitlementCache.ttl
|
|
2659
|
+
}
|
|
2346
2660
|
});
|
|
2661
|
+
} catch {
|
|
2347
2662
|
}
|
|
2348
2663
|
}
|
|
2349
2664
|
// ============================================================
|
|
@@ -2401,13 +2716,77 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
2401
2716
|
// alias so a subsequent `isEntitled({ userId }, "pro")` resolves
|
|
2402
2717
|
// to the same cache entry.
|
|
2403
2718
|
// ============================================================
|
|
2719
|
+
/**
|
|
2720
|
+
* Fetch a customer's entitlements from Crossdeck and warm the cache.
|
|
2721
|
+
*
|
|
2722
|
+
* Durability — this is where last-known-good lives, NOT in the
|
|
2723
|
+
* synchronous `isEntitled()`:
|
|
2724
|
+
* - On a SUCCESSFUL fetch: the entitlement cache is populated and,
|
|
2725
|
+
* if an `entitlementStore` is configured, the result is persisted
|
|
2726
|
+
* to it (`await store.save(...)`). The cache + store now hold
|
|
2727
|
+
* server-confirmed truth.
|
|
2728
|
+
* - On a network FAILURE: the cache is marked refresh-failed for the
|
|
2729
|
+
* customer (so `diagnostics()` shows the staleness), then — if a
|
|
2730
|
+
* store is configured — last-known-good is loaded back from it
|
|
2731
|
+
* (`await store.load(...)`). If the store yields a snapshot, the
|
|
2732
|
+
* cache is populated from it and that snapshot is RETURNED as a
|
|
2733
|
+
* normal `EntitlementsListResponse` — a cold-start / outage no
|
|
2734
|
+
* longer fails a paying customer. If there is no store, or the
|
|
2735
|
+
* store is empty, the network error is rethrown unchanged so the
|
|
2736
|
+
* caller still sees the failure.
|
|
2737
|
+
*
|
|
2738
|
+
* The store is touched only here, inside the `await` that already
|
|
2739
|
+
* existed. `isEntitled()` remains a pure synchronous `Map` read.
|
|
2740
|
+
*/
|
|
2404
2741
|
async getEntitlements(hints, options) {
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2742
|
+
let response;
|
|
2743
|
+
try {
|
|
2744
|
+
response = await this.http.request("GET", "/entitlements", {
|
|
2745
|
+
query: this.identityPayload(hints),
|
|
2746
|
+
signal: options?.signal,
|
|
2747
|
+
timeoutMs: options?.timeoutMs
|
|
2748
|
+
});
|
|
2749
|
+
} catch (err) {
|
|
2750
|
+
const failedCustomerId = this.resolveFailedRefreshCustomerId(hints);
|
|
2751
|
+
if (failedCustomerId) {
|
|
2752
|
+
this.entitlementCache.markRefreshFailed(failedCustomerId);
|
|
2753
|
+
}
|
|
2754
|
+
const recovered = await this.loadEntitlementsFromStore(hints);
|
|
2755
|
+
if (recovered) {
|
|
2756
|
+
const recoveredResponse = {
|
|
2757
|
+
object: "list",
|
|
2758
|
+
data: recovered.entitlements,
|
|
2759
|
+
crossdeckCustomerId: recovered.crossdeckCustomerId,
|
|
2760
|
+
env: recovered.env
|
|
2761
|
+
};
|
|
2762
|
+
this.populateEntitlementCache(hints, recoveredResponse);
|
|
2763
|
+
this.entitlementCache.markRefreshFailed(recovered.crossdeckCustomerId);
|
|
2764
|
+
this.debug.emit(
|
|
2765
|
+
"sdk.entitlement_store_recovered",
|
|
2766
|
+
`Crossdeck unreachable \u2014 served ${recovered.crossdeckCustomerId} from the durable store (${recovered.entitlements.length} entitlement(s), last refreshed ${new Date(recovered.savedAt).toISOString()}).`,
|
|
2767
|
+
{
|
|
2768
|
+
customerId: recovered.crossdeckCustomerId,
|
|
2769
|
+
savedAt: recovered.savedAt,
|
|
2770
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2771
|
+
}
|
|
2772
|
+
);
|
|
2773
|
+
return recoveredResponse;
|
|
2774
|
+
}
|
|
2775
|
+
if (failedCustomerId && this.entitlementCache.isStale(failedCustomerId)) {
|
|
2776
|
+
this.debug.emit(
|
|
2777
|
+
"sdk.entitlement_cache_stale",
|
|
2778
|
+
`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().",
|
|
2779
|
+
{
|
|
2780
|
+
customerId: failedCustomerId,
|
|
2781
|
+
durableStore: this.entitlementStore !== null,
|
|
2782
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2783
|
+
}
|
|
2784
|
+
);
|
|
2785
|
+
}
|
|
2786
|
+
throw err;
|
|
2787
|
+
}
|
|
2410
2788
|
this.populateEntitlementCache(hints, response);
|
|
2789
|
+
await this.saveEntitlementsToStore(hints, response);
|
|
2411
2790
|
return response;
|
|
2412
2791
|
}
|
|
2413
2792
|
async getCustomerEntitlements(customerId, options) {
|
|
@@ -2578,7 +2957,13 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
2578
2957
|
const normalized = events.map((event) => this.normalizeIngestEvent(event));
|
|
2579
2958
|
const body = {
|
|
2580
2959
|
events: normalized,
|
|
2581
|
-
sdk: { name: SDK_NAME, version: this.sdkVersion }
|
|
2960
|
+
sdk: { name: SDK_NAME, version: this.sdkVersion },
|
|
2961
|
+
// Match the queue's batch envelope (see event-queue.ts) — backend
|
|
2962
|
+
// cross-checks `environment` against the API-key-derived env and
|
|
2963
|
+
// rejects mismatches loudly (env_mismatch). Pre-fix this direct
|
|
2964
|
+
// ingest path skipped env, so a "live key, env: sandbox"
|
|
2965
|
+
// misconfig fell through silently for the bulk-import path.
|
|
2966
|
+
environment: this.env
|
|
2582
2967
|
};
|
|
2583
2968
|
if (this.appId) body.appId = this.appId;
|
|
2584
2969
|
return this.http.request("POST", "/events", {
|
|
@@ -2643,8 +3028,9 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
2643
3028
|
message: "syncPurchases requires a signedTransactionInfo string."
|
|
2644
3029
|
});
|
|
2645
3030
|
}
|
|
3031
|
+
const rail = input.rail ?? "apple";
|
|
2646
3032
|
return this.http.request("POST", "/purchases/sync", {
|
|
2647
|
-
body: {
|
|
3033
|
+
body: { ...input, rail },
|
|
2648
3034
|
signal: options?.signal,
|
|
2649
3035
|
timeoutMs: options?.timeoutMs
|
|
2650
3036
|
});
|
|
@@ -2921,7 +3307,14 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
2921
3307
|
count: this.entitlementCache.customerCount,
|
|
2922
3308
|
lastUpdated: this.entitlementCache.lastUpdated,
|
|
2923
3309
|
ttlMs: this.entitlementCache.ttl,
|
|
2924
|
-
listenerErrors: this.entitlementCache.listenerErrors
|
|
3310
|
+
listenerErrors: this.entitlementCache.listenerErrors,
|
|
3311
|
+
staleCustomers: this.entitlementCache.staleCustomerCount,
|
|
3312
|
+
isStale: this.entitlementCache.isAnyStale,
|
|
3313
|
+
lastRefreshFailedAt: this.entitlementCache.lastRefreshFailedAt,
|
|
3314
|
+
durableStore: this.entitlementStore !== null,
|
|
3315
|
+
// Cold-start durable iff a store is wired, OR the host is
|
|
3316
|
+
// long-lived (the process, hence the in-memory cache, survives).
|
|
3317
|
+
coldStartDurable: this.entitlementStore !== null || !this.runtime.isServerless
|
|
2925
3318
|
},
|
|
2926
3319
|
events: this.eventQueue.getStats(),
|
|
2927
3320
|
errors: {
|
|
@@ -3135,6 +3528,90 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
3135
3528
|
} catch {
|
|
3136
3529
|
}
|
|
3137
3530
|
}
|
|
3531
|
+
/**
|
|
3532
|
+
* Persist a successful entitlements fetch to the durable store, if
|
|
3533
|
+
* one is configured. No-op when there is no store.
|
|
3534
|
+
*
|
|
3535
|
+
* Saved under EVERY identity the caller might later look up by — the
|
|
3536
|
+
* canonical `crossdeckCustomerId` plus any `userId` / `anonymousId`
|
|
3537
|
+
* hint. The Node cache resolves a hint to a canonical ID via an
|
|
3538
|
+
* in-memory alias map; on a cold start that map is empty, so a
|
|
3539
|
+
* failure-path `load()` must be able to hit the store with the raw
|
|
3540
|
+
* hint the caller passed. Saving under all keys makes that work.
|
|
3541
|
+
*
|
|
3542
|
+
* Best-effort: a store `save()` that throws is swallowed (logged in
|
|
3543
|
+
* debug) — it weakens durability for that customer but must never
|
|
3544
|
+
* fail an otherwise-successful `getEntitlements()`.
|
|
3545
|
+
*/
|
|
3546
|
+
async saveEntitlementsToStore(hints, response) {
|
|
3547
|
+
if (!this.entitlementStore) return;
|
|
3548
|
+
const customerId = response.crossdeckCustomerId;
|
|
3549
|
+
if (!customerId) return;
|
|
3550
|
+
const snapshot = {
|
|
3551
|
+
v: 1,
|
|
3552
|
+
crossdeckCustomerId: customerId,
|
|
3553
|
+
entitlements: response.data,
|
|
3554
|
+
env: response.env,
|
|
3555
|
+
savedAt: Date.now()
|
|
3556
|
+
};
|
|
3557
|
+
const keys = /* @__PURE__ */ new Set([customerId]);
|
|
3558
|
+
if (hints.customerId) keys.add(hints.customerId);
|
|
3559
|
+
if (hints.userId) keys.add(hints.userId);
|
|
3560
|
+
if (hints.anonymousId) keys.add(hints.anonymousId);
|
|
3561
|
+
for (const key of keys) {
|
|
3562
|
+
try {
|
|
3563
|
+
await this.entitlementStore.save(key, snapshot);
|
|
3564
|
+
} catch (err) {
|
|
3565
|
+
this.debug.emit(
|
|
3566
|
+
"sdk.entitlement_store_recovered",
|
|
3567
|
+
`entitlementStore.save failed for key ${key} \u2014 durability weakened for this customer.`,
|
|
3568
|
+
{ key, error: err instanceof Error ? err.message : String(err) }
|
|
3569
|
+
);
|
|
3570
|
+
}
|
|
3571
|
+
}
|
|
3572
|
+
}
|
|
3573
|
+
/**
|
|
3574
|
+
* Load last-known-good entitlements from the durable store on a
|
|
3575
|
+
* network-failure path. Returns the first snapshot found across the
|
|
3576
|
+
* caller's identity keys, or `null` if there is no store / no stored
|
|
3577
|
+
* snapshot / every read failed.
|
|
3578
|
+
*
|
|
3579
|
+
* Tries the canonical `customerId` hint first, then `userId`, then
|
|
3580
|
+
* `anonymousId` — the order callers most commonly key by. A corrupt
|
|
3581
|
+
* or wrong-shaped blob is treated as a miss (the store is developer-
|
|
3582
|
+
* supplied; the SDK validates rather than trusts).
|
|
3583
|
+
*/
|
|
3584
|
+
async loadEntitlementsFromStore(hints) {
|
|
3585
|
+
if (!this.entitlementStore) return null;
|
|
3586
|
+
const keys = [];
|
|
3587
|
+
if (hints.customerId) keys.push(hints.customerId);
|
|
3588
|
+
if (hints.userId) keys.push(hints.userId);
|
|
3589
|
+
if (hints.anonymousId) keys.push(hints.anonymousId);
|
|
3590
|
+
for (const key of keys) {
|
|
3591
|
+
let loaded = null;
|
|
3592
|
+
try {
|
|
3593
|
+
loaded = await this.entitlementStore.load(key);
|
|
3594
|
+
} catch {
|
|
3595
|
+
continue;
|
|
3596
|
+
}
|
|
3597
|
+
if (isValidStoredEntitlements(loaded)) return loaded;
|
|
3598
|
+
}
|
|
3599
|
+
return null;
|
|
3600
|
+
}
|
|
3601
|
+
/**
|
|
3602
|
+
* Resolve the customer ID to stamp a failed-refresh marker against.
|
|
3603
|
+
*
|
|
3604
|
+
* Prefers a canonical ID the cache already knows (so the marker lands
|
|
3605
|
+
* on the existing warm entry), then falls back to whatever raw hint
|
|
3606
|
+
* the caller supplied — on a true cold-start failure there is no
|
|
3607
|
+
* cache entry yet, and marking under the hint still makes "we tried
|
|
3608
|
+
* for this customer and Crossdeck was down" observable.
|
|
3609
|
+
*/
|
|
3610
|
+
resolveFailedRefreshCustomerId(hints) {
|
|
3611
|
+
const known = this.resolveCacheCustomerId(hints);
|
|
3612
|
+
if (known) return known;
|
|
3613
|
+
return hints.customerId ?? hints.userId ?? hints.anonymousId ?? null;
|
|
3614
|
+
}
|
|
3138
3615
|
touchAlias(alias, customerId) {
|
|
3139
3616
|
this.customerIdAliases.delete(alias);
|
|
3140
3617
|
this.customerIdAliases.set(alias, customerId);
|
|
@@ -3148,10 +3625,21 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
3148
3625
|
* Resolve any hint shape (canonical customerId / userId hint /
|
|
3149
3626
|
* anonymousId hint / raw string) to a `crossdeckCustomerId` if we
|
|
3150
3627
|
* have a cache entry for it.
|
|
3628
|
+
*
|
|
3629
|
+
* String overload is STRICT on the canonical-id shape. Pre-fix
|
|
3630
|
+
* `isFresh(raw)` treated any string with a cache entry as a valid
|
|
3631
|
+
* canonical id — if tenant A's userId happened to collide with
|
|
3632
|
+
* tenant B's crossdeckCustomerId, A's call would resolve to B's
|
|
3633
|
+
* cached entitlements. Bounded by the `cdcust_` prefix convention
|
|
3634
|
+
* (which both SDKs and the backend mint, see
|
|
3635
|
+
* backend/src/lib/customers.ts) — anything else is treated purely
|
|
3636
|
+
* as an alias lookup, never as a canonical id. Audit P1 #19.
|
|
3151
3637
|
*/
|
|
3152
3638
|
resolveCacheCustomerId(hint) {
|
|
3153
3639
|
if (typeof hint === "string") {
|
|
3154
|
-
if (this.entitlementCache.isFresh(hint))
|
|
3640
|
+
if (hint.startsWith("cdcust_") && this.entitlementCache.isFresh(hint)) {
|
|
3641
|
+
return hint;
|
|
3642
|
+
}
|
|
3155
3643
|
return this.customerIdAliases.get(hint) ?? null;
|
|
3156
3644
|
}
|
|
3157
3645
|
if (hint.customerId) return hint.customerId;
|
|
@@ -3236,6 +3724,11 @@ function sanitizePropertyBag(input, fieldName) {
|
|
|
3236
3724
|
});
|
|
3237
3725
|
}
|
|
3238
3726
|
}
|
|
3727
|
+
function isValidStoredEntitlements(value) {
|
|
3728
|
+
if (typeof value !== "object" || value === null) return false;
|
|
3729
|
+
const v = value;
|
|
3730
|
+
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";
|
|
3731
|
+
}
|
|
3239
3732
|
function categoryFor(name) {
|
|
3240
3733
|
if (name.startsWith("page.") || name.startsWith("navigation.")) return "navigation";
|
|
3241
3734
|
if (name.startsWith("element.") || name.startsWith("ui.click")) return "ui.click";
|
|
@@ -3522,20 +4015,11 @@ function normaliseSecrets(input) {
|
|
|
3522
4015
|
// src/consent.ts
|
|
3523
4016
|
var EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
|
|
3524
4017
|
var CARD_PATTERN = /\b\d(?:[ -]?\d){12,18}\b/g;
|
|
3525
|
-
var REPLACEMENT_EMAIL = "
|
|
3526
|
-
var REPLACEMENT_CARD = "
|
|
4018
|
+
var REPLACEMENT_EMAIL = "<email>";
|
|
4019
|
+
var REPLACEMENT_CARD = "<card>";
|
|
3527
4020
|
function scrubPii(value) {
|
|
3528
4021
|
if (!value) return value;
|
|
3529
|
-
|
|
3530
|
-
if (EMAIL_PATTERN.test(out)) {
|
|
3531
|
-
out = out.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL);
|
|
3532
|
-
}
|
|
3533
|
-
EMAIL_PATTERN.lastIndex = 0;
|
|
3534
|
-
if (CARD_PATTERN.test(out)) {
|
|
3535
|
-
out = out.replace(CARD_PATTERN, REPLACEMENT_CARD);
|
|
3536
|
-
}
|
|
3537
|
-
CARD_PATTERN.lastIndex = 0;
|
|
3538
|
-
return out;
|
|
4022
|
+
return value.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL).replace(CARD_PATTERN, REPLACEMENT_CARD);
|
|
3539
4023
|
}
|
|
3540
4024
|
function scrubPiiFromProperties(properties) {
|
|
3541
4025
|
const out = {};
|