@cross-deck/node
v1.7.0
Published
Crossdeck server SDK for Node.js — verified subscriptions, entitlements, server-side error capture, and product telemetry in one client.
Maintainers
Readme
@cross-deck/node
The Crossdeck server SDK for Node.js — one install, three pillars: errors, analytics, entitlements.
npm install @cross-deck/nodeQuick start
import { CrossdeckServer } from "@cross-deck/node";
const crossdeck = new CrossdeckServer({
secretKey: process.env.CROSSDECK_SECRET_KEY!,
appId: "app_node_xxxxxxxxxxxx",
// env is inferred from the key prefix: cd_sk_test_… → sandbox, cd_sk_live_… → production
});
// Optional: validate the key at boot (recommended for serverless cold-starts)
await crossdeck.heartbeat();
// USP 1 — manual error capture
try {
await processOrder(orderId);
} catch (err) {
crossdeck.captureError(err, { context: { orderId } });
throw err;
}
// USP 2 — analytics
crossdeck.track({
name: "checkout.completed",
developerUserId: "user_847",
properties: { plan: "pro", revenue: 9_900 },
});
// USP 3 — entitlement gating (synchronous after first warm)
await crossdeck.getEntitlements({ userId: "user_847" });
if (crossdeck.isEntitled({ userId: "user_847" }, "pro")) {
// grant access
}Three USPs, one SDK
USP 1 — Errors
Auto-wired by default: process.on('uncaughtException'), process.on('unhandledRejection'), and globalThis.fetch wrap (5xx + network failures). Plus the full manual surface:
// Manual capture from try/catch
crossdeck.captureError(err, {
context: { jobId },
tags: { flow: "checkout" },
level: "error", // "error" | "warning" | "info"
});
// Non-error signals (Sentry pattern)
crossdeck.captureMessage("deprecated path hit", "warning");
// Pin tags + context to all subsequent errors
crossdeck.setTag("release", process.env.K_REVISION);
crossdeck.setContext("region", { az: "us-east-1a" });
// Add breadcrumbs (last 50 attached to every error report)
crossdeck.addBreadcrumb({
timestamp: Date.now(),
category: "custom",
message: "user.opened_paywall",
});
// Pre-send hook for app-specific PII scrubbing
crossdeck.setErrorBeforeSend((err) => {
if (err.message.includes("auth-token=")) return null;
return err;
});Stack frames are parsed (V8 + Firefox/Safari formats), fingerprinted via djb2 over message + top-3 in-app frames, attached with the breadcrumb buffer + your context + tags. Rate-limited per fingerprint (default 5/min), session-capped (default 100/process). Frames inside node_modules/, node:, internal/, or @cross-deck/node are marked not-in-app and excluded from fingerprints.
To opt out (e.g. if you have a separate error tracker):
new CrossdeckServer({ secretKey, errorCapture: false });USP 2 — Analytics
track() enqueues synchronously into a durable retry-with-jitter queue with per-batch Idempotency-Key reuse on retry. Flush-on-exit drains before the process terminates — critical for Cloud Functions / Lambda where the runtime freezes the process and any pending events would otherwise vanish.
crossdeck.track({
name: "paywall_shown",
developerUserId: "user_847",
properties: { variant: "v3" },
});
// Super-properties (Mixpanel pattern) — carried on every subsequent event
crossdeck.register({ serviceVersion: process.env.K_REVISION });
crossdeck.unregister("oldField");
// Group analytics — attach $groups.<type> for B2B dashboard pivots
crossdeck.group("org", "acme_inc");
crossdeck.group("team", "design", { headcount: 12 });
// Bulk imports — synchronous POST, returns IngestResponse
await crossdeck.ingest([
{ name: "job.completed", crossdeckCustomerId: "cdcust_x", properties: { durationMs: 1200 } },
{ name: "job.completed", crossdeckCustomerId: "cdcust_y", properties: { durationMs: 950 } },
]);
// Drain the queue (call at end of Lambda/CF invocations)
await crossdeck.flush();Multi-tenant servers:
register()is process-scoped, not per-request. In a single Node process handling requests for many tenants, registering{ tenant: "acme" }taints every subsequent event from that process — including ones serving other tenants. For per-request properties, pass them on thetrack()call itself.
Framework adapters (@cross-deck/node/auto-events)
Plug Crossdeck into your existing framework with a single middleware/wrap call. Auto-emits request.handled / function.invoked / function.completed / function.failed events, captures uncaught errors with request context, and (on Lambda + Firebase) awaits flush() before the handler returns.
import {
crossdeckExpress,
crossdeckExpressErrorHandler,
wrapLambdaHandler,
wrapFunction,
} from "@cross-deck/node/auto-events";
// Express 4 + 5
app.use(crossdeckExpress(crossdeck, {
getIdentity: (req) => ({ developerUserId: req.user?.id }),
}));
// ... routes ...
app.use(crossdeckExpressErrorHandler(crossdeck)); // register LAST
// AWS Lambda + Vercel Functions (which run on Lambda underneath)
export const handler = wrapLambdaHandler(crossdeck, async (event, ctx) => {
return { statusCode: 200, body: "ok" };
});
// Firebase Functions v1 + v2, Cloud Run (generic shape-preserving wrap)
export const myFunction = onRequest(
wrapFunction(crossdeck, async (req, res) => {
res.send("ok");
}),
);USP 3 — Entitlements
Per-customer TTL cache (default 60s). Hot-path entitlement gates become synchronous memory reads after the first warm. Bounded by maxCustomers (default 10,000) with LRU eviction for long-running multi-tenant servers.
// Warm the cache (records userId → customerId alias)
await crossdeck.getEntitlements({ userId: "user_847" });
// Synchronous gate — memory read within TTL, no HTTP
if (crossdeck.isEntitled({ userId: "user_847" }, "pro")) {
// grant access
}
// Full snapshot for callers needing source / validUntil
const ents = crossdeck.listEntitlements({ userId: "user_847" });
// Subscribe to cache mutations (e.g. push to connected clients)
const unsubscribe = crossdeck.onEntitlementsChange((customerId, ents) => {
// ...
});
// Server-side manual overrides
await crossdeck.grantEntitlement({
customerId: "cdcust_123",
entitlementKey: "pro",
duration: "P30D",
reason: "Support recovery after billing incident",
});
await crossdeck.revokeEntitlement({
customerId: "cdcust_123",
entitlementKey: "pro",
reason: "Chargeback",
});Webhook signature verification
Stripe-compatible HMAC-SHA256 with constant-time comparison + replay window. Supports multi-secret rotation.
import { verifyWebhookSignature } from "@cross-deck/node";
import express from "express";
app.post("/crossdeck-webhook", express.raw({ type: "application/json" }), (req, res) => {
try {
const event = verifyWebhookSignature(
req.body.toString("utf8"),
req.headers["crossdeck-signature"],
[process.env.CROSSDECK_WEBHOOK_SECRET, process.env.CROSSDECK_WEBHOOK_SECRET_OLD],
// 5-min default replay window
);
handleCrossdeckEvent(event);
res.sendStatus(200);
} catch (err) {
res.sendStatus(401);
}
});For test fixtures that need to mint signed webhooks against the same scheme, signWebhookPayload(payload, secret, timestampSec) is exported.
Cross-cutting
Runtime info
Auto-detected at construction. Attached to every event + error as runtime.* properties:
| Detected platform | Trigger env var | Surfaces as runtime.host |
|---|---|---|
| AWS Lambda + Vercel Functions | AWS_LAMBDA_FUNCTION_NAME | aws-lambda |
| Azure Functions | FUNCTIONS_WORKER_RUNTIME + WEBSITE_INSTANCE_ID | azure-functions |
| Google App Engine | GAE_APPLICATION | google-app-engine |
| Firebase Functions v2 / Cloud Functions Gen 2 | K_SERVICE + FIREBASE_CONFIG | firebase-functions-v2 |
| Firebase Functions v1 | FUNCTION_NAME + FUNCTION_REGION | firebase-functions-v1 |
| Google Cloud Run | K_SERVICE + K_REVISION (no Firebase) | cloud-run |
| Vercel | VERCEL === "1" | vercel |
| Netlify Functions | NETLIFY === "true" | netlify |
| Heroku | DYNO | heroku |
| Render | RENDER === "true" | render |
| Railway | RAILWAY_ENVIRONMENT | railway |
| Fly.io | FLY_APP_NAME | fly |
| Generic Kubernetes | KUBERNETES_SERVICE_HOST | kubernetes |
| Plain Node | (fallback) | node |
Every detected platform exposes serviceName, serviceVersion, region, instanceId where available. Override via constructor:
new CrossdeckServer({
secretKey,
serviceName: "my-fn",
serviceVersion: process.env.K_REVISION,
appVersion: "1.2.3", // attached to events as `appVersion`
});Diagnostics
const d = crossdeck.diagnostics();
// {
// sdkVersion, baseUrl, secretKeyPrefix (masked), env,
// runtime: { nodeVersion, platform, host, region, serviceName, ... },
// events: { buffered, dropped, inFlight, consecutiveFailures, ... },
// errors: { sessionCount, fingerprintsTracked, handlersInstalled },
// entitlements: { count, ttlMs, lastUpdated, listenerErrors },
// }Useful for /health and /metrics endpoints exposed to your platform.
Debug mode
new CrossdeckServer({ secretKey, debug: true });Emits NorthStar §16 debug signals to console.info:
sdk.configured— boot confirmationsdk.first_event_sent— proves wire connectivitysdk.flush_retry_scheduled— surfaces flush failures + retry delaysdk.flush_on_exit_started/sdk.flush_on_exit_completed— drain lifecyclesdk.entitlement_cache_warm/sdk.entitlement_cache_used— cache observabilitysdk.webhook_verified— signature verification confirmationsdk.sensitive_property_warning— flagged property names ontrack()sdk.runtime_detected— host platform detection
PII scrub utility
Opt-in regex-based scrub for email + card-number-shaped substrings. Use before forwarding caller-supplied properties:
import { scrubPiiFromProperties } from "@cross-deck/node";
crossdeck.track({
name: "checkout.failed",
developerUserId,
properties: scrubPiiFromProperties({
url: req.url, // /users/[email protected]/ → /users/[email]/
failedCardLast4: payload.card_number, // 4242 4242 4242 4242 → [card]
}),
});Configuration
All options on new CrossdeckServer({...}):
{
secretKey: string; // required — `cd_sk_test_…` (sandbox) | `cd_sk_live_…` (production)
baseUrl?: string; // default "https://api.cross-deck.com/v1"
timeoutMs?: number; // default 15_000, 0 disables
appId?: string; // optional metadata on event envelope
sdkVersion?: string; // override the version reported on the wire
// USP 1
errorCapture?: boolean | Partial<ErrorCaptureConfig>;
// false to disable; partial object to override specific hooks
// (onUncaughtException, onUnhandledRejection, wrapFetch, etc.)
// USP 2
eventFlushBatchSize?: number; // default 20
eventFlushIntervalMs?: number;// default 1500
flushOnExit?: boolean; // default true — beforeExit + SIGTERM + SIGINT drain
flushOnExitTimeoutMs?: number;// default 2000
// USP 3
entitlementCacheTtlMs?: number; // default 60_000, 0 disables
// Cross-cutting
serviceName?: string; // overrides env-detected
serviceVersion?: string; // overrides env-detected
appVersion?: string; // attached as `appVersion` on events
debug?: boolean; // default false
breadcrumbsMaxSize?: number; // default 50
// Bank-grade SDK extras (QA-review v2)
testMode?: boolean; // default false — short-circuits HTTP to synthetic responses
onRequest?: (info) => void; // fires on every request (incl. retries)
onResponse?: (info) => void; // fires on every response
httpRetries?: { // idempotent GET retry policy
maxAttempts?: number; // default 3 (1 initial + 2 retries)
retryableStatuses?: number[]; // default [408, 500, 502, 503, 504]
};
runtimeToken?: string; // override the User-Agent runtime token
}Error model
Stripe-style subclass hierarchy. Use instanceof for typed narrowing in your catch blocks.
import {
CrossdeckError,
CrossdeckAuthenticationError,
CrossdeckRateLimitError,
CrossdeckNetworkError,
isCrossdeckErrorCode,
} from "@cross-deck/node";
try {
await crossdeck.heartbeat();
} catch (err) {
if (err instanceof CrossdeckAuthenticationError) {
// 401 path — bad/revoked secret key, or bad webhook signature
} else if (err instanceof CrossdeckRateLimitError) {
// 429 — back off for err.retryAfterMs
} else if (err instanceof CrossdeckNetworkError) {
// fetch failed / aborted / timed out — likely transient
} else if (err instanceof CrossdeckError) {
if (isCrossdeckErrorCode(err.code) && err.code === "invalid_secret_key") {
// narrowed to the catalogue's literal union
}
console.error(err.type, err.code, err.requestId);
}
}Subclasses: CrossdeckAuthenticationError, CrossdeckPermissionError, CrossdeckValidationError, CrossdeckRateLimitError, CrossdeckNetworkError, CrossdeckInternalError, CrossdeckConfigurationError. All extend CrossdeckError. Constructed automatically by the SDK — you never need to instantiate them yourself.
CrossdeckErrorCode is the literal union of every documented code in CROSSDECK_ERROR_CODES. Use isCrossdeckErrorCode to narrow string to the union for type-safe comparisons (catches misspelled codes at compile time).
err.toJSON() is implemented — your structured logger sees type, code, requestId, status, retryAfterMs, and stack instead of just name + message:
logger.error({ err }, "crossdeck request failed");
// → { err: { name: "CrossdeckRateLimitError", type: "rate_limit_error",
// code: "too_many_requests", retryAfterMs: 30000, ... } }Every entry in CROSSDECK_ERROR_CODES carries { code, type, description, resolution, retryable } — render-able in dashboards and AI assistants.
Reliability + lifecycle
Idempotent GET retry
Read methods (getEntitlements, getCustomerEntitlements, getAuditEntry, heartbeat) automatically retry on 408 + 5xx (except 501) and on network failures. Default 3 attempts with exponential backoff + full jitter. Honours server Retry-After. Configurable per-instance:
new CrossdeckServer({
secretKey,
httpRetries: { maxAttempts: 5 }, // up to 5 attempts
});POST methods (track/ingest/syncPurchases/grantEntitlement/revokeEntitlement) DO NOT auto-retry at the HTTP layer. Retries happen via the event queue with per-batch Idempotency-Key reuse — the server can dedupe replays.
v1.4.0 — syncPurchases deterministic key. The Idempotency-Key
on syncPurchases is derived from the request body (UUID-shaped
SHA-256 of crossdeck:purchases/sync:<rail>:<jws|token>). Two retries
of the same Apple transaction land on the same key, so the backend
short-circuits with idempotent_replay: true instead of
double-processing. Override via options.idempotencyKey only when
an outer orchestrator needs a different idempotency window.
AbortSignal — caller-controlled cancellation
Every async method accepts a final RequestOptions? with { signal, timeoutMs }:
const ctrl = new AbortController();
const flight = crossdeck.heartbeat({ signal: ctrl.signal });
setTimeout(() => ctrl.abort(), 100);
try {
await flight;
} catch (err) {
if (err instanceof CrossdeckNetworkError && err.code === "request_aborted") {
// caller-cancelled
}
}PII scrubber (v1.4.0 — parity with Web/RN/Swift)
Every track() payload runs through scrubPiiFromProperties
before enqueue — email-shaped and card-number-shaped substrings
are rewritten to <email> / <card> sentinels recursively
across nested objects + arrays. Default: on. Pre-v1.4.0 the
Node SDK was the only one that skipped this, shipping payloads
UNREDACTED despite the README promising parity.
Opt out only for regulator-required audit trails where the raw value must be preserved:
new CrossdeckServer({ secretKey, scrubPii: false });Blast radius: every track() payload — event names with
embedded emails, trait values, group memberships, error context
blobs — ships verbatim to Crossdeck and downstream warehouses /
analytics exports. Document the decision at the call site.
Shutdown — flush before exit (v1.4.0 contract)
The server holds a buffered event queue. A clean teardown MUST flush the buffer before dropping it, otherwise events queued between the last flush and shutdown are silently lost.
Three teardown paths, three contracts:
| Method | Flushes? | Use when |
| ------ | -------- | -------- |
| await server.shutdown() | YES — awaits internal flush() then tears down | Default. Use this in graceful-shutdown handlers. |
| await using server = ... + [Symbol.asyncDispose] | YES — equivalent to await server.shutdown() | TC39 explicit-resource-management blocks. |
| server.shutdownSync() / using + [Symbol.dispose] | NO — drops the buffer | ONLY when the runtime cannot await (signal handlers, process.exit fallthrough). |
// Graceful shutdown (recommended)
process.on("SIGTERM", async () => {
await server.shutdown();
process.exit(0);
});
// Explicit-resource-management (Node 20+ / TS 5.2+)
{
await using server = new CrossdeckServer({ secretKey });
// ... use server ...
} // [Symbol.asyncDispose] fires here, awaits flushshutdownSync() (and the sync [Symbol.dispose] that wraps it)
logs a console.warn with the dropped-event count whenever the
buffer is non-empty at sync-teardown time — silent loss is
incompatible with the bank-grade contract.
EventEmitter — internal events
CrossdeckServer extends EventEmitter. Subscribe to internal lifecycle events with typed listeners:
crossdeck.on("queue.flush_failed", ({ error, attempt, nextRetryMs }) => {
metrics.increment("crossdeck.flush_failed", { attempt });
});
crossdeck.on("error.captured", ({ fingerprint, kind, message }) => {
// forward to your other observability tools
});
crossdeck.on("sdk.shutdown", ({ reason }) => {
// last-chance cleanup
});Events: queue.flush_succeeded, queue.flush_failed, queue.dropped, queue.buffer_changed, error.captured, entitlements.warmed, sdk.shutdown.
Health probes — Kubernetes / load balancers
crossdeck.isReady(); // synchronous: false on sustained retry storm or buffer pressure
await crossdeck.awaitReady(2000); // backpressure-aware wait
crossdeck.getHealth(); // full snapshot
// Express health endpoint
app.get("/healthz", (_req, res) => {
const h = crossdeck.getHealth();
res.status(h.healthy ? 200 : 503).json(h);
});Explicit resource management
TC39 using / await using syntax (Node 20+, TS 5.2+):
{
using crossdeck = new CrossdeckServer({ secretKey });
// ... use crossdeck ...
} // crossdeck[Symbol.dispose]() runs — handlers cleaned up
async function lambdaHandler(event) {
await using crossdeck = new CrossdeckServer({ secretKey });
crossdeck.track({ name: "handler.invoked", developerUserId: event.userId });
// ... do work ...
} // crossdeck[Symbol.asyncDispose]() runs — awaits flush() then cleans uptestMode — caller tests without mocking fetch
const crossdeck = new CrossdeckServer({
secretKey: "cd_sk_test_test",
testMode: true,
});
// Every call returns a synthetic success shape — no network.
// Use crossdeck.on("entitlements.warmed", ...) etc. to assert behaviour.onRequest / onResponse hooks
new CrossdeckServer({
secretKey,
onRequest: (info) => debug.log({ method: info.method, url: info.url, attempt: info.attempt }),
onResponse: (info) => metrics.histogram("crossdeck.request_ms", info.durationMs),
});Synchronous, errors swallowed — telemetry must never break the request pipeline.
Bulk entitlement ops
// Grant `pro_q1_bonus` to a list of customers, bounded concurrency
const results = await crossdeck.bulkGrantEntitlement(
customerIds.map((customerId) => ({
customerId,
entitlementKey: "pro_q1_bonus",
duration: "P30D",
reason: "Q1 promo",
})),
{ maxConcurrency: 10 },
);
const succeeded = results.filter((r) => r.ok);
const failed = results.filter((r) => !r.ok);
// Partial failures preserved as { ok: false, error }Symmetric bulkRevokeEntitlement(revokes[], options?).
Bank-grade contracts
The SDK ships its own contracts registry — every behavioural guarantee the SDK makes (per-user cache isolation, deterministic Idempotency-Key, queue durability, etc.) lives in contracts/**/*.json at the monorepo root and is bundled into every release. The customer's lockfile pins SDK code + contracts atomically — drift between what the SDK does and what it claims is structurally impossible. See contracts/README.md for the full architecture.
CrossdeckContracts — typed access to the bundled registry
import { CrossdeckContracts } from "@cross-deck/node";
CrossdeckContracts.all(); // enforced contracts only
CrossdeckContracts.allIncludingHistorical(); // + proposed + retired
CrossdeckContracts.byId("idempotency-key-deterministic");
CrossdeckContracts.byPillar("revenue");
CrossdeckContracts.withStatus("proposed");
CrossdeckContracts.findByTestName("rail namespacing prevents cross-rail collisions");
CrossdeckContracts.sdkVersion; // "1.5.0"
CrossdeckContracts.bundledIn; // "@cross-deck/[email protected]"The Contract type is exported alongside; the binary-stability promise is documented in contracts/README.md.
crossdeckServer.reportContractFailure(input) — surface contract test failures
When a contract test asserts and fails — in your CI, a dogfood run, or a customer integration test — fire a typed crossdeck.contract_failed event over the Crossdeck reliability channel. This is one-way operational telemetry to the Crossdeck operations team (Privacy Policy §6, "Flow B"); it never enters your track() pipeline, never shows in your dashboard, never bills against your event quota. The wire shape is schema-locked at contracts/diagnostics/contract-failed-payload-schema-lock.json:
import { CrossdeckServer } from "@cross-deck/node";
const cd = new CrossdeckServer({ secretKey: process.env.CROSSDECK_SECRET_KEY! });
cd.reportContractFailure({
contractId: "idempotency-key-deterministic",
failureReason: "expected cross-SDK oracle to match canonical vector, got drift",
runContext: process.env.CI ? "ci" : "dogfood",
runId: process.env.GITHUB_RUN_ID ?? crypto.randomUUID(),
testRef: {
file: "tests/idempotency-key.test.ts",
name: "apple JWS produces the canonical pinned UUID across all 5 SDKs",
},
});No new endpoint, no special ingest path — the event lands in the same pipeline every other server-side track() call does. It surfaces immediately in the dashboard's live event feed, the breakdown chart (group by contract_id, sdk_platform), and any alert rule with event = crossdeck.contract_failed.
Properties stamped on the wire:
| Property | Source |
|----------|--------|
| contract_id | caller |
| sdk_version, sdk_platform | auto-stamped (@cross-deck/node ships sdk_platform: "node") |
| failure_reason, run_context, run_id | caller |
| test_file, test_name | set when testRef is provided |
| device_class | optional, set by caller (categorical bucket — e.g. "linux-server", "container", "lambda") |
The wire shape is schema-locked at contracts/diagnostics/contract-failed-payload-schema-lock.json; per-SDK assertion tests gate it on every release. Free-form extra keys are not accepted — adding a field requires an amendment to the schema-lock contract first.
For per-test-framework hooks see contracts/README.md § Reporting contract failures.
Node version
Node 18+. Uses the platform fetch and node:crypto — zero runtime dependencies.
Bundle
dist/index.cjs + dist/index.mjs (main entry) + dist/auto-events/index.cjs + dist/auto-events/index.mjs (framework adapters subpath). Strict TypeScript, full .d.ts for both entries, source maps included.
License
MIT
