@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.mjs CHANGED
@@ -316,9 +316,11 @@ function byteLength(s) {
316
316
  return s.length * 4;
317
317
  }
318
318
 
319
- // src/http.ts
319
+ // src/_version.ts
320
+ var SDK_VERSION = "1.3.1";
320
321
  var SDK_NAME = "@cross-deck/node";
321
- var SDK_VERSION = "1.1.0";
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.breadcrumbs.add({
1220
- timestamp: start,
1221
- category: "http",
1222
- message: `${method} ${url}`,
1223
- data: { url, method }
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.includes("api.cross-deck.com")) {
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
- if (this.opts.beforeSend) {
1373
+ const hook = this.opts.beforeSend?.();
1374
+ if (hook) {
1347
1375
  try {
1348
- finalErr = this.opts.beforeSend(err);
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. Returns `true` iff the customer has the
2008
- * entitlement AND the cache entry is fresh (within `ttlMs`).
2009
- * Returns `false` otherwise (no entry / expired / key not active).
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
- if (Date.now() > entry.expiresAt) return false;
2015
- return entry.active.has(key);
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. Returns
2019
- * `[]` when the customer has no cached entry or the entry has
2020
- * expired.
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 a fresh entry exists for the customer. Useful for
2030
- * deciding whether to warm before a hot path.
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.expiresAt);
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. Sets the
2038
- * `expiresAt` to `now + ttlMs`. Re-inserting an existing customerId
2039
- * "touches" itthe entry moves to the end of insertion order
2040
- * (Map semantics) so it's treated as most-recently-used for LRU
2041
- * eviction. Fires listeners.
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
- active: new Set(entitlements.filter((e) => e.isActive).map((e) => e.key)),
2049
- expiresAt: now + this.ttlMs,
2050
- populatedAt: now
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
- beforeSend: null,
2318
- // wired via setErrorBeforeSend; ErrorTracker reads it through the live ref below
2319
- isConsented: () => true
2320
- });
2321
- const trackerOpts = this.errorTracker.opts;
2322
- Object.defineProperty(trackerOpts, "beforeSend", {
2323
- get: () => this.errorBeforeSend,
2324
- configurable: true
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
- const response = await this.http.request("GET", "/entitlements", {
2406
- query: this.identityPayload(hints),
2407
- signal: options?.signal,
2408
- timeoutMs: options?.timeoutMs
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: { rail: input.rail ?? "apple", ...input },
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)) return 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 = "[email]";
3526
- var REPLACEMENT_CARD = "[card]";
4018
+ var REPLACEMENT_EMAIL = "<email>";
4019
+ var REPLACEMENT_CARD = "<card>";
3527
4020
  function scrubPii(value) {
3528
4021
  if (!value) return value;
3529
- let out = value;
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 = {};