@cross-deck/node 1.1.1 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +140 -0
- package/dist/auto-events/index.d.mts +1 -1
- package/dist/auto-events/index.d.ts +1 -1
- package/dist/{crossdeck-server-BXQaFjVx.d.mts → crossdeck-server-DhnHvUhh.d.mts} +320 -21
- package/dist/{crossdeck-server-BXQaFjVx.d.ts → crossdeck-server-DhnHvUhh.d.ts} +320 -21
- package/dist/index.cjs +545 -61
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +37 -7
- package/dist/index.d.ts +37 -7
- package/dist/index.mjs +545 -61
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,146 @@ All notable changes to `@cross-deck/node` will be documented here. The
|
|
|
4
4
|
format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
|
5
5
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [1.3.1] — 2026-05-24
|
|
8
|
+
|
|
9
|
+
Patch fix for the 1.3.0 dist-load contract. Mirrors the
|
|
10
|
+
`@cross-deck/web@1.3.1` patch — `SDK_VERSION` is now sourced from a
|
|
11
|
+
generated `src/_version.ts` file (produced by
|
|
12
|
+
`scripts/sync-sdk-versions.mjs` from `package.json`) instead of a
|
|
13
|
+
runtime `import { version } from "../package.json"` that needs a
|
|
14
|
+
`with { type: "json" }` assertion to load as ESM. Wire contract is
|
|
15
|
+
unchanged. 1.3.0 was never published to npm; 1.3.1 is the first
|
|
16
|
+
1.3.x line to reach npm.
|
|
17
|
+
|
|
18
|
+
## [1.3.0] — 2026-05-24
|
|
19
|
+
|
|
20
|
+
KPMG bank-grade audit closure. Six review batches landed five SDK PRs
|
|
21
|
+
and a backend wiring fix that closes every P0 plus 12 of 13 P1 findings.
|
|
22
|
+
No public method renames; one internal contract change
|
|
23
|
+
(`ErrorTracker.beforeSend` is now a getter) that also removes the
|
|
24
|
+
`Object.defineProperty` workaround the node SDK shipped to compensate
|
|
25
|
+
for the same broken contract on web. Behavioural changes to the queue
|
|
26
|
+
and the PII scrub strictly improve correctness. The wire
|
|
27
|
+
`Crossdeck-Sdk-Version` header now reads from `package.json` so it
|
|
28
|
+
cannot drift from the published bundle.
|
|
29
|
+
|
|
30
|
+
### Fixed (P0)
|
|
31
|
+
|
|
32
|
+
- **PII scrub sentinel tokens aligned with the backend.** `[email]` /
|
|
33
|
+
`[card]` → `<email>` / `<card>`, matching `backend/src/api/lib/scrub.ts`.
|
|
34
|
+
The same event scrubbed by SDK + backend now carries the same
|
|
35
|
+
sentinel — dashboard aggregation works again.
|
|
36
|
+
- **`setErrorBeforeSend` contract cleaned up.** The
|
|
37
|
+
`ErrorTracker.beforeSend` field is now a getter
|
|
38
|
+
(`() => fn | null`). Removed the `Object.defineProperty` hack on
|
|
39
|
+
`tracker.opts` that worked around the old captured-by-value bug —
|
|
40
|
+
cleaner contract, lockstep with web.
|
|
41
|
+
- **Event queue drops 4xx batches.** Pre-fix every `catch` triggered
|
|
42
|
+
`scheduleRetry` with the same `Idempotency-Key`. A 401 (key revoked),
|
|
43
|
+
400/422 (malformed batch), 403 (permission), 404 (wrong baseUrl)
|
|
44
|
+
spun the retry timer indefinitely while the backlog grew silently.
|
|
45
|
+
New `isPermanent4xx()` helper hard-stops on any 4xx EXCEPT 408 / 429
|
|
46
|
+
(transient by spec). On permanent failure: drop the batch, increment
|
|
47
|
+
`dropped`, fire `onPermanentFailure(info)`, emit
|
|
48
|
+
`queue.permanent_failure` on the EventEmitter, log via
|
|
49
|
+
`console.error` regardless of debug mode.
|
|
50
|
+
- **Error-capture self-skip derived from `baseUrl`.** Pre-fix hardcoded
|
|
51
|
+
to `api.cross-deck.com`; customers on staging / regional / self-hosted
|
|
52
|
+
base URLs recursed (5xx → captureHttp → enqueue → /events →
|
|
53
|
+
captureHttp → ∞). Now strict-hostname compare against `selfHostname`
|
|
54
|
+
extracted from constructor `baseUrl`. Closes the substring-match
|
|
55
|
+
bypass (`api.cross-deck.com.attacker.example` would have matched).
|
|
56
|
+
|
|
57
|
+
### Added
|
|
58
|
+
|
|
59
|
+
- **`onPermanentFailure` callback** on `EventQueueConfig`, surfaced
|
|
60
|
+
via `CrossdeckServer.on("queue.permanent_failure", …)` for host-app
|
|
61
|
+
paging.
|
|
62
|
+
- **`sdk.flush_permanent_failure` debug signal** in the
|
|
63
|
+
`DebugSignal` vocabulary.
|
|
64
|
+
|
|
65
|
+
### Changed
|
|
66
|
+
|
|
67
|
+
- **`SDK_VERSION` is now imported from `package.json`.** The
|
|
68
|
+
`Crossdeck-Sdk-Version` header always matches the published bundle.
|
|
69
|
+
Single source of truth.
|
|
70
|
+
- **Event ingest envelope now ships `environment`.** Pre-fix web sent
|
|
71
|
+
it and node didn't; backend `v1-events.ts` cross-checks it against
|
|
72
|
+
the API-key-derived env and rejects mismatches loudly
|
|
73
|
+
(`env_mismatch`). Defence-in-depth so a "live key, env: sandbox"
|
|
74
|
+
misconfig fails fast instead of polluting the wrong dashboard.
|
|
75
|
+
- **`syncPurchases` body spread bug.** Pre-fix
|
|
76
|
+
`{ rail: input.rail ?? "apple", ...input }` — the `...input` ran
|
|
77
|
+
LAST and overrode the default when the caller passed
|
|
78
|
+
`rail: undefined` explicitly. Reversed: `{ ...input, rail }`.
|
|
79
|
+
- **PII scrub regex uses `.replace()` unconditionally.** Dropped the
|
|
80
|
+
`.test()`-gating that carried `lastIndex` state between calls.
|
|
81
|
+
- **`bootHeartbeat: false` no longer silences the
|
|
82
|
+
`sdk.no_durable_store` warning.** Pre-fix the warning lived inside
|
|
83
|
+
`emitBootTelemetry()` which sat inside the `bootHeartbeat` gate, so
|
|
84
|
+
the opt-out silenced the entire reason `entitlementStore` exists.
|
|
85
|
+
Split into two methods: `emitDurabilityWarning()` (local-only,
|
|
86
|
+
unconditional) and `emitBootTelemetryEvent()` (phone-home, still
|
|
87
|
+
gated).
|
|
88
|
+
- **`isEntitled(string)` requires the `cdcust_` prefix** for canonical-
|
|
89
|
+
path resolution. Pre-fix any string with a cache entry resolved
|
|
90
|
+
through the canonical path — a small cross-tenant primitive if a
|
|
91
|
+
tenant's userId collided with another tenant's `crossdeckCustomerId`.
|
|
92
|
+
Non-prefixed strings now drop to alias lookup only.
|
|
93
|
+
- **Self-skip applies to breadcrumbs too**, not just `captureHttp`.
|
|
94
|
+
Error reports no longer carry noisy `POST https://api.cross-deck.com/v1/events`
|
|
95
|
+
crumb entries.
|
|
96
|
+
|
|
97
|
+
### Wiring (backend, paired)
|
|
98
|
+
|
|
99
|
+
- **`v1-events` ingest now honours the per-project `piiAllowList`.**
|
|
100
|
+
The admin management surface (`v1-pii-allow-list.ts`) was persisted +
|
|
101
|
+
audit-logged but the hot ingest path never read it. The new
|
|
102
|
+
`backend/src/api/lib/pii-allow-list-cache.ts` (60s TTL,
|
|
103
|
+
single-flight) feeds the project's allow-list to `scrubProperties()`
|
|
104
|
+
on every batch. `HARD_LOCKED_PATTERNS` are always stripped from the
|
|
105
|
+
effective list regardless of what's in storage. (Backend-only —
|
|
106
|
+
listed here so server-SDK consumers know defence-in-depth is fully
|
|
107
|
+
closed.)
|
|
108
|
+
|
|
109
|
+
## [1.2.0] — 2026-05-18
|
|
110
|
+
|
|
111
|
+
### Added
|
|
112
|
+
|
|
113
|
+
- **Pluggable durable entitlement store (`entitlementStore`).** A new
|
|
114
|
+
constructor option taking an async `EntitlementStore` (a `load` /
|
|
115
|
+
`save` pair) — back it with Redis, your own database, or a KV. Every
|
|
116
|
+
successful `getEntitlements()` persists the result to it, and on a
|
|
117
|
+
network failure the SDK falls back to the stored snapshot. This is
|
|
118
|
+
what gives serverless deployments (Cloud Run / Lambda) cold-start
|
|
119
|
+
durability that an in-memory cache alone cannot. `EntitlementStore`
|
|
120
|
+
and `StoredEntitlements` are exported.
|
|
121
|
+
- **Staleness fields in `diagnostics()`.** `entitlements.staleCustomers`,
|
|
122
|
+
`isStale`, `durableStore`, and `coldStartDurable` — so serving
|
|
123
|
+
last-known-good through a Crossdeck outage is observable, not silent.
|
|
124
|
+
- **`sdk.no_durable_store` debug signal**, emitted once on a serverless
|
|
125
|
+
runtime with no `entitlementStore` configured, alongside a
|
|
126
|
+
`durability` fact on the boot telemetry event — so the cold-start gap
|
|
127
|
+
is measurable rather than a surprise in production.
|
|
128
|
+
|
|
129
|
+
### Changed
|
|
130
|
+
|
|
131
|
+
- **The entitlement cache is now durable last-known-good.**
|
|
132
|
+
`isEntitled()` and `list()` no longer expire to `false` / `[]` when
|
|
133
|
+
`entitlementCacheTtlMs` elapses — they keep serving the last
|
|
134
|
+
successfully-fetched entitlements. The TTL is now a refresh hint, not
|
|
135
|
+
an invalidation. Each entitlement is still honoured against its own
|
|
136
|
+
`validUntil`. A brief Crossdeck outage can no longer fail a paying
|
|
137
|
+
customer down to free 60 seconds after a warm.
|
|
138
|
+
|
|
139
|
+
## [1.1.1] — 2026-05-14
|
|
140
|
+
|
|
141
|
+
### Changed
|
|
142
|
+
|
|
143
|
+
- Ported the "never silently surface an `Unknown` error" hardening to
|
|
144
|
+
`@cross-deck/node` — a captured error with no usable type or message
|
|
145
|
+
is now labelled precisely instead of collapsing to `Unknown error`.
|
|
146
|
+
|
|
7
147
|
## [1.1.0] — 2026-05-13
|
|
8
148
|
|
|
9
149
|
### Added
|
|
@@ -221,6 +221,22 @@ interface RuntimeInfo {
|
|
|
221
221
|
platformRelease: string;
|
|
222
222
|
hostname: string;
|
|
223
223
|
host: RuntimeHost;
|
|
224
|
+
/**
|
|
225
|
+
* Whether the host is a scale-to-zero / per-request-instance platform
|
|
226
|
+
* where a cold start begins with empty process memory.
|
|
227
|
+
*
|
|
228
|
+
* `true` for FaaS + serverless-container platforms (Lambda, Cloud
|
|
229
|
+
* Run, Firebase Functions v1/v2, Vercel, Netlify, Azure Functions,
|
|
230
|
+
* App Engine). `false` for long-lived process hosts (Heroku, Render,
|
|
231
|
+
* Railway, Fly, Kubernetes, plain Node) where the process — and thus
|
|
232
|
+
* the in-memory entitlement cache — persists across requests.
|
|
233
|
+
*
|
|
234
|
+
* The entitlement-cache durability layer reads this: a serverless
|
|
235
|
+
* host with no `entitlementStore` has no cold-start durability, and
|
|
236
|
+
* the SDK surfaces that explicitly (debug warning + a `durability`
|
|
237
|
+
* fact on the boot telemetry event).
|
|
238
|
+
*/
|
|
239
|
+
isServerless: boolean;
|
|
224
240
|
region: string | null;
|
|
225
241
|
serviceName: string | null;
|
|
226
242
|
serviceVersion: string | null;
|
|
@@ -235,8 +251,6 @@ interface RuntimeInfo {
|
|
|
235
251
|
appVersion: string | null;
|
|
236
252
|
}
|
|
237
253
|
|
|
238
|
-
declare const SDK_NAME = "@cross-deck/node";
|
|
239
|
-
declare const SDK_VERSION = "1.1.0";
|
|
240
254
|
declare const DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
|
|
241
255
|
declare const DEFAULT_TIMEOUT_MS = 15000;
|
|
242
256
|
/**
|
|
@@ -310,6 +324,63 @@ interface EntitlementsListResponse {
|
|
|
310
324
|
crossdeckCustomerId: string;
|
|
311
325
|
env: Environment;
|
|
312
326
|
}
|
|
327
|
+
/**
|
|
328
|
+
* Snapshot of one customer's last-known-good entitlements, as written
|
|
329
|
+
* to / read from a durable `EntitlementStore`. Versioned for forward-
|
|
330
|
+
* compat — a future SDK can refuse a blob whose `v` it doesn't know.
|
|
331
|
+
*
|
|
332
|
+
* Carries enough to fully reconstruct an `EntitlementsListResponse` on
|
|
333
|
+
* a cold start (the durable read path in `getEntitlements()` rebuilds
|
|
334
|
+
* the response from this), plus `savedAt` so staleness is measurable
|
|
335
|
+
* after a process restart.
|
|
336
|
+
*/
|
|
337
|
+
interface StoredEntitlements {
|
|
338
|
+
v: 1;
|
|
339
|
+
/** Canonical Crossdeck customer ID this snapshot belongs to. */
|
|
340
|
+
crossdeckCustomerId: string;
|
|
341
|
+
/** The entitlement set exactly as the server last returned it. */
|
|
342
|
+
entitlements: PublicEntitlement[];
|
|
343
|
+
env: Environment;
|
|
344
|
+
/** Epoch ms of the successful server fetch that produced this snapshot. */
|
|
345
|
+
savedAt: number;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Pluggable async durable store for last-known-good entitlements.
|
|
349
|
+
*
|
|
350
|
+
* The Node SDK's entitlement cache is an in-memory per-customer `Map`.
|
|
351
|
+
* On serverless (Cloud Run / AWS Lambda) a cold start is an empty Map,
|
|
352
|
+
* and a brief Crossdeck outage during that window would otherwise read
|
|
353
|
+
* a paying customer as un-entitled. An `EntitlementStore` is the
|
|
354
|
+
* developer-supplied durability layer — Redis, their primary DB, a KV —
|
|
355
|
+
* that survives both a cold start and an outage.
|
|
356
|
+
*
|
|
357
|
+
* Contract:
|
|
358
|
+
* - `load` returns the most recent snapshot for a customer, or `null`
|
|
359
|
+
* if none exists. It MUST NOT throw for a missing key — return
|
|
360
|
+
* `null`. (The SDK additionally guards every call in a try/catch,
|
|
361
|
+
* but a well-behaved store returns `null`.)
|
|
362
|
+
* - `save` persists a snapshot. Called only after a SUCCESSFUL server
|
|
363
|
+
* fetch, so the store never holds anything but server-confirmed
|
|
364
|
+
* truth.
|
|
365
|
+
* - Both are awaited inside `getEntitlements()` (already async). They
|
|
366
|
+
* are NEVER called from the synchronous `isEntitled()` — that stays
|
|
367
|
+
* a pure in-memory `Map` read with zero I/O.
|
|
368
|
+
* - The SDK swallows store errors: a failed `save` never fails a
|
|
369
|
+
* successful fetch, a failed `load` degrades to "no durable copy".
|
|
370
|
+
* A broken store weakens durability; it never breaks the SDK.
|
|
371
|
+
*
|
|
372
|
+
* The `key` passed to `load` / `save` is whatever identity string the
|
|
373
|
+
* caller used — a canonical `crossdeckCustomerId`, or a developer
|
|
374
|
+
* `userId` / `anonymousId` hint. The SDK saves a snapshot under every
|
|
375
|
+
* identity it knows for a customer so a cold-start `load` succeeds even
|
|
376
|
+
* before the in-memory alias map is populated.
|
|
377
|
+
*/
|
|
378
|
+
interface EntitlementStore {
|
|
379
|
+
/** Resolve a customer's last-known-good snapshot, or `null` if none. */
|
|
380
|
+
load(key: string): Promise<StoredEntitlements | null>;
|
|
381
|
+
/** Persist a customer's last-known-good snapshot. */
|
|
382
|
+
save(key: string, value: StoredEntitlements): Promise<void>;
|
|
383
|
+
}
|
|
313
384
|
interface AliasResult {
|
|
314
385
|
object: "alias_result";
|
|
315
386
|
crossdeckCustomerId: string;
|
|
@@ -462,8 +533,46 @@ interface CrossdeckServerOptions {
|
|
|
462
533
|
*
|
|
463
534
|
* Pass `0` to disable caching (every `isEntitled` requires a fresh
|
|
464
535
|
* `getEntitlements()` call to populate the cache — useful for tests).
|
|
536
|
+
*
|
|
537
|
+
* NOTE: the TTL is a REFRESH HINT, not an invalidation. Once a
|
|
538
|
+
* customer is warm, `isEntitled()` keeps serving last-known-good past
|
|
539
|
+
* the TTL — a brief Crossdeck outage can never flip a paying customer
|
|
540
|
+
* to `false`. The TTL only tells `needsRefresh()` when a re-fetch is
|
|
541
|
+
* due, and (with no failed refresh) when the cache is flagged stale.
|
|
542
|
+
* Each entitlement's own `validUntil` is still honoured at read time.
|
|
465
543
|
*/
|
|
466
544
|
entitlementCacheTtlMs?: number;
|
|
545
|
+
/**
|
|
546
|
+
* Age (ms) past which last-known-good entitlement data is flagged
|
|
547
|
+
* STALE in `diagnostics()` even with no failed refresh. Default 24h.
|
|
548
|
+
*
|
|
549
|
+
* Staleness never changes what `isEntitled()` returns — the cache
|
|
550
|
+
* keeps serving last-known-good. This window only makes "we have been
|
|
551
|
+
* serving an un-refreshed answer for a long time" observable, so an
|
|
552
|
+
* event-based revoke (chargeback / refund — which has no `validUntil`)
|
|
553
|
+
* riding out a long outage is visible instead of silent.
|
|
554
|
+
*/
|
|
555
|
+
entitlementStaleAfterMs?: number;
|
|
556
|
+
/**
|
|
557
|
+
* Durable last-known-good store for entitlements. Optional.
|
|
558
|
+
*
|
|
559
|
+
* The entitlement cache is in-memory. On serverless (Cloud Run /
|
|
560
|
+
* Lambda) every cold start begins with an empty cache — and if
|
|
561
|
+
* Crossdeck is briefly unreachable during that window, a paying
|
|
562
|
+
* customer would read as un-entitled. Wiring an `EntitlementStore`
|
|
563
|
+
* (Redis / your DB / a KV) closes that gap: every successful
|
|
564
|
+
* `getEntitlements()` persists the result, and a network failure
|
|
565
|
+
* falls back to the stored snapshot instead of throwing.
|
|
566
|
+
*
|
|
567
|
+
* Without a store on a serverless host the SDK has NO cold-start
|
|
568
|
+
* durability — that is unavoidable and the SDK says so explicitly
|
|
569
|
+
* (a `debug.emit` warning plus a `durability` fact on the boot
|
|
570
|
+
* telemetry event). It is not hidden.
|
|
571
|
+
*
|
|
572
|
+
* `isEntitled()` stays synchronous regardless — the store is only
|
|
573
|
+
* ever touched from the already-async `getEntitlements()`.
|
|
574
|
+
*/
|
|
575
|
+
entitlementStore?: EntitlementStore;
|
|
467
576
|
/**
|
|
468
577
|
* Service name for runtime enrichment. Attached to every event + error
|
|
469
578
|
* as `properties.serviceName`. Default: env-detected via
|
|
@@ -674,6 +783,32 @@ interface Diagnostics {
|
|
|
674
783
|
ttlMs: number;
|
|
675
784
|
/** Cumulative count of listener invocations that threw. Swallowed inside the cache; surfaced here. */
|
|
676
785
|
listenerErrors: number;
|
|
786
|
+
/**
|
|
787
|
+
* Number of cached customers currently flagged STALE — their most
|
|
788
|
+
* recent refresh attempt failed, or their data has aged past
|
|
789
|
+
* `entitlementStaleAfterMs`. The cache keeps serving last-known-good
|
|
790
|
+
* for them; this count makes "serving through an outage" observable.
|
|
791
|
+
*/
|
|
792
|
+
staleCustomers: number;
|
|
793
|
+
/**
|
|
794
|
+
* Whether ANY cached customer is stale. Quick boolean for health
|
|
795
|
+
* checks / alerting without inspecting `staleCustomers`.
|
|
796
|
+
*/
|
|
797
|
+
isStale: boolean;
|
|
798
|
+
/**
|
|
799
|
+
* Most recent failed-refresh timestamp across all customers (epoch
|
|
800
|
+
* ms), or 0 if every customer's last refresh succeeded.
|
|
801
|
+
*/
|
|
802
|
+
lastRefreshFailedAt: number;
|
|
803
|
+
/**
|
|
804
|
+
* Durable-store posture. `durableStore` is true iff an
|
|
805
|
+
* `EntitlementStore` is configured. `coldStartDurable` is true iff
|
|
806
|
+
* the SDK has cold-start durability — which on a serverless host
|
|
807
|
+
* requires a store, and on a long-lived host is inherently true
|
|
808
|
+
* (the process, hence the in-memory cache, survives).
|
|
809
|
+
*/
|
|
810
|
+
durableStore: boolean;
|
|
811
|
+
coldStartDurable: boolean;
|
|
677
812
|
};
|
|
678
813
|
events: {
|
|
679
814
|
buffered: number;
|
|
@@ -844,8 +979,8 @@ interface GroupMembership {
|
|
|
844
979
|
}
|
|
845
980
|
|
|
846
981
|
/**
|
|
847
|
-
* Per-customer entitlement cache
|
|
848
|
-
* on the server.
|
|
982
|
+
* Per-customer durable last-known-good entitlement cache — the third
|
|
983
|
+
* Crossdeck USP on the server.
|
|
849
984
|
*
|
|
850
985
|
* Why this exists: server-side gating code looks like
|
|
851
986
|
*
|
|
@@ -856,29 +991,68 @@ interface GroupMembership {
|
|
|
856
991
|
* per request, every request, for every customer. The cache makes
|
|
857
992
|
* `isEntitled()` a `Map.get()` after the first warm.
|
|
858
993
|
*
|
|
859
|
-
*
|
|
994
|
+
* Durability contract (mirrors `@cross-deck/web/src/entitlement-cache.ts`,
|
|
995
|
+
* adapted for a multi-tenant server):
|
|
996
|
+
* - This cache is NOT a second source of truth. Crossdeck remains the
|
|
997
|
+
* only source; this is the SDK's local copy of what the server last
|
|
998
|
+
* told us — a cache that does not forget during a network partition.
|
|
999
|
+
* - Only a SUCCESSFUL fetch replaces a customer's entry (via
|
|
1000
|
+
* `setForCustomer`). A failed refresh never reaches it, so an outage
|
|
1001
|
+
* can never fail a paying customer down to free.
|
|
1002
|
+
* - **The TTL is a REFRESH HINT, not an invalidation.** `isEntitled()`
|
|
1003
|
+
* and `list()` keep serving last-known-good after `ttlMs` elapses —
|
|
1004
|
+
* they do NOT return `false` / `[]` because the entry aged. The TTL
|
|
1005
|
+
* only drives `needsRefresh()` ("a re-fetch is due") and, with no
|
|
1006
|
+
* failed refresh, the stale flag. This is the central fix: on
|
|
1007
|
+
* serverless a paying customer must not be locked out 60s after a
|
|
1008
|
+
* warm just because Crossdeck was briefly unreachable.
|
|
1009
|
+
* - Staleness alone never returns false. Each entitlement is honoured
|
|
1010
|
+
* against its OWN `validUntil` instead — a time-based trial expiry
|
|
1011
|
+
* still applies even mid-partition; a still-valid Pro entitlement
|
|
1012
|
+
* rides the outage out.
|
|
1013
|
+
* - Staleness is VISIBLE, not silent. `validUntil` covers time-based
|
|
1014
|
+
* expiry; it does NOT cover an event-based revoke (chargeback,
|
|
1015
|
+
* refund, fraud) — that has no `validUntil`, so the cache would keep
|
|
1016
|
+
* serving a revoked customer through an outage. Serving them is the
|
|
1017
|
+
* right trade (don't lock real payers out), but unbounded-and-
|
|
1018
|
+
* invisible is the bug. So once a refresh ATTEMPT fails
|
|
1019
|
+
* (`markRefreshFailed`) or the data ages past `staleAfterMs`, the
|
|
1020
|
+
* customer is flagged stale — `isStale()` / `staleCustomerCount` are
|
|
1021
|
+
* surfaced in `diagnostics()`. It keeps serving last-known-good; the
|
|
1022
|
+
* staleness is just no longer hidden.
|
|
1023
|
+
*
|
|
1024
|
+
* Cold-start durability lives one layer up: `getEntitlements()` in
|
|
1025
|
+
* `crossdeck-server.ts` persists every successful fetch to an optional
|
|
1026
|
+
* `EntitlementStore` and, on a network failure, loads last-known-good
|
|
1027
|
+
* back from it and into this cache. This cache stays a pure in-memory
|
|
1028
|
+
* structure with NO I/O — `isEntitled()` is and remains synchronous.
|
|
1029
|
+
*
|
|
1030
|
+
* Differences from the web SDK's cache:
|
|
860
1031
|
* - **Per-customer**, not singleton. Web SDK has one user per browser
|
|
861
1032
|
* tab; Node SDK has many users hitting one server. The cache is
|
|
862
|
-
* keyed by `crossdeckCustomerId`.
|
|
863
|
-
*
|
|
864
|
-
*
|
|
865
|
-
* `
|
|
866
|
-
*
|
|
1033
|
+
* keyed by `crossdeckCustomerId`. Staleness, freshness and the
|
|
1034
|
+
* failed-refresh marker are therefore all per-customer too.
|
|
1035
|
+
* - **No synchronous storage hydration** in the constructor. The web
|
|
1036
|
+
* SDK hydrates from `localStorage` on boot; the Node durable store
|
|
1037
|
+
* is async, so hydration happens lazily inside `getEntitlements()`.
|
|
1038
|
+
* - **LRU-bounded** by `maxCustomers` — a long-running multi-tenant
|
|
1039
|
+
* server would otherwise leak Map entries forever.
|
|
867
1040
|
* - **Subscriber API unchanged** — `subscribe(listener)` fires after
|
|
868
|
-
* any mutation (set / clear
|
|
869
|
-
*
|
|
870
|
-
* re-render just because a TTL elapsed).
|
|
871
|
-
*
|
|
872
|
-
* The cache holds only ACTIVE entitlements — `setForCustomer` filters.
|
|
873
|
-
* `isEntitled()` returns `false` when:
|
|
874
|
-
* - the customer has no cached entry
|
|
875
|
-
* - the entry has expired
|
|
876
|
-
* - the requested key isn't in the active set
|
|
1041
|
+
* any mutation (set / clear). Passive LRU eviction and a TTL
|
|
1042
|
+
* elapsing are NOT mutations, by design.
|
|
877
1043
|
*/
|
|
878
1044
|
|
|
879
1045
|
type EntitlementsListener = (customerId: string, entitlements: PublicEntitlement[]) => void;
|
|
880
1046
|
interface EntitlementCacheOptions {
|
|
881
|
-
/**
|
|
1047
|
+
/**
|
|
1048
|
+
* Refresh-hint TTL in ms. Default 60_000 (60s).
|
|
1049
|
+
*
|
|
1050
|
+
* After `ttlMs` a customer's entry is "refresh due" — `needsRefresh()`
|
|
1051
|
+
* returns true and the caller should re-fetch. It is NOT an expiry:
|
|
1052
|
+
* `isEntitled()` keeps serving last-known-good past it. `0` makes
|
|
1053
|
+
* every entry immediately refresh-due (useful for tests) but STILL
|
|
1054
|
+
* does not invalidate — last-known-good is served regardless.
|
|
1055
|
+
*/
|
|
882
1056
|
ttlMs?: number;
|
|
883
1057
|
/**
|
|
884
1058
|
* Maximum number of customers cached at once. Long-running multi-tenant
|
|
@@ -889,6 +1063,16 @@ interface EntitlementCacheOptions {
|
|
|
889
1063
|
* (passive eviction is not a mutation by design).
|
|
890
1064
|
*/
|
|
891
1065
|
maxCustomers?: number;
|
|
1066
|
+
/**
|
|
1067
|
+
* Age (ms) past which a customer's last-known-good data is flagged
|
|
1068
|
+
* STALE even with no failed refresh. Default 24h.
|
|
1069
|
+
*
|
|
1070
|
+
* Staleness never changes what `isEntitled()` returns; it only makes a
|
|
1071
|
+
* long un-refreshed window observable via `isStale()` / diagnostics —
|
|
1072
|
+
* so an event-based revoke (no `validUntil`) riding out an outage is
|
|
1073
|
+
* visible instead of silent.
|
|
1074
|
+
*/
|
|
1075
|
+
staleAfterMs?: number;
|
|
892
1076
|
}
|
|
893
1077
|
|
|
894
1078
|
/**
|
|
@@ -995,6 +1179,16 @@ declare class CrossdeckServer extends EventEmitter {
|
|
|
995
1179
|
private readonly flushOnExit;
|
|
996
1180
|
private readonly superProps;
|
|
997
1181
|
private readonly entitlementCache;
|
|
1182
|
+
/**
|
|
1183
|
+
* Optional developer-supplied durable store for last-known-good
|
|
1184
|
+
* entitlements (Redis / their DB / a KV). `undefined` when not
|
|
1185
|
+
* configured — the SDK then has no cold-start durability on
|
|
1186
|
+
* serverless, which it states explicitly at boot.
|
|
1187
|
+
*
|
|
1188
|
+
* Touched ONLY from the async `getEntitlements()` — never from the
|
|
1189
|
+
* synchronous `isEntitled()`.
|
|
1190
|
+
*/
|
|
1191
|
+
private readonly entitlementStore;
|
|
998
1192
|
private readonly debug;
|
|
999
1193
|
/**
|
|
1000
1194
|
* Alias map — `developerUserId` / `anonymousId` → canonical
|
|
@@ -1014,9 +1208,67 @@ declare class CrossdeckServer extends EventEmitter {
|
|
|
1014
1208
|
private errorTags;
|
|
1015
1209
|
private errorBeforeSend;
|
|
1016
1210
|
constructor(options: CrossdeckServerOptions);
|
|
1211
|
+
/**
|
|
1212
|
+
* Emit the honest "no cold-start durability" warning when the runtime
|
|
1213
|
+
* is serverless AND no `entitlementStore` is wired. Local-only debug
|
|
1214
|
+
* signal — no network call, no phone-home. Safe to fire from the
|
|
1215
|
+
* constructor before `setImmediate` because there is no I/O on this
|
|
1216
|
+
* path.
|
|
1217
|
+
*
|
|
1218
|
+
* `isServerless` AND no store is the gap: a cold start begins with an
|
|
1219
|
+
* empty in-memory cache and a brief Crossdeck outage in that window
|
|
1220
|
+
* would read a paying customer as un-entitled. That gap is
|
|
1221
|
+
* unavoidable without a store — so the SDK STATES it (a
|
|
1222
|
+
* `sdk.no_durable_store` debug warning) rather than hiding it.
|
|
1223
|
+
*
|
|
1224
|
+
* Audit P1 #9: this used to live INSIDE `emitBootTelemetry()` which
|
|
1225
|
+
* itself sat inside the `bootHeartbeat` gate, so any developer who
|
|
1226
|
+
* set `bootHeartbeat: false` silently disabled the entire reason
|
|
1227
|
+
* `entitlementStore` exists. Now split: warning fires
|
|
1228
|
+
* unconditionally; the boot phone-home stays gated.
|
|
1229
|
+
*/
|
|
1230
|
+
private emitDurabilityWarning;
|
|
1231
|
+
/**
|
|
1232
|
+
* Emit the one-time `sdk.boot` telemetry event — the aggregatable
|
|
1233
|
+
* fact the backend pivots on (compute fleet-wide
|
|
1234
|
+
* "% serverless-with-no-durable-store"). Rides the batched + retried
|
|
1235
|
+
* + idempotent queue and is drained by flush-on-exit, so it survives
|
|
1236
|
+
* a serverless teardown.
|
|
1237
|
+
*
|
|
1238
|
+
* Why a `track()` event and not the heartbeat: `GET /v1/sdk/heartbeat`
|
|
1239
|
+
* carries no request body, so it cannot transport a structured
|
|
1240
|
+
* `durability` fact.
|
|
1241
|
+
*
|
|
1242
|
+
* Gated by `bootHeartbeat` (and `testMode`) because it IS a phone-
|
|
1243
|
+
* home — the unconditional surface is `emitDurabilityWarning()`,
|
|
1244
|
+
* which has no network call.
|
|
1245
|
+
*/
|
|
1246
|
+
private emitBootTelemetryEvent;
|
|
1017
1247
|
identify(userId: string, anonymousId: string, options?: IdentifyOptions & RequestOptions): Promise<AliasResult>;
|
|
1018
1248
|
aliasIdentity(input: AliasIdentityInput, options?: RequestOptions): Promise<AliasResult>;
|
|
1019
1249
|
forget(hints: IdentityHints, options?: RequestOptions): Promise<ForgetResult>;
|
|
1250
|
+
/**
|
|
1251
|
+
* Fetch a customer's entitlements from Crossdeck and warm the cache.
|
|
1252
|
+
*
|
|
1253
|
+
* Durability — this is where last-known-good lives, NOT in the
|
|
1254
|
+
* synchronous `isEntitled()`:
|
|
1255
|
+
* - On a SUCCESSFUL fetch: the entitlement cache is populated and,
|
|
1256
|
+
* if an `entitlementStore` is configured, the result is persisted
|
|
1257
|
+
* to it (`await store.save(...)`). The cache + store now hold
|
|
1258
|
+
* server-confirmed truth.
|
|
1259
|
+
* - On a network FAILURE: the cache is marked refresh-failed for the
|
|
1260
|
+
* customer (so `diagnostics()` shows the staleness), then — if a
|
|
1261
|
+
* store is configured — last-known-good is loaded back from it
|
|
1262
|
+
* (`await store.load(...)`). If the store yields a snapshot, the
|
|
1263
|
+
* cache is populated from it and that snapshot is RETURNED as a
|
|
1264
|
+
* normal `EntitlementsListResponse` — a cold-start / outage no
|
|
1265
|
+
* longer fails a paying customer. If there is no store, or the
|
|
1266
|
+
* store is empty, the network error is rethrown unchanged so the
|
|
1267
|
+
* caller still sees the failure.
|
|
1268
|
+
*
|
|
1269
|
+
* The store is touched only here, inside the `await` that already
|
|
1270
|
+
* existed. `isEntitled()` remains a pure synchronous `Map` read.
|
|
1271
|
+
*/
|
|
1020
1272
|
getEntitlements(hints: IdentityHints, options?: RequestOptions): Promise<EntitlementsListResponse>;
|
|
1021
1273
|
getCustomerEntitlements(customerId: string, options?: RequestOptions): Promise<EntitlementsListResponse>;
|
|
1022
1274
|
/**
|
|
@@ -1389,11 +1641,58 @@ declare class CrossdeckServer extends EventEmitter {
|
|
|
1389
1641
|
* with the entitlement cache's max-customers cap.
|
|
1390
1642
|
*/
|
|
1391
1643
|
private populateEntitlementCache;
|
|
1644
|
+
/**
|
|
1645
|
+
* Persist a successful entitlements fetch to the durable store, if
|
|
1646
|
+
* one is configured. No-op when there is no store.
|
|
1647
|
+
*
|
|
1648
|
+
* Saved under EVERY identity the caller might later look up by — the
|
|
1649
|
+
* canonical `crossdeckCustomerId` plus any `userId` / `anonymousId`
|
|
1650
|
+
* hint. The Node cache resolves a hint to a canonical ID via an
|
|
1651
|
+
* in-memory alias map; on a cold start that map is empty, so a
|
|
1652
|
+
* failure-path `load()` must be able to hit the store with the raw
|
|
1653
|
+
* hint the caller passed. Saving under all keys makes that work.
|
|
1654
|
+
*
|
|
1655
|
+
* Best-effort: a store `save()` that throws is swallowed (logged in
|
|
1656
|
+
* debug) — it weakens durability for that customer but must never
|
|
1657
|
+
* fail an otherwise-successful `getEntitlements()`.
|
|
1658
|
+
*/
|
|
1659
|
+
private saveEntitlementsToStore;
|
|
1660
|
+
/**
|
|
1661
|
+
* Load last-known-good entitlements from the durable store on a
|
|
1662
|
+
* network-failure path. Returns the first snapshot found across the
|
|
1663
|
+
* caller's identity keys, or `null` if there is no store / no stored
|
|
1664
|
+
* snapshot / every read failed.
|
|
1665
|
+
*
|
|
1666
|
+
* Tries the canonical `customerId` hint first, then `userId`, then
|
|
1667
|
+
* `anonymousId` — the order callers most commonly key by. A corrupt
|
|
1668
|
+
* or wrong-shaped blob is treated as a miss (the store is developer-
|
|
1669
|
+
* supplied; the SDK validates rather than trusts).
|
|
1670
|
+
*/
|
|
1671
|
+
private loadEntitlementsFromStore;
|
|
1672
|
+
/**
|
|
1673
|
+
* Resolve the customer ID to stamp a failed-refresh marker against.
|
|
1674
|
+
*
|
|
1675
|
+
* Prefers a canonical ID the cache already knows (so the marker lands
|
|
1676
|
+
* on the existing warm entry), then falls back to whatever raw hint
|
|
1677
|
+
* the caller supplied — on a true cold-start failure there is no
|
|
1678
|
+
* cache entry yet, and marking under the hint still makes "we tried
|
|
1679
|
+
* for this customer and Crossdeck was down" observable.
|
|
1680
|
+
*/
|
|
1681
|
+
private resolveFailedRefreshCustomerId;
|
|
1392
1682
|
private touchAlias;
|
|
1393
1683
|
/**
|
|
1394
1684
|
* Resolve any hint shape (canonical customerId / userId hint /
|
|
1395
1685
|
* anonymousId hint / raw string) to a `crossdeckCustomerId` if we
|
|
1396
1686
|
* have a cache entry for it.
|
|
1687
|
+
*
|
|
1688
|
+
* String overload is STRICT on the canonical-id shape. Pre-fix
|
|
1689
|
+
* `isFresh(raw)` treated any string with a cache entry as a valid
|
|
1690
|
+
* canonical id — if tenant A's userId happened to collide with
|
|
1691
|
+
* tenant B's crossdeckCustomerId, A's call would resolve to B's
|
|
1692
|
+
* cached entitlements. Bounded by the `cdcust_` prefix convention
|
|
1693
|
+
* (which both SDKs and the backend mint, see
|
|
1694
|
+
* backend/src/lib/customers.ts) — anything else is treated purely
|
|
1695
|
+
* as an alias lookup, never as a canonical id. Audit P1 #19.
|
|
1397
1696
|
*/
|
|
1398
1697
|
private resolveCacheCustomerId;
|
|
1399
1698
|
private identityPayload;
|
|
@@ -1411,4 +1710,4 @@ declare class CrossdeckServer extends EventEmitter {
|
|
|
1411
1710
|
private normalizeIngestEvent;
|
|
1412
1711
|
}
|
|
1413
1712
|
|
|
1414
|
-
export { type
|
|
1713
|
+
export { type StoredEntitlements as $, type AliasIdentityInput as A, type Breadcrumb as B, CROSSDECK_API_VERSION as C, DEFAULT_BASE_URL as D, type EntitlementCacheOptions as E, type ErrorLevel as F, type EventProperties as G, type ForgetResult as H, type GrantDuration as I, type GrantEntitlementInput as J, type GroupMembership as K, type HeartbeatResponse as L, type HttpRequestInfo as M, type HttpResponseInfo as N, type HttpRetriesConfig as O, type IdentifyOptions as P, type IdentityHints as Q, type IngestOptions as R, type IngestResponse as S, type PublicEntitlement as T, type PurchaseResult as U, type RequestOptions as V, type RevokeEntitlementInput as W, type RuntimeHost as X, type RuntimeInfo as Y, type ServerEvent as Z, type StackFrame as _, type AliasResult as a, type SyncPurchaseInput as a0, makeCrossdeckError as a1, type AuditDecision as b, type AuditEntry as c, type BreadcrumbCategory as d, type BreadcrumbLevel as e, type CapturedError as f, CrossdeckAuthenticationError as g, CrossdeckConfigurationError as h, CrossdeckError as i, type CrossdeckErrorPayload as j, type CrossdeckErrorType as k, CrossdeckInternalError as l, CrossdeckNetworkError as m, CrossdeckPermissionError as n, CrossdeckRateLimitError as o, CrossdeckServer as p, type CrossdeckServerOptions as q, CrossdeckValidationError as r, DEFAULT_TIMEOUT_MS as s, type Diagnostics as t, type EntitlementMutationResult as u, type EntitlementStore as v, type EntitlementsListResponse as w, type EntitlementsListener as x, type Environment as y, type ErrorCaptureConfig as z };
|