@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/dist/index.cjs CHANGED
@@ -363,9 +363,11 @@ function byteLength(s) {
363
363
  return s.length * 4;
364
364
  }
365
365
 
366
- // src/http.ts
366
+ // src/_version.ts
367
+ var SDK_VERSION = "1.3.1";
367
368
  var SDK_NAME = "@cross-deck/node";
368
- var SDK_VERSION = "1.1.0";
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.breadcrumbs.add({
1267
- timestamp: start,
1268
- category: "http",
1269
- message: `${method} ${url}`,
1270
- data: { url, method }
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.includes("api.cross-deck.com")) {
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
- if (this.opts.beforeSend) {
1420
+ const hook = this.opts.beforeSend?.();
1421
+ if (hook) {
1394
1422
  try {
1395
- finalErr = this.opts.beforeSend(err);
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. Returns `true` iff the customer has the
2055
- * entitlement AND the cache entry is fresh (within `ttlMs`).
2056
- * Returns `false` otherwise (no entry / expired / key not active).
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
- if (Date.now() > entry.expiresAt) return false;
2062
- return entry.active.has(key);
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. Returns
2066
- * `[]` when the customer has no cached entry or the entry has
2067
- * expired.
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 a fresh entry exists for the customer. Useful for
2077
- * deciding whether to warm before a hot path.
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.expiresAt);
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. Sets the
2085
- * `expiresAt` to `now + ttlMs`. Re-inserting an existing customerId
2086
- * "touches" itthe entry moves to the end of insertion order
2087
- * (Map semantics) so it's treated as most-recently-used for LRU
2088
- * eviction. Fires listeners.
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
- active: new Set(entitlements.filter((e) => e.isActive).map((e) => e.key)),
2096
- expiresAt: now + this.ttlMs,
2097
- populatedAt: now
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
- beforeSend: null,
2365
- // wired via setErrorBeforeSend; ErrorTracker reads it through the live ref below
2366
- isConsented: () => true
2367
- });
2368
- const trackerOpts = this.errorTracker.opts;
2369
- Object.defineProperty(trackerOpts, "beforeSend", {
2370
- get: () => this.errorBeforeSend,
2371
- configurable: true
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
- const response = await this.http.request("GET", "/entitlements", {
2453
- query: this.identityPayload(hints),
2454
- signal: options?.signal,
2455
- timeoutMs: options?.timeoutMs
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: { rail: input.rail ?? "apple", ...input },
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)) return 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 = "[email]";
3573
- var REPLACEMENT_CARD = "[card]";
4065
+ var REPLACEMENT_EMAIL = "<email>";
4066
+ var REPLACEMENT_CARD = "<card>";
3574
4067
  function scrubPii(value) {
3575
4068
  if (!value) return value;
3576
- let out = value;
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 = {};