@cross-deck/node 1.5.2 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/dist/auto-events/index.d.mts +1 -1
- package/dist/auto-events/index.d.ts +1 -1
- package/dist/contracts.json +557 -0
- package/dist/{crossdeck-server-DhnHvUhh.d.mts → crossdeck-server-C1Ue0rv4.d.mts} +219 -12
- package/dist/{crossdeck-server-DhnHvUhh.d.ts → crossdeck-server-C1Ue0rv4.d.ts} +219 -12
- package/dist/index.cjs +1076 -75
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +150 -21
- package/dist/index.d.ts +150 -21
- package/dist/index.mjs +1061 -71
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -316,10 +316,85 @@ function byteLength(s) {
|
|
|
316
316
|
return s.length * 4;
|
|
317
317
|
}
|
|
318
318
|
|
|
319
|
+
// src/_diagnostic-telemetry.ts
|
|
320
|
+
import * as https from "https";
|
|
321
|
+
|
|
319
322
|
// src/_version.ts
|
|
320
|
-
var SDK_VERSION = "1.
|
|
323
|
+
var SDK_VERSION = "1.6.0";
|
|
321
324
|
var SDK_NAME = "@cross-deck/node";
|
|
322
325
|
|
|
326
|
+
// src/_diagnostic-telemetry.ts
|
|
327
|
+
var DIAGNOSTIC_TELEMETRY_ENDPOINT = "https://api.cross-deck.com/v1/sdk/diagnostic";
|
|
328
|
+
var DIAGNOSTIC_TELEMETRY_PUBLISHABLE_KEY = "cd_pub_live_9490e7aa029c432abf";
|
|
329
|
+
function isDiagnosticTelemetryEnabled() {
|
|
330
|
+
return !DIAGNOSTIC_TELEMETRY_PUBLISHABLE_KEY.startsWith(
|
|
331
|
+
"cd_pub_RELIABILITY_PLACEHOLDER"
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
var DIAGNOSTIC_TELEMETRY_ALLOWED_KEYS = /* @__PURE__ */ new Set([
|
|
335
|
+
"contract_id",
|
|
336
|
+
"sdk_version",
|
|
337
|
+
"sdk_platform",
|
|
338
|
+
"failure_reason",
|
|
339
|
+
"run_context",
|
|
340
|
+
"run_id",
|
|
341
|
+
"test_file",
|
|
342
|
+
"test_name",
|
|
343
|
+
"device_class"
|
|
344
|
+
]);
|
|
345
|
+
function filterDiagnosticPayload(payload) {
|
|
346
|
+
const filtered = {};
|
|
347
|
+
for (const [k, v] of Object.entries(payload)) {
|
|
348
|
+
if (DIAGNOSTIC_TELEMETRY_ALLOWED_KEYS.has(k) && typeof v === "string") {
|
|
349
|
+
filtered[k] = v;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return filtered;
|
|
353
|
+
}
|
|
354
|
+
function sendDiagnosticTelemetry(payload) {
|
|
355
|
+
if (!isDiagnosticTelemetryEnabled()) return;
|
|
356
|
+
const filtered = filterDiagnosticPayload(payload);
|
|
357
|
+
if (Object.keys(filtered).length === 0) return;
|
|
358
|
+
const body = JSON.stringify(filtered);
|
|
359
|
+
let parsed;
|
|
360
|
+
try {
|
|
361
|
+
parsed = new URL(DIAGNOSTIC_TELEMETRY_ENDPOINT);
|
|
362
|
+
} catch {
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
try {
|
|
366
|
+
const req = https.request({
|
|
367
|
+
method: "POST",
|
|
368
|
+
hostname: parsed.hostname,
|
|
369
|
+
port: parsed.port || 443,
|
|
370
|
+
path: parsed.pathname + parsed.search,
|
|
371
|
+
// Short timeout — reliability telemetry must never stall the
|
|
372
|
+
// host server. A failed POST is acceptable; a hung POST is not.
|
|
373
|
+
timeout: 5e3,
|
|
374
|
+
headers: {
|
|
375
|
+
"Content-Type": "application/json",
|
|
376
|
+
"Content-Length": Buffer.byteLength(body, "utf8").toString(),
|
|
377
|
+
Authorization: `Bearer ${DIAGNOSTIC_TELEMETRY_PUBLISHABLE_KEY}`,
|
|
378
|
+
"Crossdeck-Sdk-Version": `${SDK_NAME}@${SDK_VERSION}`
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
req.on("error", () => {
|
|
382
|
+
});
|
|
383
|
+
req.on("timeout", () => {
|
|
384
|
+
try {
|
|
385
|
+
req.destroy();
|
|
386
|
+
} catch {
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
req.on("response", (res) => {
|
|
390
|
+
res.resume();
|
|
391
|
+
});
|
|
392
|
+
req.write(body);
|
|
393
|
+
req.end();
|
|
394
|
+
} catch {
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
323
398
|
// src/http.ts
|
|
324
399
|
var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
|
|
325
400
|
var DEFAULT_TIMEOUT_MS = 15e3;
|
|
@@ -829,6 +904,11 @@ var EventQueue = class {
|
|
|
829
904
|
try {
|
|
830
905
|
const env = this.cfg.envelope();
|
|
831
906
|
const body = {
|
|
907
|
+
// Event Envelope v1 §1 — schema/wire version the server uses to
|
|
908
|
+
// decide whether it can parse this payload. Integer 1; only bumped
|
|
909
|
+
// on a breaking wire change. Distinct from sdk.version (which
|
|
910
|
+
// answers "which build?") — two questions, two fields.
|
|
911
|
+
envelopeVersion: 1,
|
|
832
912
|
events: batch,
|
|
833
913
|
sdk: env.sdk
|
|
834
914
|
};
|
|
@@ -1813,6 +1893,25 @@ function runtimeInfoToProperties(info) {
|
|
|
1813
1893
|
if (info.appVersion) out.appVersion = info.appVersion;
|
|
1814
1894
|
return out;
|
|
1815
1895
|
}
|
|
1896
|
+
function buildEventContext(info, sdkName, sdkVersion) {
|
|
1897
|
+
const ctx = {
|
|
1898
|
+
// Common fields (spec §4, all platforms)
|
|
1899
|
+
os: info.platform,
|
|
1900
|
+
osVersion: info.platformRelease,
|
|
1901
|
+
appVersion: info.appVersion ?? null,
|
|
1902
|
+
sdkName,
|
|
1903
|
+
sdkVersion,
|
|
1904
|
+
// locale / timezone: Node has no process-level locale by default;
|
|
1905
|
+
// surface them from the environment if available, otherwise null.
|
|
1906
|
+
locale: typeof process !== "undefined" && process.env["LANG"] || null,
|
|
1907
|
+
timezone: typeof Intl !== "undefined" && Intl.DateTimeFormat().resolvedOptions().timeZone || null,
|
|
1908
|
+
// Node-specific runtime context (spec §4 Node section)
|
|
1909
|
+
nodeVersion: info.nodeVersion,
|
|
1910
|
+
host: info.host,
|
|
1911
|
+
region: info.region ?? null
|
|
1912
|
+
};
|
|
1913
|
+
return ctx;
|
|
1914
|
+
}
|
|
1816
1915
|
|
|
1817
1916
|
// src/flush-on-exit.ts
|
|
1818
1917
|
var SIGNALS = ["SIGTERM", "SIGINT"];
|
|
@@ -2349,6 +2448,63 @@ var EntitlementCache = class {
|
|
|
2349
2448
|
}
|
|
2350
2449
|
};
|
|
2351
2450
|
|
|
2451
|
+
// src/consent.ts
|
|
2452
|
+
var EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
|
|
2453
|
+
var CARD_PATTERN = /\b\d(?:[ -]?\d){12,18}\b/g;
|
|
2454
|
+
var REPLACEMENT_EMAIL = "<email>";
|
|
2455
|
+
var REPLACEMENT_CARD = "<card>";
|
|
2456
|
+
function scrubPii(value) {
|
|
2457
|
+
if (!value) return value;
|
|
2458
|
+
return value.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL).replace(CARD_PATTERN, REPLACEMENT_CARD);
|
|
2459
|
+
}
|
|
2460
|
+
function scrubPiiFromProperties(properties) {
|
|
2461
|
+
const out = {};
|
|
2462
|
+
for (const k of Object.keys(properties)) {
|
|
2463
|
+
out[k] = scrubValue(properties[k]);
|
|
2464
|
+
}
|
|
2465
|
+
return out;
|
|
2466
|
+
}
|
|
2467
|
+
function scrubValue(v) {
|
|
2468
|
+
if (typeof v === "string") return scrubPii(v);
|
|
2469
|
+
if (Array.isArray(v)) return v.map(scrubValue);
|
|
2470
|
+
if (v && typeof v === "object" && v.constructor === Object) {
|
|
2471
|
+
return scrubPiiFromProperties(v);
|
|
2472
|
+
}
|
|
2473
|
+
return v;
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
// src/idempotency-key.ts
|
|
2477
|
+
import { createHash } from "crypto";
|
|
2478
|
+
function formatAsUuid(hex) {
|
|
2479
|
+
return [
|
|
2480
|
+
hex.slice(0, 8),
|
|
2481
|
+
hex.slice(8, 12),
|
|
2482
|
+
hex.slice(12, 16),
|
|
2483
|
+
hex.slice(16, 20),
|
|
2484
|
+
hex.slice(20, 32)
|
|
2485
|
+
].join("-");
|
|
2486
|
+
}
|
|
2487
|
+
function sha256Hex(input) {
|
|
2488
|
+
return createHash("sha256").update(input, "utf8").digest("hex");
|
|
2489
|
+
}
|
|
2490
|
+
function deriveIdempotencyKeyForPurchase(body) {
|
|
2491
|
+
let identifier;
|
|
2492
|
+
if (body.rail === "apple") {
|
|
2493
|
+
identifier = body.signedTransactionInfo ?? "";
|
|
2494
|
+
} else if (body.rail === "google") {
|
|
2495
|
+
identifier = body.purchaseToken ?? "";
|
|
2496
|
+
} else {
|
|
2497
|
+
identifier = "";
|
|
2498
|
+
}
|
|
2499
|
+
if (!identifier) {
|
|
2500
|
+
throw new Error(
|
|
2501
|
+
`deriveIdempotencyKeyForPurchase: no stable identifier in body (rail=${body.rail}). Apple needs signedTransactionInfo; Google needs purchaseToken.`
|
|
2502
|
+
);
|
|
2503
|
+
}
|
|
2504
|
+
const namespaced = `crossdeck:purchases/sync:${body.rail}:${identifier}`;
|
|
2505
|
+
return formatAsUuid(sha256Hex(namespaced));
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2352
2508
|
// src/debug.ts
|
|
2353
2509
|
var SENSITIVE_KEY_PATTERNS = [
|
|
2354
2510
|
/^email$/i,
|
|
@@ -2408,6 +2564,10 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
2408
2564
|
baseUrl;
|
|
2409
2565
|
appId;
|
|
2410
2566
|
env;
|
|
2567
|
+
/** PII scrubber toggle. Default true — parity with Web/RN/Swift.
|
|
2568
|
+
* Pre-v1.4.0 the Node SDK shipped track() payloads UNREDACTED,
|
|
2569
|
+
* a privacy contract drift versus the README. */
|
|
2570
|
+
scrubPii;
|
|
2411
2571
|
secretKeyPrefix;
|
|
2412
2572
|
/**
|
|
2413
2573
|
* Process-stable pseudo-anonymous ID. Used as the default identity
|
|
@@ -2419,6 +2579,8 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
2419
2579
|
processAnonymousId;
|
|
2420
2580
|
runtime;
|
|
2421
2581
|
runtimeProperties;
|
|
2582
|
+
/** Envelope v1 §4 context object — built once at SDK init, reused on every event. */
|
|
2583
|
+
eventContext;
|
|
2422
2584
|
breadcrumbs;
|
|
2423
2585
|
eventQueue;
|
|
2424
2586
|
errorTracker;
|
|
@@ -2436,6 +2598,23 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
2436
2598
|
*/
|
|
2437
2599
|
entitlementStore;
|
|
2438
2600
|
debug;
|
|
2601
|
+
/**
|
|
2602
|
+
* Event Envelope v1 §3 — per-session monotonic sequence counter.
|
|
2603
|
+
*
|
|
2604
|
+
* Node has no mobile "session" lifecycle (no app launch / background /
|
|
2605
|
+
* foreground). We model a session as the SDK instance lifetime: the
|
|
2606
|
+
* counter starts at 0 on construction and increments once per
|
|
2607
|
+
* `track()` call. `session.started` is the boot-telemetry event
|
|
2608
|
+
* (the first `track()` called from `emitBootTelemetryEvent()`), so
|
|
2609
|
+
* the seq will be 0 for that event, matching the spec's "reset to 0
|
|
2610
|
+
* at session.started" clause — by construction, it's the zeroth event
|
|
2611
|
+
* of this instance's lifecycle.
|
|
2612
|
+
*
|
|
2613
|
+
* The counter persists for the entire process lifetime of the SDK
|
|
2614
|
+
* instance (spec §3 clause 1: background/foreground does not reset).
|
|
2615
|
+
* A new `CrossdeckServer` construction is the only reset (new session).
|
|
2616
|
+
*/
|
|
2617
|
+
sessionSeq = 0;
|
|
2439
2618
|
/**
|
|
2440
2619
|
* Alias map — `developerUserId` / `anonymousId` → canonical
|
|
2441
2620
|
* `crossdeckCustomerId`. Populated by `getEntitlements()` so a
|
|
@@ -2453,6 +2632,15 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
2453
2632
|
errorContext = {};
|
|
2454
2633
|
errorTags = {};
|
|
2455
2634
|
errorBeforeSend = null;
|
|
2635
|
+
/**
|
|
2636
|
+
* Dedup gate for `sdk.shutdown`. Both `shutdown()` (async) and
|
|
2637
|
+
* `shutdownSync()` need to emit so direct callers of EITHER see
|
|
2638
|
+
* the event (the async path's listener guarantees pre-launch
|
|
2639
|
+
* tests, the sync path covers `Symbol.dispose` + tests that call
|
|
2640
|
+
* `shutdownSync()` directly). Without this flag, `shutdown()`'s
|
|
2641
|
+
* tail call into `shutdownSync()` would emit twice.
|
|
2642
|
+
*/
|
|
2643
|
+
didEmitShutdown = false;
|
|
2456
2644
|
constructor(options) {
|
|
2457
2645
|
super();
|
|
2458
2646
|
if (!options.secretKey || !options.secretKey.startsWith("cd_sk_")) {
|
|
@@ -2467,6 +2655,7 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
2467
2655
|
this.baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
|
|
2468
2656
|
this.env = inferEnvFromKey(options.secretKey);
|
|
2469
2657
|
this.secretKeyPrefix = maskSecretKey(options.secretKey);
|
|
2658
|
+
this.scrubPii = options.scrubPii !== false;
|
|
2470
2659
|
this.http = new HttpClient({
|
|
2471
2660
|
secretKey: options.secretKey,
|
|
2472
2661
|
baseUrl: this.baseUrl,
|
|
@@ -2485,6 +2674,7 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
2485
2674
|
appVersion: options.appVersion
|
|
2486
2675
|
});
|
|
2487
2676
|
this.runtimeProperties = runtimeInfoToProperties(this.runtime);
|
|
2677
|
+
this.eventContext = buildEventContext(this.runtime, SDK_NAME, this.sdkVersion);
|
|
2488
2678
|
this.breadcrumbs = new BreadcrumbBuffer(options.breadcrumbsMaxSize ?? 50);
|
|
2489
2679
|
this.superProps = new SuperPropertyStore();
|
|
2490
2680
|
this.entitlementCache = new EntitlementCache({
|
|
@@ -2506,7 +2696,9 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
2506
2696
|
this.eventQueue = new EventQueue({
|
|
2507
2697
|
http: this.http,
|
|
2508
2698
|
batchSize: options.eventFlushBatchSize ?? 20,
|
|
2509
|
-
|
|
2699
|
+
// v1.4.0 Phase 3.3 — flush interval default parity at 2000ms
|
|
2700
|
+
// across every SDK. Per-instance override stays.
|
|
2701
|
+
intervalMs: options.eventFlushIntervalMs ?? 2e3,
|
|
2510
2702
|
envelope: () => ({
|
|
2511
2703
|
appId: this.appId,
|
|
2512
2704
|
// Ship env on every batch so the backend can cross-check
|
|
@@ -2885,6 +3077,34 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
2885
3077
|
* `uncaughtException` has no per-request context; without the
|
|
2886
3078
|
* auto-fill, the event would be rejected at queue enqueue.
|
|
2887
3079
|
*/
|
|
3080
|
+
/**
|
|
3081
|
+
* Emit `crossdeck.contract_failed` to the Crossdeck reliability
|
|
3082
|
+
* endpoint — single-fire, one-way, never visible in the customer's
|
|
3083
|
+
* dashboard. Goes over a dedicated HTTP path with the reliability
|
|
3084
|
+
* publishable key embedded at build time; the customer's track()
|
|
3085
|
+
* pipeline never carries `crossdeck.*` events. This is the
|
|
3086
|
+
* independent-controller flow described in Privacy Policy §6
|
|
3087
|
+
* ("Flow B"). The wire shape is fixed by the schema-lock contract
|
|
3088
|
+
* at `contracts/diagnostics/contract-failed-payload-schema-lock.json`.
|
|
3089
|
+
*/
|
|
3090
|
+
reportContractFailure(input) {
|
|
3091
|
+
const payload = {
|
|
3092
|
+
contract_id: input.contractId,
|
|
3093
|
+
sdk_version: SDK_VERSION,
|
|
3094
|
+
sdk_platform: "node",
|
|
3095
|
+
failure_reason: input.failureReason,
|
|
3096
|
+
run_context: input.runContext,
|
|
3097
|
+
run_id: input.runId
|
|
3098
|
+
};
|
|
3099
|
+
if (input.testRef) {
|
|
3100
|
+
payload.test_file = input.testRef.file;
|
|
3101
|
+
payload.test_name = input.testRef.name;
|
|
3102
|
+
}
|
|
3103
|
+
if (input.deviceClass) {
|
|
3104
|
+
payload.device_class = input.deviceClass;
|
|
3105
|
+
}
|
|
3106
|
+
sendDiagnosticTelemetry(payload);
|
|
3107
|
+
}
|
|
2888
3108
|
track(event) {
|
|
2889
3109
|
if (!event.name) {
|
|
2890
3110
|
throw new CrossdeckError({
|
|
@@ -2893,7 +3113,8 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
2893
3113
|
message: "track(event) requires a non-empty event.name."
|
|
2894
3114
|
});
|
|
2895
3115
|
}
|
|
2896
|
-
const
|
|
3116
|
+
const validated = sanitizePropertyBag(event.properties, "event properties") ?? {};
|
|
3117
|
+
const sanitized = this.scrubPii ? scrubPiiFromProperties(validated) : validated;
|
|
2897
3118
|
if (this.debug.enabled) {
|
|
2898
3119
|
const flagged = findSensitivePropertyKeys(sanitized);
|
|
2899
3120
|
if (flagged.length > 0) {
|
|
@@ -2905,7 +3126,6 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
2905
3126
|
}
|
|
2906
3127
|
}
|
|
2907
3128
|
const properties = {
|
|
2908
|
-
...this.runtimeProperties,
|
|
2909
3129
|
...this.superProps.getSuperProperties(),
|
|
2910
3130
|
...sanitized
|
|
2911
3131
|
};
|
|
@@ -2914,10 +3134,14 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
2914
3134
|
properties.$groups = groupIds;
|
|
2915
3135
|
}
|
|
2916
3136
|
const identity = this.resolveIdentity(event);
|
|
3137
|
+
const seq = this.sessionSeq++;
|
|
3138
|
+
const timestamp = event.timestamp ?? Date.now();
|
|
2917
3139
|
const queued = {
|
|
2918
3140
|
eventId: event.eventId ?? mintId("evt", 8),
|
|
2919
3141
|
name: event.name,
|
|
2920
|
-
timestamp
|
|
3142
|
+
timestamp,
|
|
3143
|
+
seq,
|
|
3144
|
+
context: this.eventContext,
|
|
2921
3145
|
properties,
|
|
2922
3146
|
...identity
|
|
2923
3147
|
};
|
|
@@ -2956,6 +3180,8 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
2956
3180
|
}
|
|
2957
3181
|
const normalized = events.map((event) => this.normalizeIngestEvent(event));
|
|
2958
3182
|
const body = {
|
|
3183
|
+
// Event Envelope v1 §1 — wire version (parity with the queue path).
|
|
3184
|
+
envelopeVersion: 1,
|
|
2959
3185
|
events: normalized,
|
|
2960
3186
|
sdk: { name: SDK_NAME, version: this.sdkVersion },
|
|
2961
3187
|
// Match the queue's batch envelope (see event-queue.ts) — backend
|
|
@@ -3029,11 +3255,25 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
3029
3255
|
});
|
|
3030
3256
|
}
|
|
3031
3257
|
const rail = input.rail ?? "apple";
|
|
3032
|
-
|
|
3033
|
-
|
|
3258
|
+
const body = { ...input, rail };
|
|
3259
|
+
const idempotencyKey = options?.idempotencyKey ?? deriveIdempotencyKeyForPurchase(body);
|
|
3260
|
+
const result = await this.http.request("POST", "/purchases/sync", {
|
|
3261
|
+
body,
|
|
3262
|
+
idempotencyKey,
|
|
3034
3263
|
signal: options?.signal,
|
|
3035
3264
|
timeoutMs: options?.timeoutMs
|
|
3036
3265
|
});
|
|
3266
|
+
try {
|
|
3267
|
+
const sourceProductId = result.entitlements[0]?.source.productId;
|
|
3268
|
+
const sourceSubscriptionId = result.entitlements[0]?.source.subscriptionId;
|
|
3269
|
+
const props = { rail };
|
|
3270
|
+
if (sourceProductId) props.productId = sourceProductId;
|
|
3271
|
+
if (sourceSubscriptionId) props.subscriptionId = sourceSubscriptionId;
|
|
3272
|
+
if (result.idempotent_replay) props.idempotent_replay = true;
|
|
3273
|
+
this.track({ name: "purchase.completed", properties: props });
|
|
3274
|
+
} catch {
|
|
3275
|
+
}
|
|
3276
|
+
return result;
|
|
3037
3277
|
}
|
|
3038
3278
|
// ============================================================
|
|
3039
3279
|
// Manual entitlement controls + audit — direct HTTP
|
|
@@ -3325,12 +3565,56 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
3325
3565
|
};
|
|
3326
3566
|
}
|
|
3327
3567
|
/**
|
|
3328
|
-
* Tear down handlers and clear in-memory state.
|
|
3329
|
-
*
|
|
3330
|
-
* `flush
|
|
3568
|
+
* Tear down handlers and clear in-memory state.
|
|
3569
|
+
*
|
|
3570
|
+
* **v1.4.0 bank-grade contract:** `shutdown()` AWAITS `flush()`
|
|
3571
|
+
* before dropping the queue, so callers don't silently lose
|
|
3572
|
+
* every queued event on a clean shutdown. The pre-v1.4.0
|
|
3573
|
+
* behaviour (sync `eventQueue.reset()` with no flush) was the
|
|
3574
|
+
* default for both `shutdown()` and `[Symbol.dispose]`; only
|
|
3575
|
+
* `await using` + `[Symbol.asyncDispose]` flushed correctly.
|
|
3576
|
+
*
|
|
3577
|
+
* Production servers should still prefer `await server.flush()`
|
|
3578
|
+
* (visible) followed by `server.shutdown()` so the flush
|
|
3579
|
+
* outcome is observable — `shutdown()`'s internal flush swallows
|
|
3580
|
+
* errors as a best-effort drain.
|
|
3581
|
+
*
|
|
3582
|
+
* Use [[shutdownSync]] only when the runtime cannot await
|
|
3583
|
+
* (e.g. inside `Symbol.dispose` — see below).
|
|
3584
|
+
*/
|
|
3585
|
+
async shutdown(reason = "shutdown") {
|
|
3586
|
+
if (!this.didEmitShutdown) {
|
|
3587
|
+
this.emit("sdk.shutdown", { reason });
|
|
3588
|
+
this.didEmitShutdown = true;
|
|
3589
|
+
}
|
|
3590
|
+
try {
|
|
3591
|
+
await this.flush();
|
|
3592
|
+
} catch {
|
|
3593
|
+
}
|
|
3594
|
+
this.shutdownSync(reason);
|
|
3595
|
+
}
|
|
3596
|
+
/**
|
|
3597
|
+
* Synchronous teardown — drops the in-memory queue WITHOUT
|
|
3598
|
+
* flushing, then clears all in-memory state. Used by
|
|
3599
|
+
* `[Symbol.dispose]` (which has no await) and tests that need
|
|
3600
|
+
* an unconditional sync wipe. Production code should use
|
|
3601
|
+
* [[shutdown]] (async) instead so queued events are flushed.
|
|
3602
|
+
*
|
|
3603
|
+
* A queue with items at sync-shutdown logs a warning recommending
|
|
3604
|
+
* `[Symbol.asyncDispose]` or `await server.shutdown()` — silent
|
|
3605
|
+
* loss is incompatible with the bank-grade contract.
|
|
3331
3606
|
*/
|
|
3332
|
-
|
|
3333
|
-
this.
|
|
3607
|
+
shutdownSync(reason = "shutdown") {
|
|
3608
|
+
if (!this.didEmitShutdown) {
|
|
3609
|
+
this.emit("sdk.shutdown", { reason });
|
|
3610
|
+
this.didEmitShutdown = true;
|
|
3611
|
+
}
|
|
3612
|
+
const queuedCount = this.eventQueue.getStats().buffered;
|
|
3613
|
+
if (queuedCount > 0 && reason !== "asyncDispose") {
|
|
3614
|
+
console.warn(
|
|
3615
|
+
`[crossdeck] shutdownSync() dropped ${queuedCount} queued event(s) without flushing. Use \`await server.shutdown()\` or \`await using server = ...\` with \`[Symbol.asyncDispose]\` to drain the buffer before teardown.`
|
|
3616
|
+
);
|
|
3617
|
+
}
|
|
3334
3618
|
this.errorTracker?.uninstall();
|
|
3335
3619
|
this.flushOnExit?.uninstall();
|
|
3336
3620
|
this.eventQueue.reset();
|
|
@@ -3444,28 +3728,28 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
3444
3728
|
* // ... use server ...
|
|
3445
3729
|
* // at end of block, server[Symbol.dispose]() runs automatically
|
|
3446
3730
|
*
|
|
3447
|
-
*
|
|
3448
|
-
*
|
|
3449
|
-
*
|
|
3450
|
-
*
|
|
3731
|
+
* **`Symbol.dispose` is synchronous so it CANNOT await the queue
|
|
3732
|
+
* flush.** A queue with pending events at sync-dispose time will
|
|
3733
|
+
* be DROPPED — `shutdownSync` warns to the console when this
|
|
3734
|
+
* happens. For the common case of "drain the queue before
|
|
3735
|
+
* exit", switch to `await using` + `[Symbol.asyncDispose]` (or
|
|
3736
|
+
* call `await server.shutdown()` explicitly before the variable
|
|
3737
|
+
* goes out of scope).
|
|
3451
3738
|
*/
|
|
3452
3739
|
[Symbol.dispose]() {
|
|
3453
|
-
this.
|
|
3740
|
+
this.shutdownSync("dispose");
|
|
3454
3741
|
}
|
|
3455
3742
|
/**
|
|
3456
3743
|
* Async disposal hook — runs when an `await using` declaration
|
|
3457
|
-
* exits scope. Awaits
|
|
3458
|
-
*
|
|
3459
|
-
*
|
|
3744
|
+
* exits scope. Awaits the bank-grade `shutdown()` which flushes
|
|
3745
|
+
* the queue THEN tears down. Use this variant for any code path
|
|
3746
|
+
* that owns queued events at exit (serverless handlers,
|
|
3747
|
+
* background workers, end-of-request hooks).
|
|
3460
3748
|
*
|
|
3461
3749
|
* await using server = new CrossdeckServer({ ... });
|
|
3462
3750
|
*/
|
|
3463
3751
|
async [Symbol.asyncDispose]() {
|
|
3464
|
-
|
|
3465
|
-
await this.flush();
|
|
3466
|
-
} catch {
|
|
3467
|
-
}
|
|
3468
|
-
this.shutdown("asyncDispose");
|
|
3752
|
+
await this.shutdown("asyncDispose");
|
|
3469
3753
|
}
|
|
3470
3754
|
// ============================================================
|
|
3471
3755
|
reportCapturedError(captured) {
|
|
@@ -3885,18 +4169,43 @@ var _CROSSDECK_ERROR_CODES = Object.freeze([
|
|
|
3885
4169
|
retryable: false
|
|
3886
4170
|
},
|
|
3887
4171
|
// ----- Webhook verification (Node-specific) -----
|
|
4172
|
+
// v1.4.0 Phase 7.2 — distinguishable codes. Pre-v1.4.0 the
|
|
4173
|
+
// helper used webhook_invalid_signature for nearly every failure
|
|
4174
|
+
// mode so a customer couldn't separate replay-attack signals
|
|
4175
|
+
// from wrong-secret signals in alerting.
|
|
3888
4176
|
{
|
|
3889
|
-
code: "
|
|
4177
|
+
code: "webhook_signature_mismatch",
|
|
3890
4178
|
type: "authentication_error",
|
|
3891
|
-
description: "
|
|
3892
|
-
resolution: "Confirm the secret matches
|
|
4179
|
+
description: "Webhook HMAC didn't verify against any configured secret (wrong-secret / stale rotation signal).",
|
|
4180
|
+
resolution: "Confirm the secret matches dashboard \u2192 Webhooks. If you rotated, include both the old and new secret as an array until receivers cut over.",
|
|
3893
4181
|
retryable: false
|
|
3894
4182
|
},
|
|
3895
4183
|
{
|
|
3896
|
-
code: "
|
|
4184
|
+
code: "webhook_timestamp_outside_tolerance",
|
|
4185
|
+
type: "authentication_error",
|
|
4186
|
+
description: "Webhook timestamp drift exceeds the configured replay-tolerance window (default 5 minutes; replay-attack signal).",
|
|
4187
|
+
resolution: "Verify NTP on the receiving host. A spike on this code warrants its own alert separate from signature_mismatch \u2014 replay attacks look like this.",
|
|
4188
|
+
retryable: false
|
|
4189
|
+
},
|
|
4190
|
+
{
|
|
4191
|
+
code: "webhook_timestamp_missing",
|
|
3897
4192
|
type: "authentication_error",
|
|
3898
|
-
description: "
|
|
3899
|
-
resolution: "
|
|
4193
|
+
description: "Webhook signature header is absent or has no `t=` timestamp segment \u2014 the timestamp gate cannot be verified.",
|
|
4194
|
+
resolution: "Confirm the request actually came from Crossdeck (signature headers are always present on real deliveries). A missing header is either a misconfigured intermediary or a forged request.",
|
|
4195
|
+
retryable: false
|
|
4196
|
+
},
|
|
4197
|
+
{
|
|
4198
|
+
code: "webhook_payload_not_json",
|
|
4199
|
+
type: "authentication_error",
|
|
4200
|
+
description: "Webhook signature verified but the body isn't valid JSON \u2014 payload tampered post-signing or source bug.",
|
|
4201
|
+
resolution: "Inspect the raw payload. If it's not JSON, either the request was modified in transit or the sender has a bug \u2014 file a support ticket with the raw body.",
|
|
4202
|
+
retryable: false
|
|
4203
|
+
},
|
|
4204
|
+
{
|
|
4205
|
+
code: "webhook_invalid_tolerance",
|
|
4206
|
+
type: "configuration_error",
|
|
4207
|
+
description: "verifyWebhookSignature() called with a non-finite / negative / above-24h-cap replayToleranceMs (would silently disable replay protection).",
|
|
4208
|
+
resolution: "Pass a finite number between 0 and 86_400_000ms (24h). Default (5 minutes) is correct for almost every scenario. Pre-v1.4.0 accepted Infinity/NaN and silently dropped the check.",
|
|
3900
4209
|
retryable: false
|
|
3901
4210
|
},
|
|
3902
4211
|
{
|
|
@@ -3905,6 +4214,101 @@ var _CROSSDECK_ERROR_CODES = Object.freeze([
|
|
|
3905
4214
|
description: "verifyWebhookSignature() was called without a signing secret.",
|
|
3906
4215
|
resolution: "Pass the secret from your Crossdeck dashboard \u2192 Webhooks page. Never hardcode in source \u2014 read from an env var.",
|
|
3907
4216
|
retryable: false
|
|
4217
|
+
},
|
|
4218
|
+
{
|
|
4219
|
+
code: "webhook_invalid_signature",
|
|
4220
|
+
type: "authentication_error",
|
|
4221
|
+
description: "DEPRECATED in v1.4.0 \u2014 split into webhook_signature_mismatch / webhook_timestamp_missing / webhook_timestamp_outside_tolerance / webhook_payload_not_json for alerting clarity.",
|
|
4222
|
+
resolution: "Migrate alert rules to the more specific v1.4.0 codes \u2014 they distinguish replay-attack signals from wrong-secret signals.",
|
|
4223
|
+
retryable: false
|
|
4224
|
+
},
|
|
4225
|
+
{
|
|
4226
|
+
code: "webhook_replay_window_exceeded",
|
|
4227
|
+
type: "authentication_error",
|
|
4228
|
+
description: "DEPRECATED in v1.4.0 \u2014 renamed to webhook_timestamp_outside_tolerance.",
|
|
4229
|
+
resolution: "Update alerts to webhook_timestamp_outside_tolerance.",
|
|
4230
|
+
retryable: false
|
|
4231
|
+
},
|
|
4232
|
+
// ----- Backend-emitted codes (v1.4.0 Phase 6.2 backfill) -----
|
|
4233
|
+
// Mirror of backend/src/api/v1-errors.ts ApiErrorCode. Same set
|
|
4234
|
+
// as the Web SDK ships — keep these synchronised so a developer
|
|
4235
|
+
// hitting any code via either SDK gets the same remediation.
|
|
4236
|
+
{
|
|
4237
|
+
code: "missing_api_key",
|
|
4238
|
+
type: "authentication_error",
|
|
4239
|
+
description: "No Authorization header (or Crossdeck-Api-Key header) on the request.",
|
|
4240
|
+
resolution: "Confirm the CrossdeckServer was constructed with a cd_sk_\u2026 secretKey. Re-check env vars in production deployments.",
|
|
4241
|
+
retryable: false
|
|
4242
|
+
},
|
|
4243
|
+
{
|
|
4244
|
+
code: "invalid_api_key",
|
|
4245
|
+
type: "authentication_error",
|
|
4246
|
+
description: "The secret key is malformed, unknown, or doesn't resolve to a project.",
|
|
4247
|
+
resolution: "Copy the key from Crossdeck dashboard \u2192 API keys. Server SDK requires cd_sk_test_ / cd_sk_live_ \u2014 client SDK keys (cd_pub_\u2026) won't work on the Node SDK.",
|
|
4248
|
+
retryable: false
|
|
4249
|
+
},
|
|
4250
|
+
{
|
|
4251
|
+
code: "key_revoked",
|
|
4252
|
+
type: "authentication_error",
|
|
4253
|
+
description: "The secret key was revoked in the dashboard.",
|
|
4254
|
+
resolution: "Mint a fresh key in dashboard \u2192 API keys \u2192 Create new. The revoked key cannot be reactivated.",
|
|
4255
|
+
retryable: false
|
|
4256
|
+
},
|
|
4257
|
+
{
|
|
4258
|
+
code: "env_mismatch",
|
|
4259
|
+
type: "permission_error",
|
|
4260
|
+
description: "The key's env prefix doesn't match the resolved app's configured env.",
|
|
4261
|
+
resolution: "Use a cd_sk_live_ key with a production app, cd_sk_test_ with a sandbox app. Crossing breaks the env lock.",
|
|
4262
|
+
retryable: false
|
|
4263
|
+
},
|
|
4264
|
+
{
|
|
4265
|
+
code: "idempotency_key_in_use",
|
|
4266
|
+
type: "invalid_request_error",
|
|
4267
|
+
description: "An Idempotency-Key was reused for a request with a different body (Stripe-grade contract).",
|
|
4268
|
+
resolution: "Server SDK derives keys deterministically from the body since v1.4.0; this should only fire if you passed options.idempotencyKey explicitly. Use a fresh key per logical operation.",
|
|
4269
|
+
retryable: false
|
|
4270
|
+
},
|
|
4271
|
+
{
|
|
4272
|
+
code: "rate_limited",
|
|
4273
|
+
type: "rate_limit_error",
|
|
4274
|
+
description: "Request rate exceeded the project's per-second cap.",
|
|
4275
|
+
resolution: "Honour Retry-After (managed retries do this automatically). For custom paths, throttle to <100 req/s/key.",
|
|
4276
|
+
retryable: true
|
|
4277
|
+
},
|
|
4278
|
+
{
|
|
4279
|
+
code: "internal_error",
|
|
4280
|
+
type: "internal_error",
|
|
4281
|
+
description: "Server-side issue. Safe to retry with backoff.",
|
|
4282
|
+
resolution: "Managed retries handle this automatically. If a code path surfaces it to your code, contact support with the requestId.",
|
|
4283
|
+
retryable: true
|
|
4284
|
+
},
|
|
4285
|
+
{
|
|
4286
|
+
code: "google_not_supported",
|
|
4287
|
+
type: "invalid_request_error",
|
|
4288
|
+
description: "POST /purchases/sync with rail=google is gated until the Play Developer API reconciliation worker ships.",
|
|
4289
|
+
resolution: "Until v1.5+, Google Play purchases verify via Real-time Developer Notifications. The Android SDK auto-track path handles this transparently.",
|
|
4290
|
+
retryable: false
|
|
4291
|
+
},
|
|
4292
|
+
{
|
|
4293
|
+
code: "stripe_not_supported",
|
|
4294
|
+
type: "invalid_request_error",
|
|
4295
|
+
description: "POST /purchases/sync with rail=stripe is unsupported \u2014 Stripe webhooks deliver evidence server-side.",
|
|
4296
|
+
resolution: "Wire Stripe via the standard Checkout / Customer Portal flow; Crossdeck reconciles via the platform webhook automatically.",
|
|
4297
|
+
retryable: false
|
|
4298
|
+
},
|
|
4299
|
+
{
|
|
4300
|
+
code: "missing_required_param",
|
|
4301
|
+
type: "invalid_request_error",
|
|
4302
|
+
description: "A required field is absent from the request body.",
|
|
4303
|
+
resolution: "The error.message identifies the missing field. Refer to the SDK's TypeScript types for canonical shapes.",
|
|
4304
|
+
retryable: false
|
|
4305
|
+
},
|
|
4306
|
+
{
|
|
4307
|
+
code: "invalid_param_value",
|
|
4308
|
+
type: "invalid_request_error",
|
|
4309
|
+
description: "A field is present but the value failed validation.",
|
|
4310
|
+
resolution: "Read error.message for the field + reason. SDK-managed call sites should never emit this \u2014 file a bug if you do.",
|
|
4311
|
+
retryable: false
|
|
3908
4312
|
}
|
|
3909
4313
|
]);
|
|
3910
4314
|
function isCrossdeckErrorCode(code) {
|
|
@@ -3918,6 +4322,7 @@ function getErrorCode(code) {
|
|
|
3918
4322
|
// src/webhooks.ts
|
|
3919
4323
|
import { createHmac, timingSafeEqual } from "crypto";
|
|
3920
4324
|
var DEFAULT_REPLAY_TOLERANCE_MS = 5 * 60 * 1e3;
|
|
4325
|
+
var MAX_REPLAY_TOLERANCE_MS = 24 * 60 * 60 * 1e3;
|
|
3921
4326
|
function verifyWebhookSignature(payload, signatureHeader, secret, options = {}) {
|
|
3922
4327
|
const secrets = normaliseSecrets(secret);
|
|
3923
4328
|
if (secrets.length === 0) {
|
|
@@ -3927,34 +4332,56 @@ function verifyWebhookSignature(payload, signatureHeader, secret, options = {})
|
|
|
3927
4332
|
message: "verifyWebhookSignature requires a non-empty secret. Read it from process.env.CROSSDECK_WEBHOOK_SECRET \u2014 never hardcode in source."
|
|
3928
4333
|
});
|
|
3929
4334
|
}
|
|
4335
|
+
const requestedTolerance = options.replayToleranceMs;
|
|
4336
|
+
let tolerance;
|
|
4337
|
+
if (requestedTolerance === void 0) {
|
|
4338
|
+
tolerance = DEFAULT_REPLAY_TOLERANCE_MS;
|
|
4339
|
+
} else if (typeof requestedTolerance !== "number" || !Number.isFinite(requestedTolerance)) {
|
|
4340
|
+
throw new CrossdeckError({
|
|
4341
|
+
type: "configuration_error",
|
|
4342
|
+
code: "webhook_invalid_tolerance",
|
|
4343
|
+
message: `replayToleranceMs must be a finite non-negative number \u2264 ${MAX_REPLAY_TOLERANCE_MS} (24h). Got: ${String(requestedTolerance)}. Pre-v1.4.0 accepted Infinity/NaN/null and silently disabled replay protection \u2014 v1.4.0 rejects loudly.`
|
|
4344
|
+
});
|
|
4345
|
+
} else if (requestedTolerance < 0) {
|
|
4346
|
+
throw new CrossdeckError({
|
|
4347
|
+
type: "configuration_error",
|
|
4348
|
+
code: "webhook_invalid_tolerance",
|
|
4349
|
+
message: `replayToleranceMs must be \u2265 0. Got ${requestedTolerance}.`
|
|
4350
|
+
});
|
|
4351
|
+
} else if (requestedTolerance > MAX_REPLAY_TOLERANCE_MS) {
|
|
4352
|
+
throw new CrossdeckError({
|
|
4353
|
+
type: "configuration_error",
|
|
4354
|
+
code: "webhook_invalid_tolerance",
|
|
4355
|
+
message: `replayToleranceMs must not exceed ${MAX_REPLAY_TOLERANCE_MS}ms (24h). Got ${requestedTolerance}ms \u2014 a window that wide defeats replay protection.`
|
|
4356
|
+
});
|
|
4357
|
+
} else {
|
|
4358
|
+
tolerance = requestedTolerance;
|
|
4359
|
+
}
|
|
3930
4360
|
const header = normaliseHeader(signatureHeader);
|
|
3931
4361
|
const parsed = parseSignatureHeader(header);
|
|
3932
4362
|
if (!parsed) {
|
|
3933
4363
|
throw new CrossdeckError({
|
|
3934
4364
|
type: "authentication_error",
|
|
3935
|
-
code: "
|
|
3936
|
-
message: "Webhook signature header is missing or
|
|
4365
|
+
code: "webhook_timestamp_missing",
|
|
4366
|
+
message: "Webhook signature header is missing, malformed, or has no `t=` timestamp segment. Expected 'Crossdeck-Signature: t=<unix>,v1=<hex>'."
|
|
3937
4367
|
});
|
|
3938
4368
|
}
|
|
3939
|
-
const
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
|
|
3944
|
-
|
|
3945
|
-
|
|
3946
|
-
|
|
3947
|
-
|
|
3948
|
-
message: `Webhook timestamp is ${drift}ms outside the ${tolerance}ms replay-tolerance window. Either the request is replayed or the receiving clock is skewed \u2014 verify NTP on the host.`
|
|
3949
|
-
});
|
|
3950
|
-
}
|
|
4369
|
+
const now = (options.now ?? Date.now)();
|
|
4370
|
+
const timestampMs = parsed.timestampSec * 1e3;
|
|
4371
|
+
const drift = Math.abs(now - timestampMs);
|
|
4372
|
+
if (drift > tolerance) {
|
|
4373
|
+
throw new CrossdeckError({
|
|
4374
|
+
type: "authentication_error",
|
|
4375
|
+
code: "webhook_timestamp_outside_tolerance",
|
|
4376
|
+
message: `Webhook timestamp is ${drift}ms outside the ${tolerance}ms replay-tolerance window. Either the request is replayed or the receiving clock is skewed \u2014 verify NTP on the host.`
|
|
4377
|
+
});
|
|
3951
4378
|
}
|
|
3952
4379
|
const signedPayload = `${parsed.timestampSec}.${payload}`;
|
|
3953
4380
|
const expectedBuf = Buffer.from(parsed.signature, "hex");
|
|
3954
4381
|
if (expectedBuf.length === 0) {
|
|
3955
4382
|
throw new CrossdeckError({
|
|
3956
4383
|
type: "authentication_error",
|
|
3957
|
-
code: "
|
|
4384
|
+
code: "webhook_signature_mismatch",
|
|
3958
4385
|
message: "Webhook signature is not a valid hex string."
|
|
3959
4386
|
});
|
|
3960
4387
|
}
|
|
@@ -3965,8 +4392,8 @@ function verifyWebhookSignature(payload, signatureHeader, secret, options = {})
|
|
|
3965
4392
|
if (!anyMatch) {
|
|
3966
4393
|
throw new CrossdeckError({
|
|
3967
4394
|
type: "authentication_error",
|
|
3968
|
-
code: "
|
|
3969
|
-
message: "Webhook signature did not verify. Confirm the secret matches your Crossdeck dashboard \u2192 Webhooks page (and that you're not on a stale rotation)."
|
|
4395
|
+
code: "webhook_signature_mismatch",
|
|
4396
|
+
message: "Webhook signature did not verify against any configured secret. Confirm the secret matches your Crossdeck dashboard \u2192 Webhooks page (and that you're not on a stale rotation)."
|
|
3970
4397
|
});
|
|
3971
4398
|
}
|
|
3972
4399
|
try {
|
|
@@ -3974,7 +4401,7 @@ function verifyWebhookSignature(payload, signatureHeader, secret, options = {})
|
|
|
3974
4401
|
} catch {
|
|
3975
4402
|
throw new CrossdeckError({
|
|
3976
4403
|
type: "authentication_error",
|
|
3977
|
-
code: "
|
|
4404
|
+
code: "webhook_payload_not_json",
|
|
3978
4405
|
message: "Webhook signature verified but the payload is not valid JSON. Either the payload was tampered with after signing, or the webhook source is misconfigured."
|
|
3979
4406
|
});
|
|
3980
4407
|
}
|
|
@@ -4012,35 +4439,598 @@ function normaliseSecrets(input) {
|
|
|
4012
4439
|
return arr.filter((s) => typeof s === "string" && s.length > 0);
|
|
4013
4440
|
}
|
|
4014
4441
|
|
|
4015
|
-
// src/
|
|
4016
|
-
var
|
|
4017
|
-
var
|
|
4018
|
-
var
|
|
4019
|
-
|
|
4020
|
-
|
|
4021
|
-
|
|
4022
|
-
|
|
4023
|
-
|
|
4024
|
-
|
|
4025
|
-
|
|
4026
|
-
|
|
4027
|
-
|
|
4442
|
+
// src/_contracts-bundled.ts
|
|
4443
|
+
var BUNDLED_IN = "@cross-deck/node@1.6.0";
|
|
4444
|
+
var SDK_VERSION2 = "1.6.0";
|
|
4445
|
+
var BUNDLED_CONTRACTS = Object.freeze([
|
|
4446
|
+
{
|
|
4447
|
+
"id": "contract-failed-payload-schema-lock",
|
|
4448
|
+
"pillar": "diagnostics",
|
|
4449
|
+
"status": "enforced",
|
|
4450
|
+
"claim": "The `crossdeck.contract_failed` event payload contains ONLY the named diagnostic fields and never any end-user personal data. The wire shape is fixed \u2014 adding a new field requires (1) a pull request that updates this contract's `allowedFields` set, (2) a Privacy Policy \xA76 amendment, and (3) the Customer Disclosure Template / SDK Data Collection Reference \xA7B updates. Per-SDK assertion tests enforce the field set on every release. The `verification_phase` field is a categorical bucket \u2014 values are restricted to `boot` (the SDK self-test ran on Crossdeck.start) or `hot_path` (a verifier observed a real customer-triggered operation). The categorical nature is what preserves the diagnostic-only-not-personal classification. This is the structural guarantee that backs the independent-controller lawful basis in the Privacy Policy: the payload remains diagnostic-only, not personal, so the legitimate-interest analysis stays valid as the SDK evolves.",
|
|
4451
|
+
"appliesTo": [
|
|
4452
|
+
"web",
|
|
4453
|
+
"node",
|
|
4454
|
+
"swift",
|
|
4455
|
+
"android",
|
|
4456
|
+
"react-native"
|
|
4457
|
+
],
|
|
4458
|
+
"allowedFields": {
|
|
4459
|
+
"required": [
|
|
4460
|
+
"contract_id",
|
|
4461
|
+
"sdk_version",
|
|
4462
|
+
"sdk_platform",
|
|
4463
|
+
"failure_reason",
|
|
4464
|
+
"run_context",
|
|
4465
|
+
"run_id"
|
|
4466
|
+
],
|
|
4467
|
+
"optional": [
|
|
4468
|
+
"test_file",
|
|
4469
|
+
"test_name",
|
|
4470
|
+
"device_class",
|
|
4471
|
+
"verification_phase"
|
|
4472
|
+
],
|
|
4473
|
+
"forbidden": [
|
|
4474
|
+
"anonymousId",
|
|
4475
|
+
"developerUserId",
|
|
4476
|
+
"crossdeckCustomerId",
|
|
4477
|
+
"email",
|
|
4478
|
+
"ip",
|
|
4479
|
+
"user_agent",
|
|
4480
|
+
"message",
|
|
4481
|
+
"stack",
|
|
4482
|
+
"stack_trace",
|
|
4483
|
+
"frames",
|
|
4484
|
+
"exception_message",
|
|
4485
|
+
"url",
|
|
4486
|
+
"path",
|
|
4487
|
+
"screen",
|
|
4488
|
+
"title",
|
|
4489
|
+
"label",
|
|
4490
|
+
"text",
|
|
4491
|
+
"ariaLabel",
|
|
4492
|
+
"accessibilityLabel",
|
|
4493
|
+
"contentDescription",
|
|
4494
|
+
"session_id",
|
|
4495
|
+
"sessionId"
|
|
4496
|
+
]
|
|
4497
|
+
},
|
|
4498
|
+
"transport": "Telemetry is single-fire to the Crossdeck reliability endpoint only \u2014 NOT the customer's appId. The customer's track() pipeline never carries `crossdeck.*` events; the customer's dashboard never shows individual contract failures. Operational telemetry flows one-way to the Crossdeck operations team for SDK reliability purposes (legitimate interest, independent-controller flow per Privacy Policy \xA76). The reliability endpoint is hardcoded at SDK build time; the publishable key for the reliability project is embedded as a constant and rejects writes that don't match the schema.",
|
|
4499
|
+
"codeRef": [
|
|
4500
|
+
"sdks/web/src/crossdeck.ts",
|
|
4501
|
+
"sdks/node/src/crossdeck-server.ts",
|
|
4502
|
+
"sdks/swift/Sources/Crossdeck/Crossdeck.swift",
|
|
4503
|
+
"sdks/swift/Sources/Crossdeck/_DiagnosticTelemetry.swift",
|
|
4504
|
+
"sdks/android/crossdeck/src/main/kotlin/com/crossdeck/Crossdeck.kt",
|
|
4505
|
+
"sdks/android/crossdeck/src/main/kotlin/com/crossdeck/_DiagnosticTelemetry.kt",
|
|
4506
|
+
"sdks/react-native/src/crossdeck.ts",
|
|
4507
|
+
"backend/src/api/v1-sdk-diagnostic.ts",
|
|
4508
|
+
"sdks/web/src/_diagnostic-telemetry.ts",
|
|
4509
|
+
"sdks/node/src/_diagnostic-telemetry.ts",
|
|
4510
|
+
"sdks/react-native/src/_diagnostic-telemetry.ts"
|
|
4511
|
+
],
|
|
4512
|
+
"testRef": [
|
|
4513
|
+
{
|
|
4514
|
+
"file": "sdks/web/tests/contract-failed-schema-lock.test.ts",
|
|
4515
|
+
"name": "reportContractFailure payload conforms to schema-lock"
|
|
4516
|
+
},
|
|
4517
|
+
{
|
|
4518
|
+
"file": "sdks/node/tests/contract-failed-schema-lock.test.ts",
|
|
4519
|
+
"name": "reportContractFailure payload conforms to schema-lock"
|
|
4520
|
+
},
|
|
4521
|
+
{
|
|
4522
|
+
"file": "sdks/swift/Tests/CrossdeckTests/ContractFailedSchemaLockTests.swift",
|
|
4523
|
+
"name": "test_reportContractFailure_payloadFieldsAreInAllowList"
|
|
4524
|
+
},
|
|
4525
|
+
{
|
|
4526
|
+
"file": "sdks/swift/Tests/CrossdeckTests/ContractFailedSchemaLockTests.swift",
|
|
4527
|
+
"name": "test_reportContractFailure_doesNotEnterCustomerTrackPipeline"
|
|
4528
|
+
},
|
|
4529
|
+
{
|
|
4530
|
+
"file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/ContractFailedSchemaLockTest.kt",
|
|
4531
|
+
"name": "reportContractFailure payload conforms to schema-lock"
|
|
4532
|
+
},
|
|
4533
|
+
{
|
|
4534
|
+
"file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/ContractFailedSchemaLockTest.kt",
|
|
4535
|
+
"name": "reportContractFailure does not enter customer track pipeline"
|
|
4536
|
+
},
|
|
4537
|
+
{
|
|
4538
|
+
"file": "sdks/react-native/tests/contract-failed-schema-lock.test.ts",
|
|
4539
|
+
"name": "reportContractFailure payload conforms to schema-lock"
|
|
4540
|
+
},
|
|
4541
|
+
{
|
|
4542
|
+
"file": "backend/tests/unit/v1-sdk-diagnostic.test.ts",
|
|
4543
|
+
"name": "forbidden fields are enumerated in the schema-lock contract"
|
|
4544
|
+
},
|
|
4545
|
+
{
|
|
4546
|
+
"file": "backend/tests/unit/v1-sdk-diagnostic.test.ts",
|
|
4547
|
+
"name": "required fields are enumerated in the schema-lock contract"
|
|
4548
|
+
},
|
|
4549
|
+
{
|
|
4550
|
+
"file": "backend/tests/unit/v1-sdk-diagnostic.test.ts",
|
|
4551
|
+
"name": "regression guard: never returns a raw IP"
|
|
4552
|
+
},
|
|
4553
|
+
{
|
|
4554
|
+
"file": "backend/tests/unit/v1-sdk-diagnostic.test.ts",
|
|
4555
|
+
"name": "verification_phase is in the optional field set"
|
|
4556
|
+
}
|
|
4557
|
+
],
|
|
4558
|
+
"registeredAt": "2026-05-27",
|
|
4559
|
+
"firstRegisteredIn": "Diagnostic telemetry single-fire + schema-lock \u2014 independent-controller flow",
|
|
4560
|
+
"privacyReferences": [
|
|
4561
|
+
"legal/privacy/index.html#sdk-diagnostic",
|
|
4562
|
+
"legal/customer-disclosure/index.html#flow-b",
|
|
4563
|
+
"legal/security/index.html#diagnostic",
|
|
4564
|
+
"legal/sdk-data/index.html#b-diagnostic"
|
|
4565
|
+
],
|
|
4566
|
+
"bundledIn": "@cross-deck/node@1.6.0"
|
|
4567
|
+
},
|
|
4568
|
+
{
|
|
4569
|
+
"id": "documentation-honesty",
|
|
4570
|
+
"pillar": "webhooks",
|
|
4571
|
+
"status": "enforced",
|
|
4572
|
+
"claim": "Customer-facing documentation honestly tags outbound webhook delivery as ROADMAP (no signer, no worker, no scheduler in backend/src yet). The Node verifier helper exists today for fixture authoring + locking the validation contract surface BEFORE delivery ships \u2014 its jsdoc carries an explicit `[ROADMAP]` disclaimer so a developer reading the source doesn't assume Crossdeck sends webhooks today. The rail-webhooks doc no longer claims state surfaces 'through the dashboard, SDKs, and outbound webhooks' \u2014 outbound is gated to the explicit roadmap section.",
|
|
4573
|
+
"appliesTo": [
|
|
4574
|
+
"node",
|
|
4575
|
+
"backend"
|
|
4576
|
+
],
|
|
4577
|
+
"codeRef": [
|
|
4578
|
+
"sdks/node/src/webhooks.ts",
|
|
4579
|
+
"docs/rail-webhooks/index.html",
|
|
4580
|
+
"docs/webhooks-receive/index.html"
|
|
4581
|
+
],
|
|
4582
|
+
"testRef": [
|
|
4583
|
+
{
|
|
4584
|
+
"file": "sdks/node/src/webhooks.ts",
|
|
4585
|
+
"name": "[ROADMAP \u2014 v1.4.0 honesty note]"
|
|
4586
|
+
},
|
|
4587
|
+
{
|
|
4588
|
+
"file": "docs/rail-webhooks/index.html",
|
|
4589
|
+
"name": "Outbound push-to-your-backend webhooks are <strong>roadmap</strong>"
|
|
4590
|
+
},
|
|
4591
|
+
{
|
|
4592
|
+
"file": "docs/webhooks-receive/index.html",
|
|
4593
|
+
"name": "This feature is on the roadmap"
|
|
4594
|
+
}
|
|
4595
|
+
],
|
|
4596
|
+
"registeredAt": "2026-05-26",
|
|
4597
|
+
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 7.1",
|
|
4598
|
+
"bundledIn": "@cross-deck/node@1.6.0"
|
|
4599
|
+
},
|
|
4600
|
+
{
|
|
4601
|
+
"id": "error-envelope-shape",
|
|
4602
|
+
"pillar": "errors",
|
|
4603
|
+
"status": "enforced",
|
|
4604
|
+
"claim": "Every v1 REST endpoint returns errors in a Stripe-shape envelope: `{ error: { type, code, message, request_id } }` where `type` is one of authentication_error / permission_error / invalid_request_error / rate_limit_error / internal_error (the wire vocabulary in backend/src/api/v1-errors.ts ApiErrorType). HTTP status parity: invalid_request_error \u2192 400, authentication_error \u2192 401, permission_error \u2192 403, rate_limit_error \u2192 429, internal_error \u2192 500. SDK-side clients parse this shape via `crossdeckErrorFromResponse` (Web/Node/RN) / `crossdeckErrorFrom(response:)` (Swift) / `crossdeckErrorFromResponse` (Android) and surface the request_id verbatim so support traces are end-to-end joinable. Firebase callable endpoints (managed-keys / dashboard auth) use the Firebase HttpsError envelope instead \u2014 this contract applies to REST /v1/* only.",
|
|
4605
|
+
"appliesTo": [
|
|
4606
|
+
"web",
|
|
4607
|
+
"node",
|
|
4608
|
+
"react-native",
|
|
4609
|
+
"swift",
|
|
4610
|
+
"android",
|
|
4611
|
+
"backend"
|
|
4612
|
+
],
|
|
4613
|
+
"codeRef": [
|
|
4614
|
+
"backend/src/api/v1-errors.ts",
|
|
4615
|
+
"sdks/web/src/errors.ts",
|
|
4616
|
+
"sdks/node/src/errors.ts",
|
|
4617
|
+
"sdks/react-native/src/errors.ts",
|
|
4618
|
+
"sdks/swift/Sources/Crossdeck/Errors.swift",
|
|
4619
|
+
"sdks/android/crossdeck/src/main/kotlin/com/crossdeck/Errors.kt"
|
|
4620
|
+
],
|
|
4621
|
+
"testRef": [
|
|
4622
|
+
{
|
|
4623
|
+
"file": "sdks/swift/Tests/CrossdeckTests/ErrorsTests.swift",
|
|
4624
|
+
"name": "test_errorEnvelope_fallsBackOnGarbageBody"
|
|
4625
|
+
},
|
|
4626
|
+
{
|
|
4627
|
+
"file": "sdks/swift/Tests/CrossdeckTests/ErrorsTests.swift",
|
|
4628
|
+
"name": "test_errorEnvelope_reads_XRequestId_fallback"
|
|
4629
|
+
},
|
|
4630
|
+
{
|
|
4631
|
+
"file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/ErrorTypeWireVocabTest.kt",
|
|
4632
|
+
"name": "backend 500 response parses to INTERNAL_ERROR"
|
|
4633
|
+
}
|
|
4634
|
+
],
|
|
4635
|
+
"registeredAt": "2026-05-26",
|
|
4636
|
+
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 8 (codifies existing contract)",
|
|
4637
|
+
"bundledIn": "@cross-deck/node@1.6.0"
|
|
4638
|
+
},
|
|
4639
|
+
{
|
|
4640
|
+
"id": "flush-interval-parity",
|
|
4641
|
+
"pillar": "analytics",
|
|
4642
|
+
"status": "enforced",
|
|
4643
|
+
"claim": "Every Crossdeck SDK defaults its event-queue flush interval to 2000ms \u2014 the Stripe-adjacent industry norm. Pre-v1.4.0 the defaults disagreed (Web/Node 1500ms; RN/Swift/Android 5000ms), so cross-platform funnels saw events landing at different cadences. Per-instance override stays \u2014 call sites can still tune it freely.",
|
|
4644
|
+
"appliesTo": [
|
|
4645
|
+
"web",
|
|
4646
|
+
"node",
|
|
4647
|
+
"react-native",
|
|
4648
|
+
"swift",
|
|
4649
|
+
"android"
|
|
4650
|
+
],
|
|
4651
|
+
"codeRef": [
|
|
4652
|
+
"sdks/web/src/crossdeck.ts",
|
|
4653
|
+
"sdks/node/src/crossdeck-server.ts",
|
|
4654
|
+
"sdks/react-native/src/crossdeck.ts",
|
|
4655
|
+
"sdks/swift/Sources/Crossdeck/EventQueue.swift",
|
|
4656
|
+
"sdks/android/crossdeck/src/main/kotlin/com/crossdeck/EventQueue.kt"
|
|
4657
|
+
],
|
|
4658
|
+
"testRef": [
|
|
4659
|
+
{
|
|
4660
|
+
"file": "sdks/swift/Sources/Crossdeck/EventQueue.swift",
|
|
4661
|
+
"name": "flushIntervalMs: Int = 2_000"
|
|
4662
|
+
},
|
|
4663
|
+
{
|
|
4664
|
+
"file": "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/EventQueue.kt",
|
|
4665
|
+
"name": "flushIntervalMs: Long = 2_000L"
|
|
4666
|
+
},
|
|
4667
|
+
{
|
|
4668
|
+
"file": "sdks/web/src/crossdeck.ts",
|
|
4669
|
+
"name": "options.eventFlushIntervalMs ?? 2000"
|
|
4670
|
+
},
|
|
4671
|
+
{
|
|
4672
|
+
"file": "sdks/node/src/crossdeck-server.ts",
|
|
4673
|
+
"name": "options.eventFlushIntervalMs ?? 2000"
|
|
4674
|
+
},
|
|
4675
|
+
{
|
|
4676
|
+
"file": "sdks/react-native/src/crossdeck.ts",
|
|
4677
|
+
"name": "options.eventFlushIntervalMs ?? 2000"
|
|
4678
|
+
}
|
|
4679
|
+
],
|
|
4680
|
+
"registeredAt": "2026-05-26",
|
|
4681
|
+
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 3.3",
|
|
4682
|
+
"bundledIn": "@cross-deck/node@1.6.0"
|
|
4683
|
+
},
|
|
4684
|
+
{
|
|
4685
|
+
"id": "idempotency-key-deterministic",
|
|
4686
|
+
"pillar": "revenue",
|
|
4687
|
+
"status": "enforced",
|
|
4688
|
+
"claim": "syncPurchases() on every SDK derives a deterministic Idempotency-Key from the request body (UUID-shaped SHA-256 of `crossdeck:purchases/sync:<rail>:<jws|token>`). Same input -> same key. Backend short-circuits same-key-same-body retries by returning the cached response (status + body) with `idempotent_replay: true` flag in the body AND `Idempotent-Replayed: true` response header. Same-key-different-body returns 400 `idempotency_key_in_use`. 24-hour TTL matches Stripe. Cache only stores 2xx responses \u2014 4xx/5xx pass through so callers can fix bugs and retry. Helper returns nil/throws on missing identifier (no silent random fallback). Cross-SDK parity is CI-pinned: deriveForPurchase('apple', 'eyJ.jws.sig') MUST equal 'a66b1640-efaf-bb4d-1261-6650033bf111' on every SDK.",
|
|
4689
|
+
"appliesTo": [
|
|
4690
|
+
"web",
|
|
4691
|
+
"node",
|
|
4692
|
+
"react-native",
|
|
4693
|
+
"swift",
|
|
4694
|
+
"android",
|
|
4695
|
+
"backend"
|
|
4696
|
+
],
|
|
4697
|
+
"codeRef": [
|
|
4698
|
+
"sdks/web/src/idempotency-key.ts",
|
|
4699
|
+
"sdks/web/src/crossdeck.ts",
|
|
4700
|
+
"sdks/react-native/src/idempotency-key.ts",
|
|
4701
|
+
"sdks/react-native/src/crossdeck.ts",
|
|
4702
|
+
"sdks/node/src/idempotency-key.ts",
|
|
4703
|
+
"sdks/node/src/crossdeck-server.ts",
|
|
4704
|
+
"sdks/swift/Sources/Crossdeck/IdempotencyKey.swift",
|
|
4705
|
+
"sdks/swift/Sources/Crossdeck/Crossdeck.swift",
|
|
4706
|
+
"sdks/android/crossdeck/src/main/kotlin/com/crossdeck/IdempotencyKey.kt",
|
|
4707
|
+
"sdks/android/crossdeck/src/main/kotlin/com/crossdeck/Crossdeck.kt",
|
|
4708
|
+
"backend/src/lib/idempotency-response-cache.ts",
|
|
4709
|
+
"backend/src/api/v1-purchases.ts"
|
|
4710
|
+
],
|
|
4711
|
+
"testRef": [
|
|
4712
|
+
{
|
|
4713
|
+
"file": "sdks/web/tests/idempotency-key.test.ts",
|
|
4714
|
+
"name": "cross-SDK oracle \u2014 apple JWS pins canonical vector"
|
|
4715
|
+
},
|
|
4716
|
+
{
|
|
4717
|
+
"file": "sdks/web/tests/idempotency-key.test.ts",
|
|
4718
|
+
"name": "is deterministic: same body twice -> identical key"
|
|
4719
|
+
},
|
|
4720
|
+
{
|
|
4721
|
+
"file": "sdks/web/tests/idempotency-key.test.ts",
|
|
4722
|
+
"name": "same identifier under different rails -> different keys"
|
|
4723
|
+
},
|
|
4724
|
+
{
|
|
4725
|
+
"file": "sdks/web/tests/idempotency-key.test.ts",
|
|
4726
|
+
"name": "never silently falls back to a random key on missing identifier"
|
|
4727
|
+
},
|
|
4728
|
+
{
|
|
4729
|
+
"file": "sdks/react-native/tests/idempotency-key.test.ts",
|
|
4730
|
+
"name": "is deterministic"
|
|
4731
|
+
},
|
|
4732
|
+
{
|
|
4733
|
+
"file": "sdks/react-native/tests/idempotency-key.test.ts",
|
|
4734
|
+
"name": "cross-SDK oracle \u2014 apple JWS pins canonical vector"
|
|
4735
|
+
},
|
|
4736
|
+
{
|
|
4737
|
+
"file": "sdks/node/tests/idempotency-key.test.ts",
|
|
4738
|
+
"name": "is deterministic"
|
|
4739
|
+
},
|
|
4740
|
+
{
|
|
4741
|
+
"file": "sdks/node/tests/idempotency-key.test.ts",
|
|
4742
|
+
"name": "rail namespacing prevents cross-rail collisions"
|
|
4743
|
+
},
|
|
4744
|
+
{
|
|
4745
|
+
"file": "sdks/node/tests/idempotency-key.test.ts",
|
|
4746
|
+
"name": "apple JWS produces the canonical pinned UUID across all 5 SDKs"
|
|
4747
|
+
},
|
|
4748
|
+
{
|
|
4749
|
+
"file": "backend/tests/unit/idempotency-response-cache.test.ts",
|
|
4750
|
+
"name": "is deterministic for the same input"
|
|
4751
|
+
},
|
|
4752
|
+
{
|
|
4753
|
+
"file": "backend/tests/unit/idempotency-response-cache.test.ts",
|
|
4754
|
+
"name": "injects idempotent_replay: true into a JSON object body"
|
|
4755
|
+
},
|
|
4756
|
+
{
|
|
4757
|
+
"file": "backend/tests/unit/idempotency-response-cache.test.ts",
|
|
4758
|
+
"name": "matches Stripe's 24-hour idempotency window"
|
|
4759
|
+
},
|
|
4760
|
+
{
|
|
4761
|
+
"file": "sdks/swift/Tests/CrossdeckTests/IdempotencyKeyTests.swift",
|
|
4762
|
+
"name": "test_crossSdkOracle_appleJWS"
|
|
4763
|
+
},
|
|
4764
|
+
{
|
|
4765
|
+
"file": "sdks/swift/Tests/CrossdeckTests/IdempotencyKeyTests.swift",
|
|
4766
|
+
"name": "test_railNamespacing_preventsCrossRailCollisions"
|
|
4767
|
+
},
|
|
4768
|
+
{
|
|
4769
|
+
"file": "sdks/swift/Tests/CrossdeckTests/IdempotencyKeyTests.swift",
|
|
4770
|
+
"name": "test_missingIdentifier_returnsNil"
|
|
4771
|
+
},
|
|
4772
|
+
{
|
|
4773
|
+
"file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/IdempotencyKeyTest.kt",
|
|
4774
|
+
"name": "cross-SDK oracle for apple JWS"
|
|
4775
|
+
},
|
|
4776
|
+
{
|
|
4777
|
+
"file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/IdempotencyKeyTest.kt",
|
|
4778
|
+
"name": "rail namespacing prevents cross-rail collisions"
|
|
4779
|
+
},
|
|
4780
|
+
{
|
|
4781
|
+
"file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/IdempotencyKeyTest.kt",
|
|
4782
|
+
"name": "missing identifier returns null - never silent random fallback"
|
|
4783
|
+
}
|
|
4784
|
+
],
|
|
4785
|
+
"registeredAt": "2026-05-26",
|
|
4786
|
+
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 2.2.a + 2.2.b + 2.2.c",
|
|
4787
|
+
"bundledIn": "@cross-deck/node@1.6.0"
|
|
4788
|
+
},
|
|
4789
|
+
{
|
|
4790
|
+
"id": "node-pii-scrubber",
|
|
4791
|
+
"pillar": "analytics",
|
|
4792
|
+
"status": "enforced",
|
|
4793
|
+
"claim": "Node SDK's track() applies scrubPiiFromProperties on the enqueue path \u2014 parity with Web/RN/Swift. Pre-v1.4.0 the Node SDK was the ONLY one that skipped this, shipping every track() payload UNREDACTED despite the README promising parity. CrossdeckServerOptions.scrubPii defaults to true; explicit false opts out for regulator-required audit trails with a documented blast-radius warning.",
|
|
4794
|
+
"appliesTo": [
|
|
4795
|
+
"node"
|
|
4796
|
+
],
|
|
4797
|
+
"codeRef": [
|
|
4798
|
+
"sdks/node/src/crossdeck-server.ts",
|
|
4799
|
+
"sdks/node/src/types.ts",
|
|
4800
|
+
"sdks/node/src/consent.ts"
|
|
4801
|
+
],
|
|
4802
|
+
"testRef": [
|
|
4803
|
+
{
|
|
4804
|
+
"file": "sdks/node/tests/track-pii-scrub.test.ts",
|
|
4805
|
+
"name": "by default redacts email-shaped values to <email>"
|
|
4806
|
+
},
|
|
4807
|
+
{
|
|
4808
|
+
"file": "sdks/node/tests/track-pii-scrub.test.ts",
|
|
4809
|
+
"name": "redacts card-number-shaped values to <card>"
|
|
4810
|
+
},
|
|
4811
|
+
{
|
|
4812
|
+
"file": "sdks/node/tests/track-pii-scrub.test.ts",
|
|
4813
|
+
"name": "walks nested maps + arrays"
|
|
4814
|
+
},
|
|
4815
|
+
{
|
|
4816
|
+
"file": "sdks/node/tests/track-pii-scrub.test.ts",
|
|
4817
|
+
"name": "scrubPii: false preserves the raw payload (opt-out)"
|
|
4818
|
+
},
|
|
4819
|
+
{
|
|
4820
|
+
"file": "sdks/node/tests/track-pii-scrub.test.ts",
|
|
4821
|
+
"name": "scrubPii: true is the default when option is omitted"
|
|
4822
|
+
}
|
|
4823
|
+
],
|
|
4824
|
+
"registeredAt": "2026-05-26",
|
|
4825
|
+
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 3.1",
|
|
4826
|
+
"bundledIn": "@cross-deck/node@1.6.0"
|
|
4827
|
+
},
|
|
4828
|
+
{
|
|
4829
|
+
"id": "node-shutdown-awaits-flush",
|
|
4830
|
+
"pillar": "lifecycle",
|
|
4831
|
+
"status": "enforced",
|
|
4832
|
+
"claim": "Node SDK's async shutdown() awaits the internal flush() before tearing down the queue. A queue with pending events at sync-shutdown time (shutdownSync() or [Symbol.dispose]) logs a console.warn with the dropped-event count \u2014 silent loss is incompatible with the bank-grade contract. [Symbol.asyncDispose] is equivalent to await server.shutdown().",
|
|
4833
|
+
"appliesTo": [
|
|
4834
|
+
"node"
|
|
4835
|
+
],
|
|
4836
|
+
"codeRef": [
|
|
4837
|
+
"sdks/node/src/crossdeck-server.ts"
|
|
4838
|
+
],
|
|
4839
|
+
"testRef": [
|
|
4840
|
+
{
|
|
4841
|
+
"file": "sdks/node/tests/shutdown-flush.test.ts",
|
|
4842
|
+
"name": "async shutdown() flushes queued events before clearing"
|
|
4843
|
+
},
|
|
4844
|
+
{
|
|
4845
|
+
"file": "sdks/node/tests/shutdown-flush.test.ts",
|
|
4846
|
+
"name": "async shutdown() proceeds with teardown even if flush fails"
|
|
4847
|
+
},
|
|
4848
|
+
{
|
|
4849
|
+
"file": "sdks/node/tests/shutdown-flush.test.ts",
|
|
4850
|
+
"name": "sync shutdownSync() warns when the buffer has events at teardown"
|
|
4851
|
+
},
|
|
4852
|
+
{
|
|
4853
|
+
"file": "sdks/node/tests/shutdown-flush.test.ts",
|
|
4854
|
+
"name": "[Symbol.asyncDispose] equals await server.shutdown()"
|
|
4855
|
+
}
|
|
4856
|
+
],
|
|
4857
|
+
"registeredAt": "2026-05-26",
|
|
4858
|
+
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 5.4",
|
|
4859
|
+
"bundledIn": "@cross-deck/node@1.6.0"
|
|
4860
|
+
},
|
|
4861
|
+
{
|
|
4862
|
+
"id": "sdk-error-codes-catalogue",
|
|
4863
|
+
"pillar": "errors",
|
|
4864
|
+
"status": "enforced",
|
|
4865
|
+
"claim": "Web + Node SDK error-codes catalogues include EVERY backend-emitted ApiErrorCode with a description + resolution. Pre-v1.4.0 the catalogues documented codes the SDK threw ITSELF but ZERO backend codes \u2014 a developer hitting `invalid_api_key` / `origin_not_allowed` / `bundle_id_not_allowed` / `env_mismatch` / `idempotency_key_in_use` etc. got `undefined` from getErrorCode() and had to hunt for guidance. v1.4.0 backfills the catalogue from backend/src/api/v1-errors.ts so every wire code has a canonical 'what does this mean / what should I do' answer Stripe-style.",
|
|
4866
|
+
"appliesTo": [
|
|
4867
|
+
"web",
|
|
4868
|
+
"node"
|
|
4869
|
+
],
|
|
4870
|
+
"codeRef": [
|
|
4871
|
+
"sdks/web/src/error-codes.ts",
|
|
4872
|
+
"sdks/node/src/error-codes.ts",
|
|
4873
|
+
"sdks/web/src/_contract-verifiers.ts",
|
|
4874
|
+
"backend/src/api/v1-errors.ts"
|
|
4875
|
+
],
|
|
4876
|
+
"testRef": [
|
|
4877
|
+
{
|
|
4878
|
+
"file": "sdks/web/tests/contract-verifiers.test.ts",
|
|
4879
|
+
"name": "sdk-error-codes-catalogue covers every backend wire code with remediation"
|
|
4880
|
+
},
|
|
4881
|
+
{
|
|
4882
|
+
"file": "sdks/web/tests/error-codes-backfill.test.ts",
|
|
4883
|
+
"name": "includes backend code"
|
|
4884
|
+
},
|
|
4885
|
+
{
|
|
4886
|
+
"file": "sdks/web/tests/error-codes-backfill.test.ts",
|
|
4887
|
+
"name": "invalid_api_key resolution points at the dashboard"
|
|
4888
|
+
},
|
|
4889
|
+
{
|
|
4890
|
+
"file": "sdks/web/tests/error-codes-backfill.test.ts",
|
|
4891
|
+
"name": "idempotency_key_in_use resolution mentions Stripe-grade contract"
|
|
4892
|
+
},
|
|
4893
|
+
{
|
|
4894
|
+
"file": "sdks/web/tests/error-codes-backfill.test.ts",
|
|
4895
|
+
"name": "identity-lock codes carry permission_error type"
|
|
4896
|
+
},
|
|
4897
|
+
{
|
|
4898
|
+
"file": "sdks/web/tests/error-codes-backfill.test.ts",
|
|
4899
|
+
"name": "no entry has an empty description or resolution"
|
|
4900
|
+
}
|
|
4901
|
+
],
|
|
4902
|
+
"registeredAt": "2026-05-26",
|
|
4903
|
+
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 6.2",
|
|
4904
|
+
"bundledIn": "@cross-deck/node@1.6.0"
|
|
4905
|
+
},
|
|
4906
|
+
{
|
|
4907
|
+
"id": "sync-purchases-funnel-parity",
|
|
4908
|
+
"pillar": "analytics",
|
|
4909
|
+
"status": "enforced",
|
|
4910
|
+
"claim": "Manual syncPurchases() emits a `purchase.completed` analytics event on success across ALL SDKs (Web / Node / RN / Swift / Android). Pre-v1.4.0 only Swift/Android auto-track emitted it \u2014 Web/Node/RN manual calls + Swift/Android manual calls fired ZERO analytics. Schema mirrors the auto-track event name + rail/productId/subscriptionId so cross-platform funnels reconcile on every payment path. When the backend short-circuits via the v1.4.0 idempotency cache, the event also carries `idempotent_replay: true`.",
|
|
4911
|
+
"appliesTo": [
|
|
4912
|
+
"web",
|
|
4913
|
+
"node",
|
|
4914
|
+
"react-native",
|
|
4915
|
+
"swift",
|
|
4916
|
+
"android"
|
|
4917
|
+
],
|
|
4918
|
+
"codeRef": [
|
|
4919
|
+
"sdks/web/src/crossdeck.ts",
|
|
4920
|
+
"sdks/node/src/crossdeck-server.ts",
|
|
4921
|
+
"sdks/react-native/src/crossdeck.ts",
|
|
4922
|
+
"sdks/swift/Sources/Crossdeck/Crossdeck.swift",
|
|
4923
|
+
"sdks/android/crossdeck/src/main/kotlin/com/crossdeck/Crossdeck.kt"
|
|
4924
|
+
],
|
|
4925
|
+
"testRef": [
|
|
4926
|
+
{
|
|
4927
|
+
"file": "sdks/web/tests/sync-purchases-funnel.test.ts",
|
|
4928
|
+
"name": "emits purchase.completed after a successful sync"
|
|
4929
|
+
},
|
|
4930
|
+
{
|
|
4931
|
+
"file": "sdks/web/tests/sync-purchases-funnel.test.ts",
|
|
4932
|
+
"name": "carries idempotent_replay=true when backend replied from cache"
|
|
4933
|
+
}
|
|
4934
|
+
],
|
|
4935
|
+
"registeredAt": "2026-05-26",
|
|
4936
|
+
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 3.5",
|
|
4937
|
+
"bundledIn": "@cross-deck/node@1.6.0"
|
|
4938
|
+
},
|
|
4939
|
+
{
|
|
4940
|
+
"id": "verifier-timestamp-mandatory",
|
|
4941
|
+
"pillar": "webhooks",
|
|
4942
|
+
"status": "enforced",
|
|
4943
|
+
"claim": "Node verifyWebhookSignature() enforces a MANDATORY timestamp window. Pre-v1.4.0 the helper silently disabled replay protection on tolerance=0 (`if (tolerance > 0)` skipped the check) and on Infinity/NaN/null (`Math.abs(...) > Infinity = false`). v1.4.0 rejects non-finite / negative / above-24h-cap tolerances at the boundary with typed `webhook_invalid_tolerance` and always runs the drift check. Verification failures are surfaced via distinguishable codes: `webhook_signature_mismatch` (wrong-secret signal), `webhook_timestamp_outside_tolerance` (replay-attack signal \u2014 alert separately), `webhook_timestamp_missing` (header absent/malformed), `webhook_payload_not_json` (tampered post-signing), `webhook_missing_secret`, `webhook_invalid_tolerance` \u2014 replaces the pre-1.4.0 single `webhook_invalid_signature` catch-all.",
|
|
4944
|
+
"appliesTo": [
|
|
4945
|
+
"node"
|
|
4946
|
+
],
|
|
4947
|
+
"codeRef": [
|
|
4948
|
+
"sdks/node/src/webhooks.ts",
|
|
4949
|
+
"sdks/node/src/error-codes.ts"
|
|
4950
|
+
],
|
|
4951
|
+
"testRef": [
|
|
4952
|
+
{
|
|
4953
|
+
"file": "sdks/node/tests/webhooks.test.ts",
|
|
4954
|
+
"name": "tolerance of 0 still enforces the replay window (v1.4.0 \u2014 cannot disable)"
|
|
4955
|
+
},
|
|
4956
|
+
{
|
|
4957
|
+
"file": "sdks/node/tests/webhooks.test.ts",
|
|
4958
|
+
"name": "rejects Infinity tolerance (would silently disable replay protection)"
|
|
4959
|
+
},
|
|
4960
|
+
{
|
|
4961
|
+
"file": "sdks/node/tests/webhooks.test.ts",
|
|
4962
|
+
"name": "rejects NaN tolerance"
|
|
4963
|
+
},
|
|
4964
|
+
{
|
|
4965
|
+
"file": "sdks/node/tests/webhooks.test.ts",
|
|
4966
|
+
"name": "rejects negative tolerance"
|
|
4967
|
+
},
|
|
4968
|
+
{
|
|
4969
|
+
"file": "sdks/node/tests/webhooks.test.ts",
|
|
4970
|
+
"name": "rejects tolerance above the 24h cap"
|
|
4971
|
+
},
|
|
4972
|
+
{
|
|
4973
|
+
"file": "sdks/node/tests/webhooks.test.ts",
|
|
4974
|
+
"name": "rejects non-number tolerance (null / string)"
|
|
4975
|
+
},
|
|
4976
|
+
{
|
|
4977
|
+
"file": "sdks/node/tests/webhooks.test.ts",
|
|
4978
|
+
"name": "accepts tolerance exactly at the 24h cap"
|
|
4979
|
+
},
|
|
4980
|
+
{
|
|
4981
|
+
"file": "sdks/node/tests/webhooks.test.ts",
|
|
4982
|
+
"name": "malformed header (no t= or no v1=) throws webhook_timestamp_missing"
|
|
4983
|
+
},
|
|
4984
|
+
{
|
|
4985
|
+
"file": "sdks/node/tests/webhooks.test.ts",
|
|
4986
|
+
"name": "valid signature but non-JSON payload throws webhook_payload_not_json"
|
|
4987
|
+
}
|
|
4988
|
+
],
|
|
4989
|
+
"registeredAt": "2026-05-26",
|
|
4990
|
+
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 7.2",
|
|
4991
|
+
"bundledIn": "@cross-deck/node@1.6.0"
|
|
4028
4992
|
}
|
|
4029
|
-
|
|
4030
|
-
|
|
4031
|
-
|
|
4032
|
-
|
|
4033
|
-
|
|
4034
|
-
|
|
4035
|
-
|
|
4993
|
+
]);
|
|
4994
|
+
|
|
4995
|
+
// src/contracts.ts
|
|
4996
|
+
var CrossdeckContracts = {
|
|
4997
|
+
all() {
|
|
4998
|
+
return BUNDLED_CONTRACTS.filter((c) => c.status === "enforced");
|
|
4999
|
+
},
|
|
5000
|
+
allIncludingHistorical() {
|
|
5001
|
+
return BUNDLED_CONTRACTS;
|
|
5002
|
+
},
|
|
5003
|
+
byId(id) {
|
|
5004
|
+
return BUNDLED_CONTRACTS.find((c) => c.id === id);
|
|
5005
|
+
},
|
|
5006
|
+
byPillar(pillar) {
|
|
5007
|
+
return BUNDLED_CONTRACTS.filter(
|
|
5008
|
+
(c) => c.pillar === pillar && c.status === "enforced"
|
|
5009
|
+
);
|
|
5010
|
+
},
|
|
5011
|
+
withStatus(status) {
|
|
5012
|
+
return BUNDLED_CONTRACTS.filter((c) => c.status === status);
|
|
5013
|
+
},
|
|
5014
|
+
sdkVersion: SDK_VERSION2,
|
|
5015
|
+
bundledIn: BUNDLED_IN,
|
|
5016
|
+
/**
|
|
5017
|
+
* Resolve a failing test back to the contract it exercises.
|
|
5018
|
+
* Used by test-framework hooks to find the contract id of a
|
|
5019
|
+
* failed contract test so `reportContractFailure(...)` can stamp
|
|
5020
|
+
* the right `contract_id` on the emitted event.
|
|
5021
|
+
*/
|
|
5022
|
+
findByTestName(name) {
|
|
5023
|
+
return BUNDLED_CONTRACTS.find(
|
|
5024
|
+
(c) => c.testRef.some((ref) => ref.name === name)
|
|
5025
|
+
);
|
|
4036
5026
|
}
|
|
4037
|
-
|
|
4038
|
-
}
|
|
5027
|
+
};
|
|
4039
5028
|
export {
|
|
4040
5029
|
CROSSDECK_API_VERSION,
|
|
4041
5030
|
CROSSDECK_ERROR_CODES,
|
|
4042
5031
|
CrossdeckAuthenticationError,
|
|
4043
5032
|
CrossdeckConfigurationError,
|
|
5033
|
+
CrossdeckContracts,
|
|
4044
5034
|
CrossdeckError,
|
|
4045
5035
|
CrossdeckInternalError,
|
|
4046
5036
|
CrossdeckNetworkError,
|