@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/CHANGELOG.md +38 -0
- package/dist/auto-events/index.d.mts +1 -1
- package/dist/auto-events/index.d.ts +1 -1
- package/dist/{crossdeck-server-BXQaFjVx.d.mts → crossdeck-server-BZVZEuS-.d.mts} +303 -20
- package/dist/{crossdeck-server-BXQaFjVx.d.ts → crossdeck-server-BZVZEuS-.d.ts} +303 -20
- package/dist/index.cjs +421 -28
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +5 -2
- package/dist/index.d.ts +5 -2
- package/dist/index.mjs +421 -28
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { A as AliasIdentityInput, a as AliasResult, b as AuditDecision, c as AuditEntry, B as Breadcrumb, d as BreadcrumbCategory, e as BreadcrumbLevel, C as CROSSDECK_API_VERSION, f as CapturedError, g as CrossdeckAuthenticationError, h as CrossdeckConfigurationError, i as CrossdeckError, j as CrossdeckErrorPayload, k as CrossdeckErrorType, l as CrossdeckInternalError, m as CrossdeckNetworkError, n as CrossdeckPermissionError, o as CrossdeckRateLimitError, p as CrossdeckServer, q as CrossdeckServerOptions, r as CrossdeckValidationError, D as DEFAULT_BASE_URL, s as DEFAULT_TIMEOUT_MS, t as Diagnostics, E as EntitlementCacheOptions, u as EntitlementMutationResult, v as
|
|
1
|
+
export { A as AliasIdentityInput, a as AliasResult, b as AuditDecision, c as AuditEntry, B as Breadcrumb, d as BreadcrumbCategory, e as BreadcrumbLevel, C as CROSSDECK_API_VERSION, f as CapturedError, g as CrossdeckAuthenticationError, h as CrossdeckConfigurationError, i as CrossdeckError, j as CrossdeckErrorPayload, k as CrossdeckErrorType, l as CrossdeckInternalError, m as CrossdeckNetworkError, n as CrossdeckPermissionError, o as CrossdeckRateLimitError, p as CrossdeckServer, q as CrossdeckServerOptions, r as CrossdeckValidationError, D as DEFAULT_BASE_URL, s as DEFAULT_TIMEOUT_MS, t as Diagnostics, E as EntitlementCacheOptions, u as EntitlementMutationResult, v as EntitlementStore, w as EntitlementsListResponse, x as EntitlementsListener, y as Environment, z as ErrorCaptureConfig, F as ErrorLevel, G as EventProperties, H as ForgetResult, I as GrantDuration, J as GrantEntitlementInput, K as GroupMembership, L as HeartbeatResponse, M as HttpRequestInfo, N as HttpResponseInfo, O as HttpRetriesConfig, P as IdentifyOptions, Q as IdentityHints, R as IngestOptions, S as IngestResponse, T as PublicEntitlement, U as PurchaseResult, V as RequestOptions, W as RevokeEntitlementInput, X as RuntimeHost, Y as RuntimeInfo, Z as SDK_NAME, _ as SDK_VERSION, $ as ServerEvent, a0 as StackFrame, a1 as StoredEntitlements, a2 as SyncPurchaseInput, a3 as makeCrossdeckError } from './crossdeck-server-BZVZEuS-.mjs';
|
|
2
2
|
import 'node:events';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -334,9 +334,12 @@ declare function scrubPiiFromProperties(properties: Record<string, unknown>): Re
|
|
|
334
334
|
* - `sdk.webhook_verified`
|
|
335
335
|
* - `sdk.runtime_detected`
|
|
336
336
|
* - `sdk.entitlement_cache_warm`
|
|
337
|
+
* - `sdk.entitlement_cache_stale`
|
|
338
|
+
* - `sdk.entitlement_store_recovered`
|
|
339
|
+
* - `sdk.no_durable_store`
|
|
337
340
|
* - `sdk.super_property_registered`
|
|
338
341
|
*/
|
|
339
|
-
type DebugSignal = "sdk.configured" | "sdk.first_event_sent" | "sdk.invalid_key" | "sdk.no_identity" | "sdk.entitlement_cache_used" | "sdk.entitlement_cache_warm" | "sdk.purchase_evidence_sent" | "sdk.environment_mismatch" | "sdk.sensitive_property_warning" | "sdk.property_coerced" | "sdk.flush_retry_scheduled" | "sdk.flush_on_exit_started" | "sdk.flush_on_exit_completed" | "sdk.webhook_verified" | "sdk.runtime_detected" | "sdk.super_property_registered" | "sdk.boot_heartbeat_failed";
|
|
342
|
+
type DebugSignal = "sdk.configured" | "sdk.first_event_sent" | "sdk.invalid_key" | "sdk.no_identity" | "sdk.entitlement_cache_used" | "sdk.entitlement_cache_warm" | "sdk.entitlement_cache_stale" | "sdk.entitlement_store_recovered" | "sdk.no_durable_store" | "sdk.purchase_evidence_sent" | "sdk.environment_mismatch" | "sdk.sensitive_property_warning" | "sdk.property_coerced" | "sdk.flush_retry_scheduled" | "sdk.flush_on_exit_started" | "sdk.flush_on_exit_completed" | "sdk.webhook_verified" | "sdk.runtime_detected" | "sdk.super_property_registered" | "sdk.boot_heartbeat_failed";
|
|
340
343
|
interface DebugContext {
|
|
341
344
|
[key: string]: unknown;
|
|
342
345
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { A as AliasIdentityInput, a as AliasResult, b as AuditDecision, c as AuditEntry, B as Breadcrumb, d as BreadcrumbCategory, e as BreadcrumbLevel, C as CROSSDECK_API_VERSION, f as CapturedError, g as CrossdeckAuthenticationError, h as CrossdeckConfigurationError, i as CrossdeckError, j as CrossdeckErrorPayload, k as CrossdeckErrorType, l as CrossdeckInternalError, m as CrossdeckNetworkError, n as CrossdeckPermissionError, o as CrossdeckRateLimitError, p as CrossdeckServer, q as CrossdeckServerOptions, r as CrossdeckValidationError, D as DEFAULT_BASE_URL, s as DEFAULT_TIMEOUT_MS, t as Diagnostics, E as EntitlementCacheOptions, u as EntitlementMutationResult, v as
|
|
1
|
+
export { A as AliasIdentityInput, a as AliasResult, b as AuditDecision, c as AuditEntry, B as Breadcrumb, d as BreadcrumbCategory, e as BreadcrumbLevel, C as CROSSDECK_API_VERSION, f as CapturedError, g as CrossdeckAuthenticationError, h as CrossdeckConfigurationError, i as CrossdeckError, j as CrossdeckErrorPayload, k as CrossdeckErrorType, l as CrossdeckInternalError, m as CrossdeckNetworkError, n as CrossdeckPermissionError, o as CrossdeckRateLimitError, p as CrossdeckServer, q as CrossdeckServerOptions, r as CrossdeckValidationError, D as DEFAULT_BASE_URL, s as DEFAULT_TIMEOUT_MS, t as Diagnostics, E as EntitlementCacheOptions, u as EntitlementMutationResult, v as EntitlementStore, w as EntitlementsListResponse, x as EntitlementsListener, y as Environment, z as ErrorCaptureConfig, F as ErrorLevel, G as EventProperties, H as ForgetResult, I as GrantDuration, J as GrantEntitlementInput, K as GroupMembership, L as HeartbeatResponse, M as HttpRequestInfo, N as HttpResponseInfo, O as HttpRetriesConfig, P as IdentifyOptions, Q as IdentityHints, R as IngestOptions, S as IngestResponse, T as PublicEntitlement, U as PurchaseResult, V as RequestOptions, W as RevokeEntitlementInput, X as RuntimeHost, Y as RuntimeInfo, Z as SDK_NAME, _ as SDK_VERSION, $ as ServerEvent, a0 as StackFrame, a1 as StoredEntitlements, a2 as SyncPurchaseInput, a3 as makeCrossdeckError } from './crossdeck-server-BZVZEuS-.js';
|
|
2
2
|
import 'node:events';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -334,9 +334,12 @@ declare function scrubPiiFromProperties(properties: Record<string, unknown>): Re
|
|
|
334
334
|
* - `sdk.webhook_verified`
|
|
335
335
|
* - `sdk.runtime_detected`
|
|
336
336
|
* - `sdk.entitlement_cache_warm`
|
|
337
|
+
* - `sdk.entitlement_cache_stale`
|
|
338
|
+
* - `sdk.entitlement_store_recovered`
|
|
339
|
+
* - `sdk.no_durable_store`
|
|
337
340
|
* - `sdk.super_property_registered`
|
|
338
341
|
*/
|
|
339
|
-
type DebugSignal = "sdk.configured" | "sdk.first_event_sent" | "sdk.invalid_key" | "sdk.no_identity" | "sdk.entitlement_cache_used" | "sdk.entitlement_cache_warm" | "sdk.purchase_evidence_sent" | "sdk.environment_mismatch" | "sdk.sensitive_property_warning" | "sdk.property_coerced" | "sdk.flush_retry_scheduled" | "sdk.flush_on_exit_started" | "sdk.flush_on_exit_completed" | "sdk.webhook_verified" | "sdk.runtime_detected" | "sdk.super_property_registered" | "sdk.boot_heartbeat_failed";
|
|
342
|
+
type DebugSignal = "sdk.configured" | "sdk.first_event_sent" | "sdk.invalid_key" | "sdk.no_identity" | "sdk.entitlement_cache_used" | "sdk.entitlement_cache_warm" | "sdk.entitlement_cache_stale" | "sdk.entitlement_store_recovered" | "sdk.no_durable_store" | "sdk.purchase_evidence_sent" | "sdk.environment_mismatch" | "sdk.sensitive_property_warning" | "sdk.property_coerced" | "sdk.flush_retry_scheduled" | "sdk.flush_on_exit_started" | "sdk.flush_on_exit_completed" | "sdk.webhook_verified" | "sdk.runtime_detected" | "sdk.super_property_registered" | "sdk.boot_heartbeat_failed";
|
|
340
343
|
interface DebugContext {
|
|
341
344
|
[key: string]: unknown;
|
|
342
345
|
}
|
package/dist/index.mjs
CHANGED
|
@@ -318,7 +318,7 @@ function byteLength(s) {
|
|
|
318
318
|
|
|
319
319
|
// src/http.ts
|
|
320
320
|
var SDK_NAME = "@cross-deck/node";
|
|
321
|
-
var SDK_VERSION = "1.
|
|
321
|
+
var SDK_VERSION = "1.2.0";
|
|
322
322
|
var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
|
|
323
323
|
var DEFAULT_TIMEOUT_MS = 15e3;
|
|
324
324
|
var CROSSDECK_API_VERSION = "2025-01-01";
|
|
@@ -1574,6 +1574,16 @@ function safeStringify3(v) {
|
|
|
1574
1574
|
|
|
1575
1575
|
// src/runtime-info.ts
|
|
1576
1576
|
import { hostname as osHostname, platform as osPlatform, release as osRelease } from "os";
|
|
1577
|
+
var SERVERLESS_HOSTS = /* @__PURE__ */ new Set([
|
|
1578
|
+
"aws-lambda",
|
|
1579
|
+
"azure-functions",
|
|
1580
|
+
"google-app-engine",
|
|
1581
|
+
"firebase-functions-v1",
|
|
1582
|
+
"firebase-functions-v2",
|
|
1583
|
+
"cloud-run",
|
|
1584
|
+
"vercel",
|
|
1585
|
+
"netlify"
|
|
1586
|
+
]);
|
|
1577
1587
|
var cached = null;
|
|
1578
1588
|
function collectRuntimeInfo(options = {}) {
|
|
1579
1589
|
if (cached) return cached;
|
|
@@ -1589,6 +1599,7 @@ function detect(options) {
|
|
|
1589
1599
|
platformRelease: safeRelease(),
|
|
1590
1600
|
hostname: safeHostname(),
|
|
1591
1601
|
host: detected.host,
|
|
1602
|
+
isServerless: SERVERLESS_HOSTS.has(detected.host),
|
|
1592
1603
|
region: detected.region,
|
|
1593
1604
|
serviceName: options.serviceName ?? detected.serviceName,
|
|
1594
1605
|
serviceVersion: options.serviceVersion ?? detected.serviceVersion,
|
|
@@ -1992,9 +2003,11 @@ var SuperPropertyStore = class {
|
|
|
1992
2003
|
|
|
1993
2004
|
// src/entitlement-cache.ts
|
|
1994
2005
|
var DEFAULT_MAX_CUSTOMERS = 1e4;
|
|
2006
|
+
var DEFAULT_STALE_AFTER_MS = 24 * 60 * 60 * 1e3;
|
|
1995
2007
|
var EntitlementCache = class {
|
|
1996
2008
|
ttlMs;
|
|
1997
2009
|
maxCustomers;
|
|
2010
|
+
staleAfterMs;
|
|
1998
2011
|
byCustomer = /* @__PURE__ */ new Map();
|
|
1999
2012
|
listeners = /* @__PURE__ */ new Set();
|
|
2000
2013
|
listenerErrorCount = 0;
|
|
@@ -2002,52 +2015,95 @@ var EntitlementCache = class {
|
|
|
2002
2015
|
constructor(options = {}) {
|
|
2003
2016
|
this.ttlMs = options.ttlMs ?? 6e4;
|
|
2004
2017
|
this.maxCustomers = options.maxCustomers ?? DEFAULT_MAX_CUSTOMERS;
|
|
2018
|
+
this.staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
|
|
2005
2019
|
}
|
|
2006
2020
|
/**
|
|
2007
|
-
* Synchronous lookup
|
|
2008
|
-
* entitlement
|
|
2009
|
-
*
|
|
2021
|
+
* Synchronous lookup — true iff the customer currently has the
|
|
2022
|
+
* entitlement granting access. Pure in-memory `Map` read, ZERO I/O.
|
|
2023
|
+
*
|
|
2024
|
+
* Served from last-known-good: a stale entry (Crossdeck unreachable
|
|
2025
|
+
* since the last successful fetch, or past `ttlMs`) STILL answers true
|
|
2026
|
+
* for a still-valid entitlement. Cache staleness alone never makes
|
|
2027
|
+
* this `false` — the central durability fix. The only things that
|
|
2028
|
+
* turn it false:
|
|
2029
|
+
* - the customer has no cached entry at all (genuine cold miss)
|
|
2030
|
+
* - no matching `key` in the customer's entitlement set
|
|
2031
|
+
* - the matching entitlement is `isActive: false`
|
|
2032
|
+
* - the matching entitlement is past its OWN `validUntil` — a
|
|
2033
|
+
* time-based trial expiry still applies mid-outage (mirrors the
|
|
2034
|
+
* web SDK's `validUntil` check exactly).
|
|
2035
|
+
*
|
|
2036
|
+
* An entry being past `ttlMs`, or marked refresh-failed, does NOT
|
|
2037
|
+
* affect the answer — `getEntitlements()` re-fetches on the TTL hint,
|
|
2038
|
+
* but until it succeeds the customer keeps their access.
|
|
2010
2039
|
*/
|
|
2011
2040
|
isEntitled(customerId, key) {
|
|
2012
2041
|
const entry = this.byCustomer.get(customerId);
|
|
2013
2042
|
if (!entry) return false;
|
|
2014
|
-
|
|
2015
|
-
return entry.
|
|
2043
|
+
const nowSec = Date.now() / 1e3;
|
|
2044
|
+
return entry.all.some(
|
|
2045
|
+
(e) => e.key === key && e.isActive && (e.validUntil == null || e.validUntil > nowSec)
|
|
2046
|
+
);
|
|
2016
2047
|
}
|
|
2017
2048
|
/**
|
|
2018
|
-
* Full snapshot for callers that need source / validUntil.
|
|
2019
|
-
* `[]` when the customer has no cached entry
|
|
2020
|
-
*
|
|
2049
|
+
* Full snapshot for callers that need source / validUntil details.
|
|
2050
|
+
* Returns `[]` ONLY when the customer has no cached entry — a stale
|
|
2051
|
+
* or past-TTL entry still returns its last-known-good entitlements
|
|
2052
|
+
* (same durability posture as `isEntitled()`; per-entitlement
|
|
2053
|
+
* `validUntil` is the caller's to honour from the returned objects).
|
|
2021
2054
|
*/
|
|
2022
2055
|
list(customerId) {
|
|
2023
2056
|
const entry = this.byCustomer.get(customerId);
|
|
2024
2057
|
if (!entry) return [];
|
|
2025
|
-
if (Date.now() > entry.expiresAt) return [];
|
|
2026
2058
|
return entry.all.slice();
|
|
2027
2059
|
}
|
|
2028
2060
|
/**
|
|
2029
|
-
* Whether
|
|
2030
|
-
* deciding whether to warm before
|
|
2061
|
+
* Whether the customer's entry is still within `ttlMs` — i.e. a
|
|
2062
|
+
* re-fetch is NOT yet due. Useful for deciding whether to warm before
|
|
2063
|
+
* a hot path. A `false` result does NOT mean the cache is empty or
|
|
2064
|
+
* that `isEntitled()` will return false — it only means the data is
|
|
2065
|
+
* past its refresh hint. See `needsRefresh()` for the inverse.
|
|
2031
2066
|
*/
|
|
2032
2067
|
isFresh(customerId) {
|
|
2033
2068
|
const entry = this.byCustomer.get(customerId);
|
|
2034
|
-
return Boolean(entry && Date.now() <= entry.
|
|
2069
|
+
return Boolean(entry && Date.now() <= entry.refreshDueAt);
|
|
2070
|
+
}
|
|
2071
|
+
/**
|
|
2072
|
+
* Whether the customer should be re-fetched: either there is no
|
|
2073
|
+
* cached entry, or the entry is past its `ttlMs` refresh hint, or the
|
|
2074
|
+
* most recent refresh attempt for them failed (retry it).
|
|
2075
|
+
*
|
|
2076
|
+
* This is purely advisory — `getEntitlements()` decides when to act
|
|
2077
|
+
* on it. It NEVER gates `isEntitled()`, which serves last-known-good
|
|
2078
|
+
* regardless.
|
|
2079
|
+
*/
|
|
2080
|
+
needsRefresh(customerId) {
|
|
2081
|
+
const entry = this.byCustomer.get(customerId);
|
|
2082
|
+
if (!entry) return true;
|
|
2083
|
+
if (entry.refreshFailedAt > 0) return true;
|
|
2084
|
+
return Date.now() > entry.refreshDueAt;
|
|
2035
2085
|
}
|
|
2036
2086
|
/**
|
|
2037
|
-
* Replace (or insert) the cache entry for a customer
|
|
2038
|
-
* `
|
|
2039
|
-
*
|
|
2040
|
-
*
|
|
2041
|
-
*
|
|
2087
|
+
* Replace (or insert) the cache entry for a customer with a fresh
|
|
2088
|
+
* server response. Sets `refreshDueAt` to `now + ttlMs` and CLEARS
|
|
2089
|
+
* any failed-refresh marker — a success ends the stale state.
|
|
2090
|
+
*
|
|
2091
|
+
* Called ONLY after a SUCCESSFUL server read — a failed fetch is
|
|
2092
|
+
* routed to `markRefreshFailed` instead and never reaches here, so
|
|
2093
|
+
* last-known-good is preserved through an outage.
|
|
2094
|
+
*
|
|
2095
|
+
* Re-inserting an existing customerId "touches" it — the entry moves
|
|
2096
|
+
* to the end of insertion order (Map semantics) so it's treated as
|
|
2097
|
+
* most-recently-used for LRU eviction. Fires listeners.
|
|
2042
2098
|
*/
|
|
2043
2099
|
setForCustomer(customerId, entitlements) {
|
|
2044
2100
|
const now = Date.now();
|
|
2045
2101
|
this.byCustomer.delete(customerId);
|
|
2046
2102
|
this.byCustomer.set(customerId, {
|
|
2047
2103
|
all: entitlements.slice(),
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2104
|
+
refreshDueAt: now + this.ttlMs,
|
|
2105
|
+
populatedAt: now,
|
|
2106
|
+
refreshFailedAt: 0
|
|
2051
2107
|
});
|
|
2052
2108
|
while (this.byCustomer.size > this.maxCustomers) {
|
|
2053
2109
|
const oldestKey = this.byCustomer.keys().next().value;
|
|
@@ -2057,6 +2113,68 @@ var EntitlementCache = class {
|
|
|
2057
2113
|
}
|
|
2058
2114
|
this.notify(customerId, entitlements);
|
|
2059
2115
|
}
|
|
2116
|
+
/**
|
|
2117
|
+
* Record that a refresh attempt for a customer FAILED (Crossdeck
|
|
2118
|
+
* unreachable / transient error). `getEntitlements()` calls this in
|
|
2119
|
+
* its catch path.
|
|
2120
|
+
*
|
|
2121
|
+
* It does NOT touch the customer's cached entitlements — last-known-
|
|
2122
|
+
* good keeps serving — it only stamps `refreshFailedAt` so the
|
|
2123
|
+
* customer shows up as stale in `diagnostics()` rather than the
|
|
2124
|
+
* staleness being a silent unbounded window.
|
|
2125
|
+
*
|
|
2126
|
+
* If the customer has no entry yet (a genuine cold miss whose first
|
|
2127
|
+
* fetch failed) a stub entry with no entitlements is created purely
|
|
2128
|
+
* to carry the failed-refresh marker — so "we tried and Crossdeck was
|
|
2129
|
+
* down" is observable even before any successful warm. The stub holds
|
|
2130
|
+
* an empty entitlement set, so `isEntitled()` still correctly returns
|
|
2131
|
+
* false for it; there is genuinely nothing to serve.
|
|
2132
|
+
*/
|
|
2133
|
+
markRefreshFailed(customerId) {
|
|
2134
|
+
const now = Date.now();
|
|
2135
|
+
const entry = this.byCustomer.get(customerId);
|
|
2136
|
+
if (entry) {
|
|
2137
|
+
entry.refreshFailedAt = now;
|
|
2138
|
+
return;
|
|
2139
|
+
}
|
|
2140
|
+
this.byCustomer.set(customerId, {
|
|
2141
|
+
all: [],
|
|
2142
|
+
refreshDueAt: now + this.ttlMs,
|
|
2143
|
+
populatedAt: 0,
|
|
2144
|
+
refreshFailedAt: now
|
|
2145
|
+
});
|
|
2146
|
+
while (this.byCustomer.size > this.maxCustomers) {
|
|
2147
|
+
const oldestKey = this.byCustomer.keys().next().value;
|
|
2148
|
+
if (oldestKey === void 0) break;
|
|
2149
|
+
this.byCustomer.delete(oldestKey);
|
|
2150
|
+
this.evicted += 1;
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
/**
|
|
2154
|
+
* Whether a customer is knowingly serving older-than-trustworthy
|
|
2155
|
+
* data. True when the most recent refresh ATTEMPT for them failed
|
|
2156
|
+
* (Crossdeck unreachable since the last success — the outage case,
|
|
2157
|
+
* distinct from a benign idle customer simply past `ttlMs`), OR when
|
|
2158
|
+
* their last-known-good has aged past `staleAfterMs`.
|
|
2159
|
+
*
|
|
2160
|
+
* `isStale` NEVER changes what `isEntitled()` returns — the cache
|
|
2161
|
+
* still serves last-known-good. It exists so the staleness is
|
|
2162
|
+
* observable via `diagnostics()` instead of an unbounded silent
|
|
2163
|
+
* window where a revoked customer (event-based revoke, no
|
|
2164
|
+
* `validUntil`) holds access with nobody able to see it.
|
|
2165
|
+
*
|
|
2166
|
+
* Returns false for an unknown customer — nothing cached, nothing
|
|
2167
|
+
* stale.
|
|
2168
|
+
*/
|
|
2169
|
+
isStale(customerId) {
|
|
2170
|
+
const entry = this.byCustomer.get(customerId);
|
|
2171
|
+
if (!entry) return false;
|
|
2172
|
+
return this.entryIsStale(entry);
|
|
2173
|
+
}
|
|
2174
|
+
/** Epoch ms of a customer's last failed refresh, or 0 if none / unknown. */
|
|
2175
|
+
refreshFailedAt(customerId) {
|
|
2176
|
+
return this.byCustomer.get(customerId)?.refreshFailedAt ?? 0;
|
|
2177
|
+
}
|
|
2060
2178
|
/**
|
|
2061
2179
|
* Drop a single customer's entry. Fires listeners with an empty
|
|
2062
2180
|
* list so subscribers know that customer's cache is gone.
|
|
@@ -2122,7 +2240,57 @@ var EntitlementCache = class {
|
|
|
2122
2240
|
get maxSize() {
|
|
2123
2241
|
return this.maxCustomers;
|
|
2124
2242
|
}
|
|
2243
|
+
/** Configured staleness window in ms. */
|
|
2244
|
+
get staleWindowMs() {
|
|
2245
|
+
return this.staleAfterMs;
|
|
2246
|
+
}
|
|
2247
|
+
/**
|
|
2248
|
+
* Count of cached customers currently flagged stale — most recent
|
|
2249
|
+
* refresh failed, or data aged past `staleAfterMs`. The cache keeps
|
|
2250
|
+
* serving last-known-good for them; this is the observability number
|
|
2251
|
+
* `diagnostics()` surfaces.
|
|
2252
|
+
*/
|
|
2253
|
+
get staleCustomerCount() {
|
|
2254
|
+
let count = 0;
|
|
2255
|
+
for (const entry of this.byCustomer.values()) {
|
|
2256
|
+
if (this.entryIsStale(entry)) count += 1;
|
|
2257
|
+
}
|
|
2258
|
+
return count;
|
|
2259
|
+
}
|
|
2260
|
+
/** Whether ANY cached customer is currently stale. */
|
|
2261
|
+
get isAnyStale() {
|
|
2262
|
+
for (const entry of this.byCustomer.values()) {
|
|
2263
|
+
if (this.entryIsStale(entry)) return true;
|
|
2264
|
+
}
|
|
2265
|
+
return false;
|
|
2266
|
+
}
|
|
2267
|
+
/**
|
|
2268
|
+
* Most recent failed-refresh timestamp across all customers (epoch
|
|
2269
|
+
* ms), or 0 if every cached customer's last refresh succeeded.
|
|
2270
|
+
*/
|
|
2271
|
+
get lastRefreshFailedAt() {
|
|
2272
|
+
let max = 0;
|
|
2273
|
+
for (const entry of this.byCustomer.values()) {
|
|
2274
|
+
if (entry.refreshFailedAt > max) max = entry.refreshFailedAt;
|
|
2275
|
+
}
|
|
2276
|
+
return max;
|
|
2277
|
+
}
|
|
2125
2278
|
// ---------- internals ----------
|
|
2279
|
+
/**
|
|
2280
|
+
* Stale iff the entry's most recent refresh attempt failed, OR its
|
|
2281
|
+
* last-known-good has aged past `staleAfterMs`.
|
|
2282
|
+
*
|
|
2283
|
+
* `refreshFailedAt` is non-zero ONLY between a failed refresh and the
|
|
2284
|
+
* next successful one (`setForCustomer` zeroes it), so `> 0` alone
|
|
2285
|
+
* means "a failure occurred since the last success" — no need to
|
|
2286
|
+
* compare against `populatedAt`, which would mis-fire when a failure
|
|
2287
|
+
* and a populate land in the same millisecond. A marker-only stub
|
|
2288
|
+
* (populatedAt 0, failure stamped) is stale via this first clause.
|
|
2289
|
+
*/
|
|
2290
|
+
entryIsStale(entry) {
|
|
2291
|
+
if (entry.refreshFailedAt > 0) return true;
|
|
2292
|
+
return entry.populatedAt > 0 && Date.now() - entry.populatedAt > this.staleAfterMs;
|
|
2293
|
+
}
|
|
2126
2294
|
notify(customerId, snapshot) {
|
|
2127
2295
|
if (this.listeners.size === 0) return;
|
|
2128
2296
|
const snap = snapshot.slice();
|
|
@@ -2213,6 +2381,16 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
2213
2381
|
flushOnExit;
|
|
2214
2382
|
superProps;
|
|
2215
2383
|
entitlementCache;
|
|
2384
|
+
/**
|
|
2385
|
+
* Optional developer-supplied durable store for last-known-good
|
|
2386
|
+
* entitlements (Redis / their DB / a KV). `undefined` when not
|
|
2387
|
+
* configured — the SDK then has no cold-start durability on
|
|
2388
|
+
* serverless, which it states explicitly at boot.
|
|
2389
|
+
*
|
|
2390
|
+
* Touched ONLY from the async `getEntitlements()` — never from the
|
|
2391
|
+
* synchronous `isEntitled()`.
|
|
2392
|
+
*/
|
|
2393
|
+
entitlementStore;
|
|
2216
2394
|
debug;
|
|
2217
2395
|
/**
|
|
2218
2396
|
* Alias map — `developerUserId` / `anonymousId` → canonical
|
|
@@ -2266,8 +2444,10 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
2266
2444
|
this.breadcrumbs = new BreadcrumbBuffer(options.breadcrumbsMaxSize ?? 50);
|
|
2267
2445
|
this.superProps = new SuperPropertyStore();
|
|
2268
2446
|
this.entitlementCache = new EntitlementCache({
|
|
2269
|
-
ttlMs: options.entitlementCacheTtlMs ?? 6e4
|
|
2447
|
+
ttlMs: options.entitlementCacheTtlMs ?? 6e4,
|
|
2448
|
+
staleAfterMs: options.entitlementStaleAfterMs
|
|
2270
2449
|
});
|
|
2450
|
+
this.entitlementStore = options.entitlementStore ?? null;
|
|
2271
2451
|
this.debug = options.debug === true ? new ConsoleDebugLogger() : new NullDebugLogger();
|
|
2272
2452
|
if (options.debug === true) this.debug.enabled = true;
|
|
2273
2453
|
this.debug.emit(
|
|
@@ -2343,7 +2523,60 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
2343
2523
|
{ message: err instanceof Error ? err.message : String(err) }
|
|
2344
2524
|
);
|
|
2345
2525
|
});
|
|
2526
|
+
this.emitBootTelemetry();
|
|
2527
|
+
});
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2530
|
+
/**
|
|
2531
|
+
* Emit the one-time `sdk.boot` telemetry event and, when the runtime
|
|
2532
|
+
* is serverless with no `entitlementStore`, the honest "no cold-start
|
|
2533
|
+
* durability" warning.
|
|
2534
|
+
*
|
|
2535
|
+
* Why a `track()` event and not the heartbeat: `GET /v1/sdk/heartbeat`
|
|
2536
|
+
* carries no request body, so it cannot transport a structured
|
|
2537
|
+
* `durability` fact. The event pipeline can — every `track()` event
|
|
2538
|
+
* lands as an aggregatable document the backend can query, so
|
|
2539
|
+
* Crossdeck can compute fleet-wide "% serverless-with-no-durable-
|
|
2540
|
+
* store" from `sdk.boot` events (denominator = all `sdk.boot`,
|
|
2541
|
+
* numerator = those with `durability.coldStartDurable === false`).
|
|
2542
|
+
* The event rides the existing batched + retried + idempotent queue
|
|
2543
|
+
* and is drained by flush-on-exit, so it survives a serverless
|
|
2544
|
+
* teardown — it is NOT a local-only debug log.
|
|
2545
|
+
*
|
|
2546
|
+
* `isServerless` AND no store is the gap: a cold start begins with an
|
|
2547
|
+
* empty in-memory cache and a brief Crossdeck outage in that window
|
|
2548
|
+
* would read a paying customer as un-entitled. That gap is
|
|
2549
|
+
* unavoidable without a store — so the SDK STATES it (a
|
|
2550
|
+
* `sdk.no_durable_store` debug warning) rather than hiding it.
|
|
2551
|
+
*
|
|
2552
|
+
* Called once, from the deferred boot block — so it inherits the
|
|
2553
|
+
* `testMode` / `bootHeartbeat:false` opt-outs and never fires before
|
|
2554
|
+
* the constructor returns.
|
|
2555
|
+
*/
|
|
2556
|
+
emitBootTelemetry() {
|
|
2557
|
+
const isServerless = this.runtime.isServerless;
|
|
2558
|
+
const hasStore = this.entitlementStore !== null;
|
|
2559
|
+
const coldStartDurable = hasStore || !isServerless;
|
|
2560
|
+
if (isServerless && !hasStore) {
|
|
2561
|
+
this.debug.emit(
|
|
2562
|
+
"sdk.no_durable_store",
|
|
2563
|
+
`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.`,
|
|
2564
|
+
{ host: this.runtime.host, isServerless, durableStore: false }
|
|
2565
|
+
);
|
|
2566
|
+
}
|
|
2567
|
+
try {
|
|
2568
|
+
this.track({
|
|
2569
|
+
name: "sdk.boot",
|
|
2570
|
+
anonymousId: this.processAnonymousId,
|
|
2571
|
+
properties: {
|
|
2572
|
+
"durability.entitlementStore": hasStore,
|
|
2573
|
+
"durability.coldStartDurable": coldStartDurable,
|
|
2574
|
+
"durability.runtimeIsServerless": isServerless,
|
|
2575
|
+
"durability.runtimeHost": this.runtime.host,
|
|
2576
|
+
"durability.entitlementCacheTtlMs": this.entitlementCache.ttl
|
|
2577
|
+
}
|
|
2346
2578
|
});
|
|
2579
|
+
} catch {
|
|
2347
2580
|
}
|
|
2348
2581
|
}
|
|
2349
2582
|
// ============================================================
|
|
@@ -2401,13 +2634,77 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
2401
2634
|
// alias so a subsequent `isEntitled({ userId }, "pro")` resolves
|
|
2402
2635
|
// to the same cache entry.
|
|
2403
2636
|
// ============================================================
|
|
2637
|
+
/**
|
|
2638
|
+
* Fetch a customer's entitlements from Crossdeck and warm the cache.
|
|
2639
|
+
*
|
|
2640
|
+
* Durability — this is where last-known-good lives, NOT in the
|
|
2641
|
+
* synchronous `isEntitled()`:
|
|
2642
|
+
* - On a SUCCESSFUL fetch: the entitlement cache is populated and,
|
|
2643
|
+
* if an `entitlementStore` is configured, the result is persisted
|
|
2644
|
+
* to it (`await store.save(...)`). The cache + store now hold
|
|
2645
|
+
* server-confirmed truth.
|
|
2646
|
+
* - On a network FAILURE: the cache is marked refresh-failed for the
|
|
2647
|
+
* customer (so `diagnostics()` shows the staleness), then — if a
|
|
2648
|
+
* store is configured — last-known-good is loaded back from it
|
|
2649
|
+
* (`await store.load(...)`). If the store yields a snapshot, the
|
|
2650
|
+
* cache is populated from it and that snapshot is RETURNED as a
|
|
2651
|
+
* normal `EntitlementsListResponse` — a cold-start / outage no
|
|
2652
|
+
* longer fails a paying customer. If there is no store, or the
|
|
2653
|
+
* store is empty, the network error is rethrown unchanged so the
|
|
2654
|
+
* caller still sees the failure.
|
|
2655
|
+
*
|
|
2656
|
+
* The store is touched only here, inside the `await` that already
|
|
2657
|
+
* existed. `isEntitled()` remains a pure synchronous `Map` read.
|
|
2658
|
+
*/
|
|
2404
2659
|
async getEntitlements(hints, options) {
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2660
|
+
let response;
|
|
2661
|
+
try {
|
|
2662
|
+
response = await this.http.request("GET", "/entitlements", {
|
|
2663
|
+
query: this.identityPayload(hints),
|
|
2664
|
+
signal: options?.signal,
|
|
2665
|
+
timeoutMs: options?.timeoutMs
|
|
2666
|
+
});
|
|
2667
|
+
} catch (err) {
|
|
2668
|
+
const failedCustomerId = this.resolveFailedRefreshCustomerId(hints);
|
|
2669
|
+
if (failedCustomerId) {
|
|
2670
|
+
this.entitlementCache.markRefreshFailed(failedCustomerId);
|
|
2671
|
+
}
|
|
2672
|
+
const recovered = await this.loadEntitlementsFromStore(hints);
|
|
2673
|
+
if (recovered) {
|
|
2674
|
+
const recoveredResponse = {
|
|
2675
|
+
object: "list",
|
|
2676
|
+
data: recovered.entitlements,
|
|
2677
|
+
crossdeckCustomerId: recovered.crossdeckCustomerId,
|
|
2678
|
+
env: recovered.env
|
|
2679
|
+
};
|
|
2680
|
+
this.populateEntitlementCache(hints, recoveredResponse);
|
|
2681
|
+
this.entitlementCache.markRefreshFailed(recovered.crossdeckCustomerId);
|
|
2682
|
+
this.debug.emit(
|
|
2683
|
+
"sdk.entitlement_store_recovered",
|
|
2684
|
+
`Crossdeck unreachable \u2014 served ${recovered.crossdeckCustomerId} from the durable store (${recovered.entitlements.length} entitlement(s), last refreshed ${new Date(recovered.savedAt).toISOString()}).`,
|
|
2685
|
+
{
|
|
2686
|
+
customerId: recovered.crossdeckCustomerId,
|
|
2687
|
+
savedAt: recovered.savedAt,
|
|
2688
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2689
|
+
}
|
|
2690
|
+
);
|
|
2691
|
+
return recoveredResponse;
|
|
2692
|
+
}
|
|
2693
|
+
if (failedCustomerId && this.entitlementCache.isStale(failedCustomerId)) {
|
|
2694
|
+
this.debug.emit(
|
|
2695
|
+
"sdk.entitlement_cache_stale",
|
|
2696
|
+
`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().",
|
|
2697
|
+
{
|
|
2698
|
+
customerId: failedCustomerId,
|
|
2699
|
+
durableStore: this.entitlementStore !== null,
|
|
2700
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2701
|
+
}
|
|
2702
|
+
);
|
|
2703
|
+
}
|
|
2704
|
+
throw err;
|
|
2705
|
+
}
|
|
2410
2706
|
this.populateEntitlementCache(hints, response);
|
|
2707
|
+
await this.saveEntitlementsToStore(hints, response);
|
|
2411
2708
|
return response;
|
|
2412
2709
|
}
|
|
2413
2710
|
async getCustomerEntitlements(customerId, options) {
|
|
@@ -2921,7 +3218,14 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
2921
3218
|
count: this.entitlementCache.customerCount,
|
|
2922
3219
|
lastUpdated: this.entitlementCache.lastUpdated,
|
|
2923
3220
|
ttlMs: this.entitlementCache.ttl,
|
|
2924
|
-
listenerErrors: this.entitlementCache.listenerErrors
|
|
3221
|
+
listenerErrors: this.entitlementCache.listenerErrors,
|
|
3222
|
+
staleCustomers: this.entitlementCache.staleCustomerCount,
|
|
3223
|
+
isStale: this.entitlementCache.isAnyStale,
|
|
3224
|
+
lastRefreshFailedAt: this.entitlementCache.lastRefreshFailedAt,
|
|
3225
|
+
durableStore: this.entitlementStore !== null,
|
|
3226
|
+
// Cold-start durable iff a store is wired, OR the host is
|
|
3227
|
+
// long-lived (the process, hence the in-memory cache, survives).
|
|
3228
|
+
coldStartDurable: this.entitlementStore !== null || !this.runtime.isServerless
|
|
2925
3229
|
},
|
|
2926
3230
|
events: this.eventQueue.getStats(),
|
|
2927
3231
|
errors: {
|
|
@@ -3135,6 +3439,90 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
3135
3439
|
} catch {
|
|
3136
3440
|
}
|
|
3137
3441
|
}
|
|
3442
|
+
/**
|
|
3443
|
+
* Persist a successful entitlements fetch to the durable store, if
|
|
3444
|
+
* one is configured. No-op when there is no store.
|
|
3445
|
+
*
|
|
3446
|
+
* Saved under EVERY identity the caller might later look up by — the
|
|
3447
|
+
* canonical `crossdeckCustomerId` plus any `userId` / `anonymousId`
|
|
3448
|
+
* hint. The Node cache resolves a hint to a canonical ID via an
|
|
3449
|
+
* in-memory alias map; on a cold start that map is empty, so a
|
|
3450
|
+
* failure-path `load()` must be able to hit the store with the raw
|
|
3451
|
+
* hint the caller passed. Saving under all keys makes that work.
|
|
3452
|
+
*
|
|
3453
|
+
* Best-effort: a store `save()` that throws is swallowed (logged in
|
|
3454
|
+
* debug) — it weakens durability for that customer but must never
|
|
3455
|
+
* fail an otherwise-successful `getEntitlements()`.
|
|
3456
|
+
*/
|
|
3457
|
+
async saveEntitlementsToStore(hints, response) {
|
|
3458
|
+
if (!this.entitlementStore) return;
|
|
3459
|
+
const customerId = response.crossdeckCustomerId;
|
|
3460
|
+
if (!customerId) return;
|
|
3461
|
+
const snapshot = {
|
|
3462
|
+
v: 1,
|
|
3463
|
+
crossdeckCustomerId: customerId,
|
|
3464
|
+
entitlements: response.data,
|
|
3465
|
+
env: response.env,
|
|
3466
|
+
savedAt: Date.now()
|
|
3467
|
+
};
|
|
3468
|
+
const keys = /* @__PURE__ */ new Set([customerId]);
|
|
3469
|
+
if (hints.customerId) keys.add(hints.customerId);
|
|
3470
|
+
if (hints.userId) keys.add(hints.userId);
|
|
3471
|
+
if (hints.anonymousId) keys.add(hints.anonymousId);
|
|
3472
|
+
for (const key of keys) {
|
|
3473
|
+
try {
|
|
3474
|
+
await this.entitlementStore.save(key, snapshot);
|
|
3475
|
+
} catch (err) {
|
|
3476
|
+
this.debug.emit(
|
|
3477
|
+
"sdk.entitlement_store_recovered",
|
|
3478
|
+
`entitlementStore.save failed for key ${key} \u2014 durability weakened for this customer.`,
|
|
3479
|
+
{ key, error: err instanceof Error ? err.message : String(err) }
|
|
3480
|
+
);
|
|
3481
|
+
}
|
|
3482
|
+
}
|
|
3483
|
+
}
|
|
3484
|
+
/**
|
|
3485
|
+
* Load last-known-good entitlements from the durable store on a
|
|
3486
|
+
* network-failure path. Returns the first snapshot found across the
|
|
3487
|
+
* caller's identity keys, or `null` if there is no store / no stored
|
|
3488
|
+
* snapshot / every read failed.
|
|
3489
|
+
*
|
|
3490
|
+
* Tries the canonical `customerId` hint first, then `userId`, then
|
|
3491
|
+
* `anonymousId` — the order callers most commonly key by. A corrupt
|
|
3492
|
+
* or wrong-shaped blob is treated as a miss (the store is developer-
|
|
3493
|
+
* supplied; the SDK validates rather than trusts).
|
|
3494
|
+
*/
|
|
3495
|
+
async loadEntitlementsFromStore(hints) {
|
|
3496
|
+
if (!this.entitlementStore) return null;
|
|
3497
|
+
const keys = [];
|
|
3498
|
+
if (hints.customerId) keys.push(hints.customerId);
|
|
3499
|
+
if (hints.userId) keys.push(hints.userId);
|
|
3500
|
+
if (hints.anonymousId) keys.push(hints.anonymousId);
|
|
3501
|
+
for (const key of keys) {
|
|
3502
|
+
let loaded = null;
|
|
3503
|
+
try {
|
|
3504
|
+
loaded = await this.entitlementStore.load(key);
|
|
3505
|
+
} catch {
|
|
3506
|
+
continue;
|
|
3507
|
+
}
|
|
3508
|
+
if (isValidStoredEntitlements(loaded)) return loaded;
|
|
3509
|
+
}
|
|
3510
|
+
return null;
|
|
3511
|
+
}
|
|
3512
|
+
/**
|
|
3513
|
+
* Resolve the customer ID to stamp a failed-refresh marker against.
|
|
3514
|
+
*
|
|
3515
|
+
* Prefers a canonical ID the cache already knows (so the marker lands
|
|
3516
|
+
* on the existing warm entry), then falls back to whatever raw hint
|
|
3517
|
+
* the caller supplied — on a true cold-start failure there is no
|
|
3518
|
+
* cache entry yet, and marking under the hint still makes "we tried
|
|
3519
|
+
* for this customer and Crossdeck was down" observable.
|
|
3520
|
+
*/
|
|
3521
|
+
resolveFailedRefreshCustomerId(hints) {
|
|
3522
|
+
const known = this.resolveCacheCustomerId(hints);
|
|
3523
|
+
if (known) return known;
|
|
3524
|
+
return hints.customerId ?? hints.userId ?? hints.anonymousId ?? null;
|
|
3525
|
+
}
|
|
3138
3526
|
touchAlias(alias, customerId) {
|
|
3139
3527
|
this.customerIdAliases.delete(alias);
|
|
3140
3528
|
this.customerIdAliases.set(alias, customerId);
|
|
@@ -3236,6 +3624,11 @@ function sanitizePropertyBag(input, fieldName) {
|
|
|
3236
3624
|
});
|
|
3237
3625
|
}
|
|
3238
3626
|
}
|
|
3627
|
+
function isValidStoredEntitlements(value) {
|
|
3628
|
+
if (typeof value !== "object" || value === null) return false;
|
|
3629
|
+
const v = value;
|
|
3630
|
+
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";
|
|
3631
|
+
}
|
|
3239
3632
|
function categoryFor(name) {
|
|
3240
3633
|
if (name.startsWith("page.") || name.startsWith("navigation.")) return "navigation";
|
|
3241
3634
|
if (name.startsWith("element.") || name.startsWith("ui.click")) return "ui.click";
|