@cross-deck/node 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -365,7 +365,7 @@ function byteLength(s) {
365
365
 
366
366
  // src/http.ts
367
367
  var SDK_NAME = "@cross-deck/node";
368
- var SDK_VERSION = "1.1.0";
368
+ var SDK_VERSION = "1.2.0";
369
369
  var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
370
370
  var DEFAULT_TIMEOUT_MS = 15e3;
371
371
  var CROSSDECK_API_VERSION = "2025-01-01";
@@ -1621,6 +1621,16 @@ function safeStringify3(v) {
1621
1621
 
1622
1622
  // src/runtime-info.ts
1623
1623
  var import_node_os = require("os");
1624
+ var SERVERLESS_HOSTS = /* @__PURE__ */ new Set([
1625
+ "aws-lambda",
1626
+ "azure-functions",
1627
+ "google-app-engine",
1628
+ "firebase-functions-v1",
1629
+ "firebase-functions-v2",
1630
+ "cloud-run",
1631
+ "vercel",
1632
+ "netlify"
1633
+ ]);
1624
1634
  var cached = null;
1625
1635
  function collectRuntimeInfo(options = {}) {
1626
1636
  if (cached) return cached;
@@ -1636,6 +1646,7 @@ function detect(options) {
1636
1646
  platformRelease: safeRelease(),
1637
1647
  hostname: safeHostname(),
1638
1648
  host: detected.host,
1649
+ isServerless: SERVERLESS_HOSTS.has(detected.host),
1639
1650
  region: detected.region,
1640
1651
  serviceName: options.serviceName ?? detected.serviceName,
1641
1652
  serviceVersion: options.serviceVersion ?? detected.serviceVersion,
@@ -2039,9 +2050,11 @@ var SuperPropertyStore = class {
2039
2050
 
2040
2051
  // src/entitlement-cache.ts
2041
2052
  var DEFAULT_MAX_CUSTOMERS = 1e4;
2053
+ var DEFAULT_STALE_AFTER_MS = 24 * 60 * 60 * 1e3;
2042
2054
  var EntitlementCache = class {
2043
2055
  ttlMs;
2044
2056
  maxCustomers;
2057
+ staleAfterMs;
2045
2058
  byCustomer = /* @__PURE__ */ new Map();
2046
2059
  listeners = /* @__PURE__ */ new Set();
2047
2060
  listenerErrorCount = 0;
@@ -2049,52 +2062,95 @@ var EntitlementCache = class {
2049
2062
  constructor(options = {}) {
2050
2063
  this.ttlMs = options.ttlMs ?? 6e4;
2051
2064
  this.maxCustomers = options.maxCustomers ?? DEFAULT_MAX_CUSTOMERS;
2065
+ this.staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
2052
2066
  }
2053
2067
  /**
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).
2068
+ * Synchronous lookup true iff the customer currently has the
2069
+ * entitlement granting access. Pure in-memory `Map` read, ZERO I/O.
2070
+ *
2071
+ * Served from last-known-good: a stale entry (Crossdeck unreachable
2072
+ * since the last successful fetch, or past `ttlMs`) STILL answers true
2073
+ * for a still-valid entitlement. Cache staleness alone never makes
2074
+ * this `false` — the central durability fix. The only things that
2075
+ * turn it false:
2076
+ * - the customer has no cached entry at all (genuine cold miss)
2077
+ * - no matching `key` in the customer's entitlement set
2078
+ * - the matching entitlement is `isActive: false`
2079
+ * - the matching entitlement is past its OWN `validUntil` — a
2080
+ * time-based trial expiry still applies mid-outage (mirrors the
2081
+ * web SDK's `validUntil` check exactly).
2082
+ *
2083
+ * An entry being past `ttlMs`, or marked refresh-failed, does NOT
2084
+ * affect the answer — `getEntitlements()` re-fetches on the TTL hint,
2085
+ * but until it succeeds the customer keeps their access.
2057
2086
  */
2058
2087
  isEntitled(customerId, key) {
2059
2088
  const entry = this.byCustomer.get(customerId);
2060
2089
  if (!entry) return false;
2061
- if (Date.now() > entry.expiresAt) return false;
2062
- return entry.active.has(key);
2090
+ const nowSec = Date.now() / 1e3;
2091
+ return entry.all.some(
2092
+ (e) => e.key === key && e.isActive && (e.validUntil == null || e.validUntil > nowSec)
2093
+ );
2063
2094
  }
2064
2095
  /**
2065
- * Full snapshot for callers that need source / validUntil. Returns
2066
- * `[]` when the customer has no cached entry or the entry has
2067
- * expired.
2096
+ * Full snapshot for callers that need source / validUntil details.
2097
+ * Returns `[]` ONLY when the customer has no cached entry a stale
2098
+ * or past-TTL entry still returns its last-known-good entitlements
2099
+ * (same durability posture as `isEntitled()`; per-entitlement
2100
+ * `validUntil` is the caller's to honour from the returned objects).
2068
2101
  */
2069
2102
  list(customerId) {
2070
2103
  const entry = this.byCustomer.get(customerId);
2071
2104
  if (!entry) return [];
2072
- if (Date.now() > entry.expiresAt) return [];
2073
2105
  return entry.all.slice();
2074
2106
  }
2075
2107
  /**
2076
- * Whether a fresh entry exists for the customer. Useful for
2077
- * deciding whether to warm before a hot path.
2108
+ * Whether the customer's entry is still within `ttlMs` — i.e. a
2109
+ * re-fetch is NOT yet due. Useful for deciding whether to warm before
2110
+ * a hot path. A `false` result does NOT mean the cache is empty or
2111
+ * that `isEntitled()` will return false — it only means the data is
2112
+ * past its refresh hint. See `needsRefresh()` for the inverse.
2078
2113
  */
2079
2114
  isFresh(customerId) {
2080
2115
  const entry = this.byCustomer.get(customerId);
2081
- return Boolean(entry && Date.now() <= entry.expiresAt);
2116
+ return Boolean(entry && Date.now() <= entry.refreshDueAt);
2117
+ }
2118
+ /**
2119
+ * Whether the customer should be re-fetched: either there is no
2120
+ * cached entry, or the entry is past its `ttlMs` refresh hint, or the
2121
+ * most recent refresh attempt for them failed (retry it).
2122
+ *
2123
+ * This is purely advisory — `getEntitlements()` decides when to act
2124
+ * on it. It NEVER gates `isEntitled()`, which serves last-known-good
2125
+ * regardless.
2126
+ */
2127
+ needsRefresh(customerId) {
2128
+ const entry = this.byCustomer.get(customerId);
2129
+ if (!entry) return true;
2130
+ if (entry.refreshFailedAt > 0) return true;
2131
+ return Date.now() > entry.refreshDueAt;
2082
2132
  }
2083
2133
  /**
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.
2134
+ * Replace (or insert) the cache entry for a customer with a fresh
2135
+ * server response. Sets `refreshDueAt` to `now + ttlMs` and CLEARS
2136
+ * any failed-refresh marker a success ends the stale state.
2137
+ *
2138
+ * Called ONLY after a SUCCESSFUL server read — a failed fetch is
2139
+ * routed to `markRefreshFailed` instead and never reaches here, so
2140
+ * last-known-good is preserved through an outage.
2141
+ *
2142
+ * Re-inserting an existing customerId "touches" it — the entry moves
2143
+ * to the end of insertion order (Map semantics) so it's treated as
2144
+ * most-recently-used for LRU eviction. Fires listeners.
2089
2145
  */
2090
2146
  setForCustomer(customerId, entitlements) {
2091
2147
  const now = Date.now();
2092
2148
  this.byCustomer.delete(customerId);
2093
2149
  this.byCustomer.set(customerId, {
2094
2150
  all: entitlements.slice(),
2095
- active: new Set(entitlements.filter((e) => e.isActive).map((e) => e.key)),
2096
- expiresAt: now + this.ttlMs,
2097
- populatedAt: now
2151
+ refreshDueAt: now + this.ttlMs,
2152
+ populatedAt: now,
2153
+ refreshFailedAt: 0
2098
2154
  });
2099
2155
  while (this.byCustomer.size > this.maxCustomers) {
2100
2156
  const oldestKey = this.byCustomer.keys().next().value;
@@ -2104,6 +2160,68 @@ var EntitlementCache = class {
2104
2160
  }
2105
2161
  this.notify(customerId, entitlements);
2106
2162
  }
2163
+ /**
2164
+ * Record that a refresh attempt for a customer FAILED (Crossdeck
2165
+ * unreachable / transient error). `getEntitlements()` calls this in
2166
+ * its catch path.
2167
+ *
2168
+ * It does NOT touch the customer's cached entitlements — last-known-
2169
+ * good keeps serving — it only stamps `refreshFailedAt` so the
2170
+ * customer shows up as stale in `diagnostics()` rather than the
2171
+ * staleness being a silent unbounded window.
2172
+ *
2173
+ * If the customer has no entry yet (a genuine cold miss whose first
2174
+ * fetch failed) a stub entry with no entitlements is created purely
2175
+ * to carry the failed-refresh marker — so "we tried and Crossdeck was
2176
+ * down" is observable even before any successful warm. The stub holds
2177
+ * an empty entitlement set, so `isEntitled()` still correctly returns
2178
+ * false for it; there is genuinely nothing to serve.
2179
+ */
2180
+ markRefreshFailed(customerId) {
2181
+ const now = Date.now();
2182
+ const entry = this.byCustomer.get(customerId);
2183
+ if (entry) {
2184
+ entry.refreshFailedAt = now;
2185
+ return;
2186
+ }
2187
+ this.byCustomer.set(customerId, {
2188
+ all: [],
2189
+ refreshDueAt: now + this.ttlMs,
2190
+ populatedAt: 0,
2191
+ refreshFailedAt: now
2192
+ });
2193
+ while (this.byCustomer.size > this.maxCustomers) {
2194
+ const oldestKey = this.byCustomer.keys().next().value;
2195
+ if (oldestKey === void 0) break;
2196
+ this.byCustomer.delete(oldestKey);
2197
+ this.evicted += 1;
2198
+ }
2199
+ }
2200
+ /**
2201
+ * Whether a customer is knowingly serving older-than-trustworthy
2202
+ * data. True when the most recent refresh ATTEMPT for them failed
2203
+ * (Crossdeck unreachable since the last success — the outage case,
2204
+ * distinct from a benign idle customer simply past `ttlMs`), OR when
2205
+ * their last-known-good has aged past `staleAfterMs`.
2206
+ *
2207
+ * `isStale` NEVER changes what `isEntitled()` returns — the cache
2208
+ * still serves last-known-good. It exists so the staleness is
2209
+ * observable via `diagnostics()` instead of an unbounded silent
2210
+ * window where a revoked customer (event-based revoke, no
2211
+ * `validUntil`) holds access with nobody able to see it.
2212
+ *
2213
+ * Returns false for an unknown customer — nothing cached, nothing
2214
+ * stale.
2215
+ */
2216
+ isStale(customerId) {
2217
+ const entry = this.byCustomer.get(customerId);
2218
+ if (!entry) return false;
2219
+ return this.entryIsStale(entry);
2220
+ }
2221
+ /** Epoch ms of a customer's last failed refresh, or 0 if none / unknown. */
2222
+ refreshFailedAt(customerId) {
2223
+ return this.byCustomer.get(customerId)?.refreshFailedAt ?? 0;
2224
+ }
2107
2225
  /**
2108
2226
  * Drop a single customer's entry. Fires listeners with an empty
2109
2227
  * list so subscribers know that customer's cache is gone.
@@ -2169,7 +2287,57 @@ var EntitlementCache = class {
2169
2287
  get maxSize() {
2170
2288
  return this.maxCustomers;
2171
2289
  }
2290
+ /** Configured staleness window in ms. */
2291
+ get staleWindowMs() {
2292
+ return this.staleAfterMs;
2293
+ }
2294
+ /**
2295
+ * Count of cached customers currently flagged stale — most recent
2296
+ * refresh failed, or data aged past `staleAfterMs`. The cache keeps
2297
+ * serving last-known-good for them; this is the observability number
2298
+ * `diagnostics()` surfaces.
2299
+ */
2300
+ get staleCustomerCount() {
2301
+ let count = 0;
2302
+ for (const entry of this.byCustomer.values()) {
2303
+ if (this.entryIsStale(entry)) count += 1;
2304
+ }
2305
+ return count;
2306
+ }
2307
+ /** Whether ANY cached customer is currently stale. */
2308
+ get isAnyStale() {
2309
+ for (const entry of this.byCustomer.values()) {
2310
+ if (this.entryIsStale(entry)) return true;
2311
+ }
2312
+ return false;
2313
+ }
2314
+ /**
2315
+ * Most recent failed-refresh timestamp across all customers (epoch
2316
+ * ms), or 0 if every cached customer's last refresh succeeded.
2317
+ */
2318
+ get lastRefreshFailedAt() {
2319
+ let max = 0;
2320
+ for (const entry of this.byCustomer.values()) {
2321
+ if (entry.refreshFailedAt > max) max = entry.refreshFailedAt;
2322
+ }
2323
+ return max;
2324
+ }
2172
2325
  // ---------- internals ----------
2326
+ /**
2327
+ * Stale iff the entry's most recent refresh attempt failed, OR its
2328
+ * last-known-good has aged past `staleAfterMs`.
2329
+ *
2330
+ * `refreshFailedAt` is non-zero ONLY between a failed refresh and the
2331
+ * next successful one (`setForCustomer` zeroes it), so `> 0` alone
2332
+ * means "a failure occurred since the last success" — no need to
2333
+ * compare against `populatedAt`, which would mis-fire when a failure
2334
+ * and a populate land in the same millisecond. A marker-only stub
2335
+ * (populatedAt 0, failure stamped) is stale via this first clause.
2336
+ */
2337
+ entryIsStale(entry) {
2338
+ if (entry.refreshFailedAt > 0) return true;
2339
+ return entry.populatedAt > 0 && Date.now() - entry.populatedAt > this.staleAfterMs;
2340
+ }
2173
2341
  notify(customerId, snapshot) {
2174
2342
  if (this.listeners.size === 0) return;
2175
2343
  const snap = snapshot.slice();
@@ -2260,6 +2428,16 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
2260
2428
  flushOnExit;
2261
2429
  superProps;
2262
2430
  entitlementCache;
2431
+ /**
2432
+ * Optional developer-supplied durable store for last-known-good
2433
+ * entitlements (Redis / their DB / a KV). `undefined` when not
2434
+ * configured — the SDK then has no cold-start durability on
2435
+ * serverless, which it states explicitly at boot.
2436
+ *
2437
+ * Touched ONLY from the async `getEntitlements()` — never from the
2438
+ * synchronous `isEntitled()`.
2439
+ */
2440
+ entitlementStore;
2263
2441
  debug;
2264
2442
  /**
2265
2443
  * Alias map — `developerUserId` / `anonymousId` → canonical
@@ -2313,8 +2491,10 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
2313
2491
  this.breadcrumbs = new BreadcrumbBuffer(options.breadcrumbsMaxSize ?? 50);
2314
2492
  this.superProps = new SuperPropertyStore();
2315
2493
  this.entitlementCache = new EntitlementCache({
2316
- ttlMs: options.entitlementCacheTtlMs ?? 6e4
2494
+ ttlMs: options.entitlementCacheTtlMs ?? 6e4,
2495
+ staleAfterMs: options.entitlementStaleAfterMs
2317
2496
  });
2497
+ this.entitlementStore = options.entitlementStore ?? null;
2318
2498
  this.debug = options.debug === true ? new ConsoleDebugLogger() : new NullDebugLogger();
2319
2499
  if (options.debug === true) this.debug.enabled = true;
2320
2500
  this.debug.emit(
@@ -2390,7 +2570,60 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
2390
2570
  { message: err instanceof Error ? err.message : String(err) }
2391
2571
  );
2392
2572
  });
2573
+ this.emitBootTelemetry();
2574
+ });
2575
+ }
2576
+ }
2577
+ /**
2578
+ * Emit the one-time `sdk.boot` telemetry event and, when the runtime
2579
+ * is serverless with no `entitlementStore`, the honest "no cold-start
2580
+ * durability" warning.
2581
+ *
2582
+ * Why a `track()` event and not the heartbeat: `GET /v1/sdk/heartbeat`
2583
+ * carries no request body, so it cannot transport a structured
2584
+ * `durability` fact. The event pipeline can — every `track()` event
2585
+ * lands as an aggregatable document the backend can query, so
2586
+ * Crossdeck can compute fleet-wide "% serverless-with-no-durable-
2587
+ * store" from `sdk.boot` events (denominator = all `sdk.boot`,
2588
+ * numerator = those with `durability.coldStartDurable === false`).
2589
+ * The event rides the existing batched + retried + idempotent queue
2590
+ * and is drained by flush-on-exit, so it survives a serverless
2591
+ * teardown — it is NOT a local-only debug log.
2592
+ *
2593
+ * `isServerless` AND no store is the gap: a cold start begins with an
2594
+ * empty in-memory cache and a brief Crossdeck outage in that window
2595
+ * would read a paying customer as un-entitled. That gap is
2596
+ * unavoidable without a store — so the SDK STATES it (a
2597
+ * `sdk.no_durable_store` debug warning) rather than hiding it.
2598
+ *
2599
+ * Called once, from the deferred boot block — so it inherits the
2600
+ * `testMode` / `bootHeartbeat:false` opt-outs and never fires before
2601
+ * the constructor returns.
2602
+ */
2603
+ emitBootTelemetry() {
2604
+ const isServerless = this.runtime.isServerless;
2605
+ const hasStore = this.entitlementStore !== null;
2606
+ const coldStartDurable = hasStore || !isServerless;
2607
+ if (isServerless && !hasStore) {
2608
+ this.debug.emit(
2609
+ "sdk.no_durable_store",
2610
+ `Running on a serverless host (${this.runtime.host}) with no entitlementStore. The entitlement cache is in-memory only, so a cold start begins empty: if Crossdeck is briefly unreachable during that window, isEntitled() can read a paying customer as un-entitled. Wire \`entitlementStore\` (Redis / your DB / a KV) to close this gap.`,
2611
+ { host: this.runtime.host, isServerless, durableStore: false }
2612
+ );
2613
+ }
2614
+ try {
2615
+ this.track({
2616
+ name: "sdk.boot",
2617
+ anonymousId: this.processAnonymousId,
2618
+ properties: {
2619
+ "durability.entitlementStore": hasStore,
2620
+ "durability.coldStartDurable": coldStartDurable,
2621
+ "durability.runtimeIsServerless": isServerless,
2622
+ "durability.runtimeHost": this.runtime.host,
2623
+ "durability.entitlementCacheTtlMs": this.entitlementCache.ttl
2624
+ }
2393
2625
  });
2626
+ } catch {
2394
2627
  }
2395
2628
  }
2396
2629
  // ============================================================
@@ -2448,13 +2681,77 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
2448
2681
  // alias so a subsequent `isEntitled({ userId }, "pro")` resolves
2449
2682
  // to the same cache entry.
2450
2683
  // ============================================================
2684
+ /**
2685
+ * Fetch a customer's entitlements from Crossdeck and warm the cache.
2686
+ *
2687
+ * Durability — this is where last-known-good lives, NOT in the
2688
+ * synchronous `isEntitled()`:
2689
+ * - On a SUCCESSFUL fetch: the entitlement cache is populated and,
2690
+ * if an `entitlementStore` is configured, the result is persisted
2691
+ * to it (`await store.save(...)`). The cache + store now hold
2692
+ * server-confirmed truth.
2693
+ * - On a network FAILURE: the cache is marked refresh-failed for the
2694
+ * customer (so `diagnostics()` shows the staleness), then — if a
2695
+ * store is configured — last-known-good is loaded back from it
2696
+ * (`await store.load(...)`). If the store yields a snapshot, the
2697
+ * cache is populated from it and that snapshot is RETURNED as a
2698
+ * normal `EntitlementsListResponse` — a cold-start / outage no
2699
+ * longer fails a paying customer. If there is no store, or the
2700
+ * store is empty, the network error is rethrown unchanged so the
2701
+ * caller still sees the failure.
2702
+ *
2703
+ * The store is touched only here, inside the `await` that already
2704
+ * existed. `isEntitled()` remains a pure synchronous `Map` read.
2705
+ */
2451
2706
  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
- });
2707
+ let response;
2708
+ try {
2709
+ response = await this.http.request("GET", "/entitlements", {
2710
+ query: this.identityPayload(hints),
2711
+ signal: options?.signal,
2712
+ timeoutMs: options?.timeoutMs
2713
+ });
2714
+ } catch (err) {
2715
+ const failedCustomerId = this.resolveFailedRefreshCustomerId(hints);
2716
+ if (failedCustomerId) {
2717
+ this.entitlementCache.markRefreshFailed(failedCustomerId);
2718
+ }
2719
+ const recovered = await this.loadEntitlementsFromStore(hints);
2720
+ if (recovered) {
2721
+ const recoveredResponse = {
2722
+ object: "list",
2723
+ data: recovered.entitlements,
2724
+ crossdeckCustomerId: recovered.crossdeckCustomerId,
2725
+ env: recovered.env
2726
+ };
2727
+ this.populateEntitlementCache(hints, recoveredResponse);
2728
+ this.entitlementCache.markRefreshFailed(recovered.crossdeckCustomerId);
2729
+ this.debug.emit(
2730
+ "sdk.entitlement_store_recovered",
2731
+ `Crossdeck unreachable \u2014 served ${recovered.crossdeckCustomerId} from the durable store (${recovered.entitlements.length} entitlement(s), last refreshed ${new Date(recovered.savedAt).toISOString()}).`,
2732
+ {
2733
+ customerId: recovered.crossdeckCustomerId,
2734
+ savedAt: recovered.savedAt,
2735
+ error: err instanceof Error ? err.message : String(err)
2736
+ }
2737
+ );
2738
+ return recoveredResponse;
2739
+ }
2740
+ if (failedCustomerId && this.entitlementCache.isStale(failedCustomerId)) {
2741
+ this.debug.emit(
2742
+ "sdk.entitlement_cache_stale",
2743
+ `Crossdeck unreachable \u2014 entitlement cache for ${failedCustomerId} is now stale. ` + (this.entitlementStore ? "No durable snapshot was available to recover from." : "No entitlementStore is configured, so there is no durable fallback.") + " isEntitled() keeps serving last-known-good; staleness is visible in diagnostics().",
2744
+ {
2745
+ customerId: failedCustomerId,
2746
+ durableStore: this.entitlementStore !== null,
2747
+ error: err instanceof Error ? err.message : String(err)
2748
+ }
2749
+ );
2750
+ }
2751
+ throw err;
2752
+ }
2457
2753
  this.populateEntitlementCache(hints, response);
2754
+ await this.saveEntitlementsToStore(hints, response);
2458
2755
  return response;
2459
2756
  }
2460
2757
  async getCustomerEntitlements(customerId, options) {
@@ -2968,7 +3265,14 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
2968
3265
  count: this.entitlementCache.customerCount,
2969
3266
  lastUpdated: this.entitlementCache.lastUpdated,
2970
3267
  ttlMs: this.entitlementCache.ttl,
2971
- listenerErrors: this.entitlementCache.listenerErrors
3268
+ listenerErrors: this.entitlementCache.listenerErrors,
3269
+ staleCustomers: this.entitlementCache.staleCustomerCount,
3270
+ isStale: this.entitlementCache.isAnyStale,
3271
+ lastRefreshFailedAt: this.entitlementCache.lastRefreshFailedAt,
3272
+ durableStore: this.entitlementStore !== null,
3273
+ // Cold-start durable iff a store is wired, OR the host is
3274
+ // long-lived (the process, hence the in-memory cache, survives).
3275
+ coldStartDurable: this.entitlementStore !== null || !this.runtime.isServerless
2972
3276
  },
2973
3277
  events: this.eventQueue.getStats(),
2974
3278
  errors: {
@@ -3182,6 +3486,90 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
3182
3486
  } catch {
3183
3487
  }
3184
3488
  }
3489
+ /**
3490
+ * Persist a successful entitlements fetch to the durable store, if
3491
+ * one is configured. No-op when there is no store.
3492
+ *
3493
+ * Saved under EVERY identity the caller might later look up by — the
3494
+ * canonical `crossdeckCustomerId` plus any `userId` / `anonymousId`
3495
+ * hint. The Node cache resolves a hint to a canonical ID via an
3496
+ * in-memory alias map; on a cold start that map is empty, so a
3497
+ * failure-path `load()` must be able to hit the store with the raw
3498
+ * hint the caller passed. Saving under all keys makes that work.
3499
+ *
3500
+ * Best-effort: a store `save()` that throws is swallowed (logged in
3501
+ * debug) — it weakens durability for that customer but must never
3502
+ * fail an otherwise-successful `getEntitlements()`.
3503
+ */
3504
+ async saveEntitlementsToStore(hints, response) {
3505
+ if (!this.entitlementStore) return;
3506
+ const customerId = response.crossdeckCustomerId;
3507
+ if (!customerId) return;
3508
+ const snapshot = {
3509
+ v: 1,
3510
+ crossdeckCustomerId: customerId,
3511
+ entitlements: response.data,
3512
+ env: response.env,
3513
+ savedAt: Date.now()
3514
+ };
3515
+ const keys = /* @__PURE__ */ new Set([customerId]);
3516
+ if (hints.customerId) keys.add(hints.customerId);
3517
+ if (hints.userId) keys.add(hints.userId);
3518
+ if (hints.anonymousId) keys.add(hints.anonymousId);
3519
+ for (const key of keys) {
3520
+ try {
3521
+ await this.entitlementStore.save(key, snapshot);
3522
+ } catch (err) {
3523
+ this.debug.emit(
3524
+ "sdk.entitlement_store_recovered",
3525
+ `entitlementStore.save failed for key ${key} \u2014 durability weakened for this customer.`,
3526
+ { key, error: err instanceof Error ? err.message : String(err) }
3527
+ );
3528
+ }
3529
+ }
3530
+ }
3531
+ /**
3532
+ * Load last-known-good entitlements from the durable store on a
3533
+ * network-failure path. Returns the first snapshot found across the
3534
+ * caller's identity keys, or `null` if there is no store / no stored
3535
+ * snapshot / every read failed.
3536
+ *
3537
+ * Tries the canonical `customerId` hint first, then `userId`, then
3538
+ * `anonymousId` — the order callers most commonly key by. A corrupt
3539
+ * or wrong-shaped blob is treated as a miss (the store is developer-
3540
+ * supplied; the SDK validates rather than trusts).
3541
+ */
3542
+ async loadEntitlementsFromStore(hints) {
3543
+ if (!this.entitlementStore) return null;
3544
+ const keys = [];
3545
+ if (hints.customerId) keys.push(hints.customerId);
3546
+ if (hints.userId) keys.push(hints.userId);
3547
+ if (hints.anonymousId) keys.push(hints.anonymousId);
3548
+ for (const key of keys) {
3549
+ let loaded = null;
3550
+ try {
3551
+ loaded = await this.entitlementStore.load(key);
3552
+ } catch {
3553
+ continue;
3554
+ }
3555
+ if (isValidStoredEntitlements(loaded)) return loaded;
3556
+ }
3557
+ return null;
3558
+ }
3559
+ /**
3560
+ * Resolve the customer ID to stamp a failed-refresh marker against.
3561
+ *
3562
+ * Prefers a canonical ID the cache already knows (so the marker lands
3563
+ * on the existing warm entry), then falls back to whatever raw hint
3564
+ * the caller supplied — on a true cold-start failure there is no
3565
+ * cache entry yet, and marking under the hint still makes "we tried
3566
+ * for this customer and Crossdeck was down" observable.
3567
+ */
3568
+ resolveFailedRefreshCustomerId(hints) {
3569
+ const known = this.resolveCacheCustomerId(hints);
3570
+ if (known) return known;
3571
+ return hints.customerId ?? hints.userId ?? hints.anonymousId ?? null;
3572
+ }
3185
3573
  touchAlias(alias, customerId) {
3186
3574
  this.customerIdAliases.delete(alias);
3187
3575
  this.customerIdAliases.set(alias, customerId);
@@ -3283,6 +3671,11 @@ function sanitizePropertyBag(input, fieldName) {
3283
3671
  });
3284
3672
  }
3285
3673
  }
3674
+ function isValidStoredEntitlements(value) {
3675
+ if (typeof value !== "object" || value === null) return false;
3676
+ const v = value;
3677
+ return v.v === 1 && typeof v.crossdeckCustomerId === "string" && v.crossdeckCustomerId.length > 0 && Array.isArray(v.entitlements) && (v.env === "production" || v.env === "sandbox") && typeof v.savedAt === "number";
3678
+ }
3286
3679
  function categoryFor(name) {
3287
3680
  if (name.startsWith("page.") || name.startsWith("navigation.")) return "navigation";
3288
3681
  if (name.startsWith("element.") || name.startsWith("ui.click")) return "ui.click";