@cross-deck/node 1.2.0 → 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 +102 -0
- package/dist/auto-events/index.d.mts +1 -1
- package/dist/auto-events/index.d.ts +1 -1
- package/dist/{crossdeck-server-BZVZEuS-.d.mts → crossdeck-server-DhnHvUhh.d.mts} +37 -21
- package/dist/{crossdeck-server-BZVZEuS-.d.ts → crossdeck-server-DhnHvUhh.d.ts} +37 -21
- package/dist/index.cjs +145 -54
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +34 -7
- package/dist/index.d.ts +34 -7
- package/dist/index.mjs +145 -54
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,108 @@ 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
|
+
|
|
7
109
|
## [1.2.0] — 2026-05-18
|
|
8
110
|
|
|
9
111
|
### Added
|
|
@@ -251,8 +251,6 @@ interface RuntimeInfo {
|
|
|
251
251
|
appVersion: string | null;
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
-
declare const SDK_NAME = "@cross-deck/node";
|
|
255
|
-
declare const SDK_VERSION = "1.2.0";
|
|
256
254
|
declare const DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
|
|
257
255
|
declare const DEFAULT_TIMEOUT_MS = 15000;
|
|
258
256
|
/**
|
|
@@ -1211,20 +1209,11 @@ declare class CrossdeckServer extends EventEmitter {
|
|
|
1211
1209
|
private errorBeforeSend;
|
|
1212
1210
|
constructor(options: CrossdeckServerOptions);
|
|
1213
1211
|
/**
|
|
1214
|
-
* Emit the
|
|
1215
|
-
* is serverless
|
|
1216
|
-
*
|
|
1217
|
-
*
|
|
1218
|
-
*
|
|
1219
|
-
* carries no request body, so it cannot transport a structured
|
|
1220
|
-
* `durability` fact. The event pipeline can — every `track()` event
|
|
1221
|
-
* lands as an aggregatable document the backend can query, so
|
|
1222
|
-
* Crossdeck can compute fleet-wide "% serverless-with-no-durable-
|
|
1223
|
-
* store" from `sdk.boot` events (denominator = all `sdk.boot`,
|
|
1224
|
-
* numerator = those with `durability.coldStartDurable === false`).
|
|
1225
|
-
* The event rides the existing batched + retried + idempotent queue
|
|
1226
|
-
* and is drained by flush-on-exit, so it survives a serverless
|
|
1227
|
-
* teardown — it is NOT a local-only debug log.
|
|
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.
|
|
1228
1217
|
*
|
|
1229
1218
|
* `isServerless` AND no store is the gap: a cold start begins with an
|
|
1230
1219
|
* empty in-memory cache and a brief Crossdeck outage in that window
|
|
@@ -1232,11 +1221,29 @@ declare class CrossdeckServer extends EventEmitter {
|
|
|
1232
1221
|
* unavoidable without a store — so the SDK STATES it (a
|
|
1233
1222
|
* `sdk.no_durable_store` debug warning) rather than hiding it.
|
|
1234
1223
|
*
|
|
1235
|
-
*
|
|
1236
|
-
*
|
|
1237
|
-
* the
|
|
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.
|
|
1238
1245
|
*/
|
|
1239
|
-
private
|
|
1246
|
+
private emitBootTelemetryEvent;
|
|
1240
1247
|
identify(userId: string, anonymousId: string, options?: IdentifyOptions & RequestOptions): Promise<AliasResult>;
|
|
1241
1248
|
aliasIdentity(input: AliasIdentityInput, options?: RequestOptions): Promise<AliasResult>;
|
|
1242
1249
|
forget(hints: IdentityHints, options?: RequestOptions): Promise<ForgetResult>;
|
|
@@ -1677,6 +1684,15 @@ declare class CrossdeckServer extends EventEmitter {
|
|
|
1677
1684
|
* Resolve any hint shape (canonical customerId / userId hint /
|
|
1678
1685
|
* anonymousId hint / raw string) to a `crossdeckCustomerId` if we
|
|
1679
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.
|
|
1680
1696
|
*/
|
|
1681
1697
|
private resolveCacheCustomerId;
|
|
1682
1698
|
private identityPayload;
|
|
@@ -1694,4 +1710,4 @@ declare class CrossdeckServer extends EventEmitter {
|
|
|
1694
1710
|
private normalizeIngestEvent;
|
|
1695
1711
|
}
|
|
1696
1712
|
|
|
1697
|
-
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 };
|
|
@@ -251,8 +251,6 @@ interface RuntimeInfo {
|
|
|
251
251
|
appVersion: string | null;
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
-
declare const SDK_NAME = "@cross-deck/node";
|
|
255
|
-
declare const SDK_VERSION = "1.2.0";
|
|
256
254
|
declare const DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
|
|
257
255
|
declare const DEFAULT_TIMEOUT_MS = 15000;
|
|
258
256
|
/**
|
|
@@ -1211,20 +1209,11 @@ declare class CrossdeckServer extends EventEmitter {
|
|
|
1211
1209
|
private errorBeforeSend;
|
|
1212
1210
|
constructor(options: CrossdeckServerOptions);
|
|
1213
1211
|
/**
|
|
1214
|
-
* Emit the
|
|
1215
|
-
* is serverless
|
|
1216
|
-
*
|
|
1217
|
-
*
|
|
1218
|
-
*
|
|
1219
|
-
* carries no request body, so it cannot transport a structured
|
|
1220
|
-
* `durability` fact. The event pipeline can — every `track()` event
|
|
1221
|
-
* lands as an aggregatable document the backend can query, so
|
|
1222
|
-
* Crossdeck can compute fleet-wide "% serverless-with-no-durable-
|
|
1223
|
-
* store" from `sdk.boot` events (denominator = all `sdk.boot`,
|
|
1224
|
-
* numerator = those with `durability.coldStartDurable === false`).
|
|
1225
|
-
* The event rides the existing batched + retried + idempotent queue
|
|
1226
|
-
* and is drained by flush-on-exit, so it survives a serverless
|
|
1227
|
-
* teardown — it is NOT a local-only debug log.
|
|
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.
|
|
1228
1217
|
*
|
|
1229
1218
|
* `isServerless` AND no store is the gap: a cold start begins with an
|
|
1230
1219
|
* empty in-memory cache and a brief Crossdeck outage in that window
|
|
@@ -1232,11 +1221,29 @@ declare class CrossdeckServer extends EventEmitter {
|
|
|
1232
1221
|
* unavoidable without a store — so the SDK STATES it (a
|
|
1233
1222
|
* `sdk.no_durable_store` debug warning) rather than hiding it.
|
|
1234
1223
|
*
|
|
1235
|
-
*
|
|
1236
|
-
*
|
|
1237
|
-
* the
|
|
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.
|
|
1238
1245
|
*/
|
|
1239
|
-
private
|
|
1246
|
+
private emitBootTelemetryEvent;
|
|
1240
1247
|
identify(userId: string, anonymousId: string, options?: IdentifyOptions & RequestOptions): Promise<AliasResult>;
|
|
1241
1248
|
aliasIdentity(input: AliasIdentityInput, options?: RequestOptions): Promise<AliasResult>;
|
|
1242
1249
|
forget(hints: IdentityHints, options?: RequestOptions): Promise<ForgetResult>;
|
|
@@ -1677,6 +1684,15 @@ declare class CrossdeckServer extends EventEmitter {
|
|
|
1677
1684
|
* Resolve any hint shape (canonical customerId / userId hint /
|
|
1678
1685
|
* anonymousId hint / raw string) to a `crossdeckCustomerId` if we
|
|
1679
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.
|
|
1680
1696
|
*/
|
|
1681
1697
|
private resolveCacheCustomerId;
|
|
1682
1698
|
private identityPayload;
|
|
@@ -1694,4 +1710,4 @@ declare class CrossdeckServer extends EventEmitter {
|
|
|
1694
1710
|
private normalizeIngestEvent;
|
|
1695
1711
|
}
|
|
1696
1712
|
|
|
1697
|
-
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 };
|
package/dist/index.cjs
CHANGED
|
@@ -363,9 +363,11 @@ function byteLength(s) {
|
|
|
363
363
|
return s.length * 4;
|
|
364
364
|
}
|
|
365
365
|
|
|
366
|
-
// src/
|
|
366
|
+
// src/_version.ts
|
|
367
|
+
var SDK_VERSION = "1.3.1";
|
|
367
368
|
var SDK_NAME = "@cross-deck/node";
|
|
368
|
-
|
|
369
|
+
|
|
370
|
+
// src/http.ts
|
|
369
371
|
var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
|
|
370
372
|
var DEFAULT_TIMEOUT_MS = 15e3;
|
|
371
373
|
var CROSSDECK_API_VERSION = "2025-01-01";
|
|
@@ -878,6 +880,7 @@ var EventQueue = class {
|
|
|
878
880
|
sdk: env.sdk
|
|
879
881
|
};
|
|
880
882
|
if (env.appId) body.appId = env.appId;
|
|
883
|
+
if (env.environment) body.environment = env.environment;
|
|
881
884
|
const result = await this.cfg.http.request("POST", "/events", {
|
|
882
885
|
body,
|
|
883
886
|
idempotencyKey: batchId
|
|
@@ -896,6 +899,20 @@ var EventQueue = class {
|
|
|
896
899
|
} catch (err) {
|
|
897
900
|
const message = err instanceof Error ? err.message : String(err);
|
|
898
901
|
this.lastError = message;
|
|
902
|
+
if (isPermanent4xx(err)) {
|
|
903
|
+
const droppedCount = batch.length;
|
|
904
|
+
this.pendingBatch = null;
|
|
905
|
+
this.pendingBatchId = null;
|
|
906
|
+
this.inFlight -= droppedCount;
|
|
907
|
+
this.dropped += droppedCount;
|
|
908
|
+
this.cfg.onDrop?.(droppedCount);
|
|
909
|
+
this.cfg.onPermanentFailure?.({
|
|
910
|
+
status: err.status ?? 0,
|
|
911
|
+
droppedCount,
|
|
912
|
+
lastError: message
|
|
913
|
+
});
|
|
914
|
+
return null;
|
|
915
|
+
}
|
|
899
916
|
const retryAfterMs = extractRetryAfterMs(err);
|
|
900
917
|
const delay = this.retry.nextDelay(retryAfterMs);
|
|
901
918
|
this.scheduleRetry(delay);
|
|
@@ -974,6 +991,14 @@ function extractRetryAfterMs(err) {
|
|
|
974
991
|
}
|
|
975
992
|
return void 0;
|
|
976
993
|
}
|
|
994
|
+
function isPermanent4xx(err) {
|
|
995
|
+
if (!err || typeof err !== "object") return false;
|
|
996
|
+
const status = err.status;
|
|
997
|
+
if (typeof status !== "number" || !Number.isFinite(status)) return false;
|
|
998
|
+
if (status < 400 || status >= 500) return false;
|
|
999
|
+
if (status === 408 || status === 429) return false;
|
|
1000
|
+
return true;
|
|
1001
|
+
}
|
|
977
1002
|
function defaultScheduler(fn, ms) {
|
|
978
1003
|
const id = setTimeout(fn, ms);
|
|
979
1004
|
if (typeof id.unref === "function") {
|
|
@@ -1263,16 +1288,18 @@ var ErrorTracker = class {
|
|
|
1263
1288
|
const url = typeof input === "string" ? input : input?.url ?? "";
|
|
1264
1289
|
const method = (init.method || "GET").toUpperCase();
|
|
1265
1290
|
const start = Date.now();
|
|
1266
|
-
tracker.opts.
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1291
|
+
if (!isSelfRequest(url, tracker.opts.selfHostname)) {
|
|
1292
|
+
tracker.opts.breadcrumbs.add({
|
|
1293
|
+
timestamp: start,
|
|
1294
|
+
category: "http",
|
|
1295
|
+
message: `${method} ${url}`,
|
|
1296
|
+
data: { url, method }
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1272
1299
|
try {
|
|
1273
1300
|
const response = await origFetch(...args);
|
|
1274
1301
|
if (response.status >= 500 && tracker.opts.isConsented()) {
|
|
1275
|
-
if (!url.
|
|
1302
|
+
if (!isSelfRequest(url, tracker.opts.selfHostname)) {
|
|
1276
1303
|
tracker.captureHttp({
|
|
1277
1304
|
url,
|
|
1278
1305
|
method,
|
|
@@ -1390,9 +1417,10 @@ var ErrorTracker = class {
|
|
|
1390
1417
|
if (!this.passesSample(err)) return;
|
|
1391
1418
|
if (!this.passesRateLimit(err)) return;
|
|
1392
1419
|
let finalErr = err;
|
|
1393
|
-
|
|
1420
|
+
const hook = this.opts.beforeSend?.();
|
|
1421
|
+
if (hook) {
|
|
1394
1422
|
try {
|
|
1395
|
-
finalErr =
|
|
1423
|
+
finalErr = hook(err);
|
|
1396
1424
|
} catch {
|
|
1397
1425
|
finalErr = err;
|
|
1398
1426
|
}
|
|
@@ -1618,6 +1646,22 @@ function safeClone(v) {
|
|
|
1618
1646
|
function safeStringify3(v) {
|
|
1619
1647
|
return coerceErrorPayload(v).message;
|
|
1620
1648
|
}
|
|
1649
|
+
function extractSelfHostname(baseUrl) {
|
|
1650
|
+
if (!baseUrl || typeof baseUrl !== "string") return null;
|
|
1651
|
+
try {
|
|
1652
|
+
return new URL(baseUrl).hostname.toLowerCase();
|
|
1653
|
+
} catch {
|
|
1654
|
+
return null;
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
function isSelfRequest(requestUrl, selfHostname) {
|
|
1658
|
+
if (!selfHostname || !requestUrl) return false;
|
|
1659
|
+
try {
|
|
1660
|
+
return new URL(requestUrl).hostname.toLowerCase() === selfHostname;
|
|
1661
|
+
} catch {
|
|
1662
|
+
return false;
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1621
1665
|
|
|
1622
1666
|
// src/runtime-info.ts
|
|
1623
1667
|
var import_node_os = require("os");
|
|
@@ -2512,6 +2556,11 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
2512
2556
|
intervalMs: options.eventFlushIntervalMs ?? 1500,
|
|
2513
2557
|
envelope: () => ({
|
|
2514
2558
|
appId: this.appId,
|
|
2559
|
+
// Ship env on every batch so the backend can cross-check
|
|
2560
|
+
// against the API-key-derived env and reject mismatches
|
|
2561
|
+
// loudly (env_mismatch). Web has always done this; node now
|
|
2562
|
+
// matches so defence-in-depth is symmetric across SDKs.
|
|
2563
|
+
environment: this.env,
|
|
2515
2564
|
sdk: { name: SDK_NAME, version: this.sdkVersion }
|
|
2516
2565
|
}),
|
|
2517
2566
|
onDrop: (count) => {
|
|
@@ -2527,6 +2576,20 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
2527
2576
|
nextRetryMs: info.delayMs
|
|
2528
2577
|
});
|
|
2529
2578
|
},
|
|
2579
|
+
onPermanentFailure: (info) => {
|
|
2580
|
+
const headline = `[crossdeck] Event batch DROPPED (status ${info.status}): ${info.lastError}. ${info.droppedCount} event(s) lost \u2014 check your secret key + app config.`;
|
|
2581
|
+
console.error(headline);
|
|
2582
|
+
this.debug.emit(
|
|
2583
|
+
"sdk.flush_permanent_failure",
|
|
2584
|
+
headline,
|
|
2585
|
+
{ ...info }
|
|
2586
|
+
);
|
|
2587
|
+
this.emit("queue.permanent_failure", {
|
|
2588
|
+
status: info.status,
|
|
2589
|
+
droppedCount: info.droppedCount,
|
|
2590
|
+
error: info.lastError
|
|
2591
|
+
});
|
|
2592
|
+
},
|
|
2530
2593
|
onFirstFlushSuccess: () => {
|
|
2531
2594
|
this.debug.emit("sdk.first_event_sent", "First batch landed.");
|
|
2532
2595
|
}
|
|
@@ -2541,14 +2604,20 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
2541
2604
|
report: (err) => this.reportCapturedError(err),
|
|
2542
2605
|
getContext: () => ({ ...this.errorContext }),
|
|
2543
2606
|
getTags: () => ({ ...this.errorTags }),
|
|
2544
|
-
|
|
2545
|
-
//
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2607
|
+
// GETTER, not a captured value — `setErrorBeforeSend()` mutates
|
|
2608
|
+
// `this.errorBeforeSend` after init() and the tracker MUST pick
|
|
2609
|
+
// up the new hook on the next error. Pre-fix we worked around
|
|
2610
|
+
// a captured-by-value field with `Object.defineProperty` on the
|
|
2611
|
+
// tracker's private opts; the contract is now a real getter so
|
|
2612
|
+
// we just hand it the closure and the hack is gone.
|
|
2613
|
+
beforeSend: () => this.errorBeforeSend,
|
|
2614
|
+
isConsented: () => true,
|
|
2615
|
+
// Derived from the configured baseUrl at construction time.
|
|
2616
|
+
// Used by the fetch wrapper to skip captureHttp on Crossdeck's
|
|
2617
|
+
// own requests — pre-fix the skip was hardcoded to
|
|
2618
|
+
// `api.cross-deck.com` and broke for customers on staging /
|
|
2619
|
+
// regional / self-hosted base URLs (recursive capture loop).
|
|
2620
|
+
selfHostname: extractSelfHostname(this.baseUrl)
|
|
2552
2621
|
});
|
|
2553
2622
|
this.errorTracker.install();
|
|
2554
2623
|
}
|
|
@@ -2561,6 +2630,7 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
2561
2630
|
});
|
|
2562
2631
|
this.flushOnExit.install();
|
|
2563
2632
|
}
|
|
2633
|
+
this.emitDurabilityWarning();
|
|
2564
2634
|
if (options.testMode !== true && options.bootHeartbeat !== false) {
|
|
2565
2635
|
setImmediate(() => {
|
|
2566
2636
|
void this.heartbeat().catch((err) => {
|
|
@@ -2570,25 +2640,16 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
2570
2640
|
{ message: err instanceof Error ? err.message : String(err) }
|
|
2571
2641
|
);
|
|
2572
2642
|
});
|
|
2573
|
-
this.
|
|
2643
|
+
this.emitBootTelemetryEvent();
|
|
2574
2644
|
});
|
|
2575
2645
|
}
|
|
2576
2646
|
}
|
|
2577
2647
|
/**
|
|
2578
|
-
* Emit the
|
|
2579
|
-
* is serverless
|
|
2580
|
-
*
|
|
2581
|
-
*
|
|
2582
|
-
*
|
|
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.
|
|
2648
|
+
* Emit the honest "no cold-start durability" warning when the runtime
|
|
2649
|
+
* is serverless AND no `entitlementStore` is wired. Local-only debug
|
|
2650
|
+
* signal — no network call, no phone-home. Safe to fire from the
|
|
2651
|
+
* constructor before `setImmediate` because there is no I/O on this
|
|
2652
|
+
* path.
|
|
2592
2653
|
*
|
|
2593
2654
|
* `isServerless` AND no store is the gap: a cold start begins with an
|
|
2594
2655
|
* empty in-memory cache and a brief Crossdeck outage in that window
|
|
@@ -2596,14 +2657,15 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
2596
2657
|
* unavoidable without a store — so the SDK STATES it (a
|
|
2597
2658
|
* `sdk.no_durable_store` debug warning) rather than hiding it.
|
|
2598
2659
|
*
|
|
2599
|
-
*
|
|
2600
|
-
*
|
|
2601
|
-
* the
|
|
2660
|
+
* Audit P1 #9: this used to live INSIDE `emitBootTelemetry()` which
|
|
2661
|
+
* itself sat inside the `bootHeartbeat` gate, so any developer who
|
|
2662
|
+
* set `bootHeartbeat: false` silently disabled the entire reason
|
|
2663
|
+
* `entitlementStore` exists. Now split: warning fires
|
|
2664
|
+
* unconditionally; the boot phone-home stays gated.
|
|
2602
2665
|
*/
|
|
2603
|
-
|
|
2666
|
+
emitDurabilityWarning() {
|
|
2604
2667
|
const isServerless = this.runtime.isServerless;
|
|
2605
2668
|
const hasStore = this.entitlementStore !== null;
|
|
2606
|
-
const coldStartDurable = hasStore || !isServerless;
|
|
2607
2669
|
if (isServerless && !hasStore) {
|
|
2608
2670
|
this.debug.emit(
|
|
2609
2671
|
"sdk.no_durable_store",
|
|
@@ -2611,6 +2673,26 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
2611
2673
|
{ host: this.runtime.host, isServerless, durableStore: false }
|
|
2612
2674
|
);
|
|
2613
2675
|
}
|
|
2676
|
+
}
|
|
2677
|
+
/**
|
|
2678
|
+
* Emit the one-time `sdk.boot` telemetry event — the aggregatable
|
|
2679
|
+
* fact the backend pivots on (compute fleet-wide
|
|
2680
|
+
* "% serverless-with-no-durable-store"). Rides the batched + retried
|
|
2681
|
+
* + idempotent queue and is drained by flush-on-exit, so it survives
|
|
2682
|
+
* a serverless teardown.
|
|
2683
|
+
*
|
|
2684
|
+
* Why a `track()` event and not the heartbeat: `GET /v1/sdk/heartbeat`
|
|
2685
|
+
* carries no request body, so it cannot transport a structured
|
|
2686
|
+
* `durability` fact.
|
|
2687
|
+
*
|
|
2688
|
+
* Gated by `bootHeartbeat` (and `testMode`) because it IS a phone-
|
|
2689
|
+
* home — the unconditional surface is `emitDurabilityWarning()`,
|
|
2690
|
+
* which has no network call.
|
|
2691
|
+
*/
|
|
2692
|
+
emitBootTelemetryEvent() {
|
|
2693
|
+
const isServerless = this.runtime.isServerless;
|
|
2694
|
+
const hasStore = this.entitlementStore !== null;
|
|
2695
|
+
const coldStartDurable = hasStore || !isServerless;
|
|
2614
2696
|
try {
|
|
2615
2697
|
this.track({
|
|
2616
2698
|
name: "sdk.boot",
|
|
@@ -2922,7 +3004,13 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
2922
3004
|
const normalized = events.map((event) => this.normalizeIngestEvent(event));
|
|
2923
3005
|
const body = {
|
|
2924
3006
|
events: normalized,
|
|
2925
|
-
sdk: { name: SDK_NAME, version: this.sdkVersion }
|
|
3007
|
+
sdk: { name: SDK_NAME, version: this.sdkVersion },
|
|
3008
|
+
// Match the queue's batch envelope (see event-queue.ts) — backend
|
|
3009
|
+
// cross-checks `environment` against the API-key-derived env and
|
|
3010
|
+
// rejects mismatches loudly (env_mismatch). Pre-fix this direct
|
|
3011
|
+
// ingest path skipped env, so a "live key, env: sandbox"
|
|
3012
|
+
// misconfig fell through silently for the bulk-import path.
|
|
3013
|
+
environment: this.env
|
|
2926
3014
|
};
|
|
2927
3015
|
if (this.appId) body.appId = this.appId;
|
|
2928
3016
|
return this.http.request("POST", "/events", {
|
|
@@ -2987,8 +3075,9 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
2987
3075
|
message: "syncPurchases requires a signedTransactionInfo string."
|
|
2988
3076
|
});
|
|
2989
3077
|
}
|
|
3078
|
+
const rail = input.rail ?? "apple";
|
|
2990
3079
|
return this.http.request("POST", "/purchases/sync", {
|
|
2991
|
-
body: {
|
|
3080
|
+
body: { ...input, rail },
|
|
2992
3081
|
signal: options?.signal,
|
|
2993
3082
|
timeoutMs: options?.timeoutMs
|
|
2994
3083
|
});
|
|
@@ -3583,10 +3672,21 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
3583
3672
|
* Resolve any hint shape (canonical customerId / userId hint /
|
|
3584
3673
|
* anonymousId hint / raw string) to a `crossdeckCustomerId` if we
|
|
3585
3674
|
* have a cache entry for it.
|
|
3675
|
+
*
|
|
3676
|
+
* String overload is STRICT on the canonical-id shape. Pre-fix
|
|
3677
|
+
* `isFresh(raw)` treated any string with a cache entry as a valid
|
|
3678
|
+
* canonical id — if tenant A's userId happened to collide with
|
|
3679
|
+
* tenant B's crossdeckCustomerId, A's call would resolve to B's
|
|
3680
|
+
* cached entitlements. Bounded by the `cdcust_` prefix convention
|
|
3681
|
+
* (which both SDKs and the backend mint, see
|
|
3682
|
+
* backend/src/lib/customers.ts) — anything else is treated purely
|
|
3683
|
+
* as an alias lookup, never as a canonical id. Audit P1 #19.
|
|
3586
3684
|
*/
|
|
3587
3685
|
resolveCacheCustomerId(hint) {
|
|
3588
3686
|
if (typeof hint === "string") {
|
|
3589
|
-
if (this.entitlementCache.isFresh(hint))
|
|
3687
|
+
if (hint.startsWith("cdcust_") && this.entitlementCache.isFresh(hint)) {
|
|
3688
|
+
return hint;
|
|
3689
|
+
}
|
|
3590
3690
|
return this.customerIdAliases.get(hint) ?? null;
|
|
3591
3691
|
}
|
|
3592
3692
|
if (hint.customerId) return hint.customerId;
|
|
@@ -3962,20 +4062,11 @@ function normaliseSecrets(input) {
|
|
|
3962
4062
|
// src/consent.ts
|
|
3963
4063
|
var EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
|
|
3964
4064
|
var CARD_PATTERN = /\b\d(?:[ -]?\d){12,18}\b/g;
|
|
3965
|
-
var REPLACEMENT_EMAIL = "
|
|
3966
|
-
var REPLACEMENT_CARD = "
|
|
4065
|
+
var REPLACEMENT_EMAIL = "<email>";
|
|
4066
|
+
var REPLACEMENT_CARD = "<card>";
|
|
3967
4067
|
function scrubPii(value) {
|
|
3968
4068
|
if (!value) return value;
|
|
3969
|
-
|
|
3970
|
-
if (EMAIL_PATTERN.test(out)) {
|
|
3971
|
-
out = out.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL);
|
|
3972
|
-
}
|
|
3973
|
-
EMAIL_PATTERN.lastIndex = 0;
|
|
3974
|
-
if (CARD_PATTERN.test(out)) {
|
|
3975
|
-
out = out.replace(CARD_PATTERN, REPLACEMENT_CARD);
|
|
3976
|
-
}
|
|
3977
|
-
CARD_PATTERN.lastIndex = 0;
|
|
3978
|
-
return out;
|
|
4069
|
+
return value.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL).replace(CARD_PATTERN, REPLACEMENT_CARD);
|
|
3979
4070
|
}
|
|
3980
4071
|
function scrubPiiFromProperties(properties) {
|
|
3981
4072
|
const out = {};
|