@cross-deck/node 1.2.0 → 1.5.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 +150 -0
- package/README.md +62 -0
- package/dist/auto-events/index.d.mts +1 -1
- package/dist/auto-events/index.d.ts +1 -1
- package/dist/contracts.json +430 -0
- package/dist/{crossdeck-server-BZVZEuS-.d.mts → crossdeck-server-oAaKBnUU.d.mts} +219 -32
- package/dist/{crossdeck-server-BZVZEuS-.d.ts → crossdeck-server-oAaKBnUU.d.ts} +219 -32
- package/dist/index.cjs +957 -122
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +182 -26
- package/dist/index.d.ts +182 -26
- package/dist/index.mjs +952 -118
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -3
package/dist/index.mjs
CHANGED
|
@@ -316,9 +316,11 @@ function byteLength(s) {
|
|
|
316
316
|
return s.length * 4;
|
|
317
317
|
}
|
|
318
318
|
|
|
319
|
-
// src/
|
|
319
|
+
// src/_version.ts
|
|
320
|
+
var SDK_VERSION = "1.4.2";
|
|
320
321
|
var SDK_NAME = "@cross-deck/node";
|
|
321
|
-
|
|
322
|
+
|
|
323
|
+
// src/http.ts
|
|
322
324
|
var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
|
|
323
325
|
var DEFAULT_TIMEOUT_MS = 15e3;
|
|
324
326
|
var CROSSDECK_API_VERSION = "2025-01-01";
|
|
@@ -831,6 +833,7 @@ var EventQueue = class {
|
|
|
831
833
|
sdk: env.sdk
|
|
832
834
|
};
|
|
833
835
|
if (env.appId) body.appId = env.appId;
|
|
836
|
+
if (env.environment) body.environment = env.environment;
|
|
834
837
|
const result = await this.cfg.http.request("POST", "/events", {
|
|
835
838
|
body,
|
|
836
839
|
idempotencyKey: batchId
|
|
@@ -849,6 +852,20 @@ var EventQueue = class {
|
|
|
849
852
|
} catch (err) {
|
|
850
853
|
const message = err instanceof Error ? err.message : String(err);
|
|
851
854
|
this.lastError = message;
|
|
855
|
+
if (isPermanent4xx(err)) {
|
|
856
|
+
const droppedCount = batch.length;
|
|
857
|
+
this.pendingBatch = null;
|
|
858
|
+
this.pendingBatchId = null;
|
|
859
|
+
this.inFlight -= droppedCount;
|
|
860
|
+
this.dropped += droppedCount;
|
|
861
|
+
this.cfg.onDrop?.(droppedCount);
|
|
862
|
+
this.cfg.onPermanentFailure?.({
|
|
863
|
+
status: err.status ?? 0,
|
|
864
|
+
droppedCount,
|
|
865
|
+
lastError: message
|
|
866
|
+
});
|
|
867
|
+
return null;
|
|
868
|
+
}
|
|
852
869
|
const retryAfterMs = extractRetryAfterMs(err);
|
|
853
870
|
const delay = this.retry.nextDelay(retryAfterMs);
|
|
854
871
|
this.scheduleRetry(delay);
|
|
@@ -927,6 +944,14 @@ function extractRetryAfterMs(err) {
|
|
|
927
944
|
}
|
|
928
945
|
return void 0;
|
|
929
946
|
}
|
|
947
|
+
function isPermanent4xx(err) {
|
|
948
|
+
if (!err || typeof err !== "object") return false;
|
|
949
|
+
const status = err.status;
|
|
950
|
+
if (typeof status !== "number" || !Number.isFinite(status)) return false;
|
|
951
|
+
if (status < 400 || status >= 500) return false;
|
|
952
|
+
if (status === 408 || status === 429) return false;
|
|
953
|
+
return true;
|
|
954
|
+
}
|
|
930
955
|
function defaultScheduler(fn, ms) {
|
|
931
956
|
const id = setTimeout(fn, ms);
|
|
932
957
|
if (typeof id.unref === "function") {
|
|
@@ -1216,16 +1241,18 @@ var ErrorTracker = class {
|
|
|
1216
1241
|
const url = typeof input === "string" ? input : input?.url ?? "";
|
|
1217
1242
|
const method = (init.method || "GET").toUpperCase();
|
|
1218
1243
|
const start = Date.now();
|
|
1219
|
-
tracker.opts.
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1244
|
+
if (!isSelfRequest(url, tracker.opts.selfHostname)) {
|
|
1245
|
+
tracker.opts.breadcrumbs.add({
|
|
1246
|
+
timestamp: start,
|
|
1247
|
+
category: "http",
|
|
1248
|
+
message: `${method} ${url}`,
|
|
1249
|
+
data: { url, method }
|
|
1250
|
+
});
|
|
1251
|
+
}
|
|
1225
1252
|
try {
|
|
1226
1253
|
const response = await origFetch(...args);
|
|
1227
1254
|
if (response.status >= 500 && tracker.opts.isConsented()) {
|
|
1228
|
-
if (!url.
|
|
1255
|
+
if (!isSelfRequest(url, tracker.opts.selfHostname)) {
|
|
1229
1256
|
tracker.captureHttp({
|
|
1230
1257
|
url,
|
|
1231
1258
|
method,
|
|
@@ -1343,9 +1370,10 @@ var ErrorTracker = class {
|
|
|
1343
1370
|
if (!this.passesSample(err)) return;
|
|
1344
1371
|
if (!this.passesRateLimit(err)) return;
|
|
1345
1372
|
let finalErr = err;
|
|
1346
|
-
|
|
1373
|
+
const hook = this.opts.beforeSend?.();
|
|
1374
|
+
if (hook) {
|
|
1347
1375
|
try {
|
|
1348
|
-
finalErr =
|
|
1376
|
+
finalErr = hook(err);
|
|
1349
1377
|
} catch {
|
|
1350
1378
|
finalErr = err;
|
|
1351
1379
|
}
|
|
@@ -1571,6 +1599,22 @@ function safeClone(v) {
|
|
|
1571
1599
|
function safeStringify3(v) {
|
|
1572
1600
|
return coerceErrorPayload(v).message;
|
|
1573
1601
|
}
|
|
1602
|
+
function extractSelfHostname(baseUrl) {
|
|
1603
|
+
if (!baseUrl || typeof baseUrl !== "string") return null;
|
|
1604
|
+
try {
|
|
1605
|
+
return new URL(baseUrl).hostname.toLowerCase();
|
|
1606
|
+
} catch {
|
|
1607
|
+
return null;
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
function isSelfRequest(requestUrl, selfHostname) {
|
|
1611
|
+
if (!selfHostname || !requestUrl) return false;
|
|
1612
|
+
try {
|
|
1613
|
+
return new URL(requestUrl).hostname.toLowerCase() === selfHostname;
|
|
1614
|
+
} catch {
|
|
1615
|
+
return false;
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1574
1618
|
|
|
1575
1619
|
// src/runtime-info.ts
|
|
1576
1620
|
import { hostname as osHostname, platform as osPlatform, release as osRelease } from "os";
|
|
@@ -2305,6 +2349,63 @@ var EntitlementCache = class {
|
|
|
2305
2349
|
}
|
|
2306
2350
|
};
|
|
2307
2351
|
|
|
2352
|
+
// src/consent.ts
|
|
2353
|
+
var EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
|
|
2354
|
+
var CARD_PATTERN = /\b\d(?:[ -]?\d){12,18}\b/g;
|
|
2355
|
+
var REPLACEMENT_EMAIL = "<email>";
|
|
2356
|
+
var REPLACEMENT_CARD = "<card>";
|
|
2357
|
+
function scrubPii(value) {
|
|
2358
|
+
if (!value) return value;
|
|
2359
|
+
return value.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL).replace(CARD_PATTERN, REPLACEMENT_CARD);
|
|
2360
|
+
}
|
|
2361
|
+
function scrubPiiFromProperties(properties) {
|
|
2362
|
+
const out = {};
|
|
2363
|
+
for (const k of Object.keys(properties)) {
|
|
2364
|
+
out[k] = scrubValue(properties[k]);
|
|
2365
|
+
}
|
|
2366
|
+
return out;
|
|
2367
|
+
}
|
|
2368
|
+
function scrubValue(v) {
|
|
2369
|
+
if (typeof v === "string") return scrubPii(v);
|
|
2370
|
+
if (Array.isArray(v)) return v.map(scrubValue);
|
|
2371
|
+
if (v && typeof v === "object" && v.constructor === Object) {
|
|
2372
|
+
return scrubPiiFromProperties(v);
|
|
2373
|
+
}
|
|
2374
|
+
return v;
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
// src/idempotency-key.ts
|
|
2378
|
+
import { createHash } from "crypto";
|
|
2379
|
+
function formatAsUuid(hex) {
|
|
2380
|
+
return [
|
|
2381
|
+
hex.slice(0, 8),
|
|
2382
|
+
hex.slice(8, 12),
|
|
2383
|
+
hex.slice(12, 16),
|
|
2384
|
+
hex.slice(16, 20),
|
|
2385
|
+
hex.slice(20, 32)
|
|
2386
|
+
].join("-");
|
|
2387
|
+
}
|
|
2388
|
+
function sha256Hex(input) {
|
|
2389
|
+
return createHash("sha256").update(input, "utf8").digest("hex");
|
|
2390
|
+
}
|
|
2391
|
+
function deriveIdempotencyKeyForPurchase(body) {
|
|
2392
|
+
let identifier;
|
|
2393
|
+
if (body.rail === "apple") {
|
|
2394
|
+
identifier = body.signedTransactionInfo ?? "";
|
|
2395
|
+
} else if (body.rail === "google") {
|
|
2396
|
+
identifier = body.purchaseToken ?? "";
|
|
2397
|
+
} else {
|
|
2398
|
+
identifier = "";
|
|
2399
|
+
}
|
|
2400
|
+
if (!identifier) {
|
|
2401
|
+
throw new Error(
|
|
2402
|
+
`deriveIdempotencyKeyForPurchase: no stable identifier in body (rail=${body.rail}). Apple needs signedTransactionInfo; Google needs purchaseToken.`
|
|
2403
|
+
);
|
|
2404
|
+
}
|
|
2405
|
+
const namespaced = `crossdeck:purchases/sync:${body.rail}:${identifier}`;
|
|
2406
|
+
return formatAsUuid(sha256Hex(namespaced));
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2308
2409
|
// src/debug.ts
|
|
2309
2410
|
var SENSITIVE_KEY_PATTERNS = [
|
|
2310
2411
|
/^email$/i,
|
|
@@ -2364,6 +2465,10 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
2364
2465
|
baseUrl;
|
|
2365
2466
|
appId;
|
|
2366
2467
|
env;
|
|
2468
|
+
/** PII scrubber toggle. Default true — parity with Web/RN/Swift.
|
|
2469
|
+
* Pre-v1.4.0 the Node SDK shipped track() payloads UNREDACTED,
|
|
2470
|
+
* a privacy contract drift versus the README. */
|
|
2471
|
+
scrubPii;
|
|
2367
2472
|
secretKeyPrefix;
|
|
2368
2473
|
/**
|
|
2369
2474
|
* Process-stable pseudo-anonymous ID. Used as the default identity
|
|
@@ -2409,6 +2514,15 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
2409
2514
|
errorContext = {};
|
|
2410
2515
|
errorTags = {};
|
|
2411
2516
|
errorBeforeSend = null;
|
|
2517
|
+
/**
|
|
2518
|
+
* Dedup gate for `sdk.shutdown`. Both `shutdown()` (async) and
|
|
2519
|
+
* `shutdownSync()` need to emit so direct callers of EITHER see
|
|
2520
|
+
* the event (the async path's listener guarantees pre-launch
|
|
2521
|
+
* tests, the sync path covers `Symbol.dispose` + tests that call
|
|
2522
|
+
* `shutdownSync()` directly). Without this flag, `shutdown()`'s
|
|
2523
|
+
* tail call into `shutdownSync()` would emit twice.
|
|
2524
|
+
*/
|
|
2525
|
+
didEmitShutdown = false;
|
|
2412
2526
|
constructor(options) {
|
|
2413
2527
|
super();
|
|
2414
2528
|
if (!options.secretKey || !options.secretKey.startsWith("cd_sk_")) {
|
|
@@ -2423,6 +2537,7 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
2423
2537
|
this.baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
|
|
2424
2538
|
this.env = inferEnvFromKey(options.secretKey);
|
|
2425
2539
|
this.secretKeyPrefix = maskSecretKey(options.secretKey);
|
|
2540
|
+
this.scrubPii = options.scrubPii !== false;
|
|
2426
2541
|
this.http = new HttpClient({
|
|
2427
2542
|
secretKey: options.secretKey,
|
|
2428
2543
|
baseUrl: this.baseUrl,
|
|
@@ -2462,9 +2577,16 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
2462
2577
|
this.eventQueue = new EventQueue({
|
|
2463
2578
|
http: this.http,
|
|
2464
2579
|
batchSize: options.eventFlushBatchSize ?? 20,
|
|
2465
|
-
|
|
2580
|
+
// v1.4.0 Phase 3.3 — flush interval default parity at 2000ms
|
|
2581
|
+
// across every SDK. Per-instance override stays.
|
|
2582
|
+
intervalMs: options.eventFlushIntervalMs ?? 2e3,
|
|
2466
2583
|
envelope: () => ({
|
|
2467
2584
|
appId: this.appId,
|
|
2585
|
+
// Ship env on every batch so the backend can cross-check
|
|
2586
|
+
// against the API-key-derived env and reject mismatches
|
|
2587
|
+
// loudly (env_mismatch). Web has always done this; node now
|
|
2588
|
+
// matches so defence-in-depth is symmetric across SDKs.
|
|
2589
|
+
environment: this.env,
|
|
2468
2590
|
sdk: { name: SDK_NAME, version: this.sdkVersion }
|
|
2469
2591
|
}),
|
|
2470
2592
|
onDrop: (count) => {
|
|
@@ -2480,6 +2602,20 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
2480
2602
|
nextRetryMs: info.delayMs
|
|
2481
2603
|
});
|
|
2482
2604
|
},
|
|
2605
|
+
onPermanentFailure: (info) => {
|
|
2606
|
+
const headline = `[crossdeck] Event batch DROPPED (status ${info.status}): ${info.lastError}. ${info.droppedCount} event(s) lost \u2014 check your secret key + app config.`;
|
|
2607
|
+
console.error(headline);
|
|
2608
|
+
this.debug.emit(
|
|
2609
|
+
"sdk.flush_permanent_failure",
|
|
2610
|
+
headline,
|
|
2611
|
+
{ ...info }
|
|
2612
|
+
);
|
|
2613
|
+
this.emit("queue.permanent_failure", {
|
|
2614
|
+
status: info.status,
|
|
2615
|
+
droppedCount: info.droppedCount,
|
|
2616
|
+
error: info.lastError
|
|
2617
|
+
});
|
|
2618
|
+
},
|
|
2483
2619
|
onFirstFlushSuccess: () => {
|
|
2484
2620
|
this.debug.emit("sdk.first_event_sent", "First batch landed.");
|
|
2485
2621
|
}
|
|
@@ -2494,14 +2630,20 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
2494
2630
|
report: (err) => this.reportCapturedError(err),
|
|
2495
2631
|
getContext: () => ({ ...this.errorContext }),
|
|
2496
2632
|
getTags: () => ({ ...this.errorTags }),
|
|
2497
|
-
|
|
2498
|
-
//
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2633
|
+
// GETTER, not a captured value — `setErrorBeforeSend()` mutates
|
|
2634
|
+
// `this.errorBeforeSend` after init() and the tracker MUST pick
|
|
2635
|
+
// up the new hook on the next error. Pre-fix we worked around
|
|
2636
|
+
// a captured-by-value field with `Object.defineProperty` on the
|
|
2637
|
+
// tracker's private opts; the contract is now a real getter so
|
|
2638
|
+
// we just hand it the closure and the hack is gone.
|
|
2639
|
+
beforeSend: () => this.errorBeforeSend,
|
|
2640
|
+
isConsented: () => true,
|
|
2641
|
+
// Derived from the configured baseUrl at construction time.
|
|
2642
|
+
// Used by the fetch wrapper to skip captureHttp on Crossdeck's
|
|
2643
|
+
// own requests — pre-fix the skip was hardcoded to
|
|
2644
|
+
// `api.cross-deck.com` and broke for customers on staging /
|
|
2645
|
+
// regional / self-hosted base URLs (recursive capture loop).
|
|
2646
|
+
selfHostname: extractSelfHostname(this.baseUrl)
|
|
2505
2647
|
});
|
|
2506
2648
|
this.errorTracker.install();
|
|
2507
2649
|
}
|
|
@@ -2514,6 +2656,7 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
2514
2656
|
});
|
|
2515
2657
|
this.flushOnExit.install();
|
|
2516
2658
|
}
|
|
2659
|
+
this.emitDurabilityWarning();
|
|
2517
2660
|
if (options.testMode !== true && options.bootHeartbeat !== false) {
|
|
2518
2661
|
setImmediate(() => {
|
|
2519
2662
|
void this.heartbeat().catch((err) => {
|
|
@@ -2523,25 +2666,16 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
2523
2666
|
{ message: err instanceof Error ? err.message : String(err) }
|
|
2524
2667
|
);
|
|
2525
2668
|
});
|
|
2526
|
-
this.
|
|
2669
|
+
this.emitBootTelemetryEvent();
|
|
2527
2670
|
});
|
|
2528
2671
|
}
|
|
2529
2672
|
}
|
|
2530
2673
|
/**
|
|
2531
|
-
* Emit the
|
|
2532
|
-
* is serverless
|
|
2533
|
-
*
|
|
2534
|
-
*
|
|
2535
|
-
*
|
|
2536
|
-
* carries no request body, so it cannot transport a structured
|
|
2537
|
-
* `durability` fact. The event pipeline can — every `track()` event
|
|
2538
|
-
* lands as an aggregatable document the backend can query, so
|
|
2539
|
-
* Crossdeck can compute fleet-wide "% serverless-with-no-durable-
|
|
2540
|
-
* store" from `sdk.boot` events (denominator = all `sdk.boot`,
|
|
2541
|
-
* numerator = those with `durability.coldStartDurable === false`).
|
|
2542
|
-
* The event rides the existing batched + retried + idempotent queue
|
|
2543
|
-
* and is drained by flush-on-exit, so it survives a serverless
|
|
2544
|
-
* teardown — it is NOT a local-only debug log.
|
|
2674
|
+
* Emit the honest "no cold-start durability" warning when the runtime
|
|
2675
|
+
* is serverless AND no `entitlementStore` is wired. Local-only debug
|
|
2676
|
+
* signal — no network call, no phone-home. Safe to fire from the
|
|
2677
|
+
* constructor before `setImmediate` because there is no I/O on this
|
|
2678
|
+
* path.
|
|
2545
2679
|
*
|
|
2546
2680
|
* `isServerless` AND no store is the gap: a cold start begins with an
|
|
2547
2681
|
* empty in-memory cache and a brief Crossdeck outage in that window
|
|
@@ -2549,14 +2683,15 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
2549
2683
|
* unavoidable without a store — so the SDK STATES it (a
|
|
2550
2684
|
* `sdk.no_durable_store` debug warning) rather than hiding it.
|
|
2551
2685
|
*
|
|
2552
|
-
*
|
|
2553
|
-
*
|
|
2554
|
-
* the
|
|
2686
|
+
* Audit P1 #9: this used to live INSIDE `emitBootTelemetry()` which
|
|
2687
|
+
* itself sat inside the `bootHeartbeat` gate, so any developer who
|
|
2688
|
+
* set `bootHeartbeat: false` silently disabled the entire reason
|
|
2689
|
+
* `entitlementStore` exists. Now split: warning fires
|
|
2690
|
+
* unconditionally; the boot phone-home stays gated.
|
|
2555
2691
|
*/
|
|
2556
|
-
|
|
2692
|
+
emitDurabilityWarning() {
|
|
2557
2693
|
const isServerless = this.runtime.isServerless;
|
|
2558
2694
|
const hasStore = this.entitlementStore !== null;
|
|
2559
|
-
const coldStartDurable = hasStore || !isServerless;
|
|
2560
2695
|
if (isServerless && !hasStore) {
|
|
2561
2696
|
this.debug.emit(
|
|
2562
2697
|
"sdk.no_durable_store",
|
|
@@ -2564,6 +2699,26 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
2564
2699
|
{ host: this.runtime.host, isServerless, durableStore: false }
|
|
2565
2700
|
);
|
|
2566
2701
|
}
|
|
2702
|
+
}
|
|
2703
|
+
/**
|
|
2704
|
+
* Emit the one-time `sdk.boot` telemetry event — the aggregatable
|
|
2705
|
+
* fact the backend pivots on (compute fleet-wide
|
|
2706
|
+
* "% serverless-with-no-durable-store"). Rides the batched + retried
|
|
2707
|
+
* + idempotent queue and is drained by flush-on-exit, so it survives
|
|
2708
|
+
* a serverless teardown.
|
|
2709
|
+
*
|
|
2710
|
+
* Why a `track()` event and not the heartbeat: `GET /v1/sdk/heartbeat`
|
|
2711
|
+
* carries no request body, so it cannot transport a structured
|
|
2712
|
+
* `durability` fact.
|
|
2713
|
+
*
|
|
2714
|
+
* Gated by `bootHeartbeat` (and `testMode`) because it IS a phone-
|
|
2715
|
+
* home — the unconditional surface is `emitDurabilityWarning()`,
|
|
2716
|
+
* which has no network call.
|
|
2717
|
+
*/
|
|
2718
|
+
emitBootTelemetryEvent() {
|
|
2719
|
+
const isServerless = this.runtime.isServerless;
|
|
2720
|
+
const hasStore = this.entitlementStore !== null;
|
|
2721
|
+
const coldStartDurable = hasStore || !isServerless;
|
|
2567
2722
|
try {
|
|
2568
2723
|
this.track({
|
|
2569
2724
|
name: "sdk.boot",
|
|
@@ -2803,6 +2958,38 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
2803
2958
|
* `uncaughtException` has no per-request context; without the
|
|
2804
2959
|
* auto-fill, the event would be rejected at queue enqueue.
|
|
2805
2960
|
*/
|
|
2961
|
+
/**
|
|
2962
|
+
* Emit `crossdeck.contract_failed` with the canonical property
|
|
2963
|
+
* shape. Same wire shape every Crossdeck SDK uses for contract
|
|
2964
|
+
* verification telemetry — see `contracts/README.md` for the
|
|
2965
|
+
* full pattern. No new endpoint, no special path; goes through
|
|
2966
|
+
* the standard server-side `track()` pipeline.
|
|
2967
|
+
*/
|
|
2968
|
+
reportContractFailure(input) {
|
|
2969
|
+
const props = {
|
|
2970
|
+
contract_id: input.contractId,
|
|
2971
|
+
sdk_version: SDK_VERSION,
|
|
2972
|
+
sdk_platform: "node",
|
|
2973
|
+
failure_reason: input.failureReason,
|
|
2974
|
+
run_context: input.runContext,
|
|
2975
|
+
run_id: input.runId
|
|
2976
|
+
};
|
|
2977
|
+
if (input.testRef) {
|
|
2978
|
+
props.test_file = input.testRef.file;
|
|
2979
|
+
props.test_name = input.testRef.name;
|
|
2980
|
+
}
|
|
2981
|
+
if (input.extra) {
|
|
2982
|
+
for (const [k, v] of Object.entries(input.extra)) {
|
|
2983
|
+
if (props[k] === void 0) props[k] = v;
|
|
2984
|
+
}
|
|
2985
|
+
}
|
|
2986
|
+
this.track({
|
|
2987
|
+
name: "crossdeck.contract_failed",
|
|
2988
|
+
properties: props
|
|
2989
|
+
// No identity hint — these events are about the SDK / dogfood
|
|
2990
|
+
// run itself, not a specific end-user.
|
|
2991
|
+
});
|
|
2992
|
+
}
|
|
2806
2993
|
track(event) {
|
|
2807
2994
|
if (!event.name) {
|
|
2808
2995
|
throw new CrossdeckError({
|
|
@@ -2811,7 +2998,8 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
2811
2998
|
message: "track(event) requires a non-empty event.name."
|
|
2812
2999
|
});
|
|
2813
3000
|
}
|
|
2814
|
-
const
|
|
3001
|
+
const validated = sanitizePropertyBag(event.properties, "event properties") ?? {};
|
|
3002
|
+
const sanitized = this.scrubPii ? scrubPiiFromProperties(validated) : validated;
|
|
2815
3003
|
if (this.debug.enabled) {
|
|
2816
3004
|
const flagged = findSensitivePropertyKeys(sanitized);
|
|
2817
3005
|
if (flagged.length > 0) {
|
|
@@ -2875,7 +3063,13 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
2875
3063
|
const normalized = events.map((event) => this.normalizeIngestEvent(event));
|
|
2876
3064
|
const body = {
|
|
2877
3065
|
events: normalized,
|
|
2878
|
-
sdk: { name: SDK_NAME, version: this.sdkVersion }
|
|
3066
|
+
sdk: { name: SDK_NAME, version: this.sdkVersion },
|
|
3067
|
+
// Match the queue's batch envelope (see event-queue.ts) — backend
|
|
3068
|
+
// cross-checks `environment` against the API-key-derived env and
|
|
3069
|
+
// rejects mismatches loudly (env_mismatch). Pre-fix this direct
|
|
3070
|
+
// ingest path skipped env, so a "live key, env: sandbox"
|
|
3071
|
+
// misconfig fell through silently for the bulk-import path.
|
|
3072
|
+
environment: this.env
|
|
2879
3073
|
};
|
|
2880
3074
|
if (this.appId) body.appId = this.appId;
|
|
2881
3075
|
return this.http.request("POST", "/events", {
|
|
@@ -2940,11 +3134,26 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
2940
3134
|
message: "syncPurchases requires a signedTransactionInfo string."
|
|
2941
3135
|
});
|
|
2942
3136
|
}
|
|
2943
|
-
|
|
2944
|
-
|
|
3137
|
+
const rail = input.rail ?? "apple";
|
|
3138
|
+
const body = { ...input, rail };
|
|
3139
|
+
const idempotencyKey = options?.idempotencyKey ?? deriveIdempotencyKeyForPurchase(body);
|
|
3140
|
+
const result = await this.http.request("POST", "/purchases/sync", {
|
|
3141
|
+
body,
|
|
3142
|
+
idempotencyKey,
|
|
2945
3143
|
signal: options?.signal,
|
|
2946
3144
|
timeoutMs: options?.timeoutMs
|
|
2947
3145
|
});
|
|
3146
|
+
try {
|
|
3147
|
+
const sourceProductId = result.entitlements[0]?.source.productId;
|
|
3148
|
+
const sourceSubscriptionId = result.entitlements[0]?.source.subscriptionId;
|
|
3149
|
+
const props = { rail };
|
|
3150
|
+
if (sourceProductId) props.productId = sourceProductId;
|
|
3151
|
+
if (sourceSubscriptionId) props.subscriptionId = sourceSubscriptionId;
|
|
3152
|
+
if (result.idempotent_replay) props.idempotent_replay = true;
|
|
3153
|
+
this.track({ name: "purchase.completed", properties: props });
|
|
3154
|
+
} catch {
|
|
3155
|
+
}
|
|
3156
|
+
return result;
|
|
2948
3157
|
}
|
|
2949
3158
|
// ============================================================
|
|
2950
3159
|
// Manual entitlement controls + audit — direct HTTP
|
|
@@ -3236,12 +3445,56 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
3236
3445
|
};
|
|
3237
3446
|
}
|
|
3238
3447
|
/**
|
|
3239
|
-
* Tear down handlers and clear in-memory state.
|
|
3240
|
-
*
|
|
3241
|
-
* `flush
|
|
3448
|
+
* Tear down handlers and clear in-memory state.
|
|
3449
|
+
*
|
|
3450
|
+
* **v1.4.0 bank-grade contract:** `shutdown()` AWAITS `flush()`
|
|
3451
|
+
* before dropping the queue, so callers don't silently lose
|
|
3452
|
+
* every queued event on a clean shutdown. The pre-v1.4.0
|
|
3453
|
+
* behaviour (sync `eventQueue.reset()` with no flush) was the
|
|
3454
|
+
* default for both `shutdown()` and `[Symbol.dispose]`; only
|
|
3455
|
+
* `await using` + `[Symbol.asyncDispose]` flushed correctly.
|
|
3456
|
+
*
|
|
3457
|
+
* Production servers should still prefer `await server.flush()`
|
|
3458
|
+
* (visible) followed by `server.shutdown()` so the flush
|
|
3459
|
+
* outcome is observable — `shutdown()`'s internal flush swallows
|
|
3460
|
+
* errors as a best-effort drain.
|
|
3461
|
+
*
|
|
3462
|
+
* Use [[shutdownSync]] only when the runtime cannot await
|
|
3463
|
+
* (e.g. inside `Symbol.dispose` — see below).
|
|
3464
|
+
*/
|
|
3465
|
+
async shutdown(reason = "shutdown") {
|
|
3466
|
+
if (!this.didEmitShutdown) {
|
|
3467
|
+
this.emit("sdk.shutdown", { reason });
|
|
3468
|
+
this.didEmitShutdown = true;
|
|
3469
|
+
}
|
|
3470
|
+
try {
|
|
3471
|
+
await this.flush();
|
|
3472
|
+
} catch {
|
|
3473
|
+
}
|
|
3474
|
+
this.shutdownSync(reason);
|
|
3475
|
+
}
|
|
3476
|
+
/**
|
|
3477
|
+
* Synchronous teardown — drops the in-memory queue WITHOUT
|
|
3478
|
+
* flushing, then clears all in-memory state. Used by
|
|
3479
|
+
* `[Symbol.dispose]` (which has no await) and tests that need
|
|
3480
|
+
* an unconditional sync wipe. Production code should use
|
|
3481
|
+
* [[shutdown]] (async) instead so queued events are flushed.
|
|
3482
|
+
*
|
|
3483
|
+
* A queue with items at sync-shutdown logs a warning recommending
|
|
3484
|
+
* `[Symbol.asyncDispose]` or `await server.shutdown()` — silent
|
|
3485
|
+
* loss is incompatible with the bank-grade contract.
|
|
3242
3486
|
*/
|
|
3243
|
-
|
|
3244
|
-
this.
|
|
3487
|
+
shutdownSync(reason = "shutdown") {
|
|
3488
|
+
if (!this.didEmitShutdown) {
|
|
3489
|
+
this.emit("sdk.shutdown", { reason });
|
|
3490
|
+
this.didEmitShutdown = true;
|
|
3491
|
+
}
|
|
3492
|
+
const queuedCount = this.eventQueue.getStats().buffered;
|
|
3493
|
+
if (queuedCount > 0 && reason !== "asyncDispose") {
|
|
3494
|
+
console.warn(
|
|
3495
|
+
`[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.`
|
|
3496
|
+
);
|
|
3497
|
+
}
|
|
3245
3498
|
this.errorTracker?.uninstall();
|
|
3246
3499
|
this.flushOnExit?.uninstall();
|
|
3247
3500
|
this.eventQueue.reset();
|
|
@@ -3355,28 +3608,28 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
3355
3608
|
* // ... use server ...
|
|
3356
3609
|
* // at end of block, server[Symbol.dispose]() runs automatically
|
|
3357
3610
|
*
|
|
3358
|
-
*
|
|
3359
|
-
*
|
|
3360
|
-
*
|
|
3361
|
-
*
|
|
3611
|
+
* **`Symbol.dispose` is synchronous so it CANNOT await the queue
|
|
3612
|
+
* flush.** A queue with pending events at sync-dispose time will
|
|
3613
|
+
* be DROPPED — `shutdownSync` warns to the console when this
|
|
3614
|
+
* happens. For the common case of "drain the queue before
|
|
3615
|
+
* exit", switch to `await using` + `[Symbol.asyncDispose]` (or
|
|
3616
|
+
* call `await server.shutdown()` explicitly before the variable
|
|
3617
|
+
* goes out of scope).
|
|
3362
3618
|
*/
|
|
3363
3619
|
[Symbol.dispose]() {
|
|
3364
|
-
this.
|
|
3620
|
+
this.shutdownSync("dispose");
|
|
3365
3621
|
}
|
|
3366
3622
|
/**
|
|
3367
3623
|
* Async disposal hook — runs when an `await using` declaration
|
|
3368
|
-
* exits scope. Awaits
|
|
3369
|
-
*
|
|
3370
|
-
*
|
|
3624
|
+
* exits scope. Awaits the bank-grade `shutdown()` which flushes
|
|
3625
|
+
* the queue THEN tears down. Use this variant for any code path
|
|
3626
|
+
* that owns queued events at exit (serverless handlers,
|
|
3627
|
+
* background workers, end-of-request hooks).
|
|
3371
3628
|
*
|
|
3372
3629
|
* await using server = new CrossdeckServer({ ... });
|
|
3373
3630
|
*/
|
|
3374
3631
|
async [Symbol.asyncDispose]() {
|
|
3375
|
-
|
|
3376
|
-
await this.flush();
|
|
3377
|
-
} catch {
|
|
3378
|
-
}
|
|
3379
|
-
this.shutdown("asyncDispose");
|
|
3632
|
+
await this.shutdown("asyncDispose");
|
|
3380
3633
|
}
|
|
3381
3634
|
// ============================================================
|
|
3382
3635
|
reportCapturedError(captured) {
|
|
@@ -3536,10 +3789,21 @@ var CrossdeckServer = class extends EventEmitter {
|
|
|
3536
3789
|
* Resolve any hint shape (canonical customerId / userId hint /
|
|
3537
3790
|
* anonymousId hint / raw string) to a `crossdeckCustomerId` if we
|
|
3538
3791
|
* have a cache entry for it.
|
|
3792
|
+
*
|
|
3793
|
+
* String overload is STRICT on the canonical-id shape. Pre-fix
|
|
3794
|
+
* `isFresh(raw)` treated any string with a cache entry as a valid
|
|
3795
|
+
* canonical id — if tenant A's userId happened to collide with
|
|
3796
|
+
* tenant B's crossdeckCustomerId, A's call would resolve to B's
|
|
3797
|
+
* cached entitlements. Bounded by the `cdcust_` prefix convention
|
|
3798
|
+
* (which both SDKs and the backend mint, see
|
|
3799
|
+
* backend/src/lib/customers.ts) — anything else is treated purely
|
|
3800
|
+
* as an alias lookup, never as a canonical id. Audit P1 #19.
|
|
3539
3801
|
*/
|
|
3540
3802
|
resolveCacheCustomerId(hint) {
|
|
3541
3803
|
if (typeof hint === "string") {
|
|
3542
|
-
if (this.entitlementCache.isFresh(hint))
|
|
3804
|
+
if (hint.startsWith("cdcust_") && this.entitlementCache.isFresh(hint)) {
|
|
3805
|
+
return hint;
|
|
3806
|
+
}
|
|
3543
3807
|
return this.customerIdAliases.get(hint) ?? null;
|
|
3544
3808
|
}
|
|
3545
3809
|
if (hint.customerId) return hint.customerId;
|
|
@@ -3785,18 +4049,43 @@ var _CROSSDECK_ERROR_CODES = Object.freeze([
|
|
|
3785
4049
|
retryable: false
|
|
3786
4050
|
},
|
|
3787
4051
|
// ----- Webhook verification (Node-specific) -----
|
|
4052
|
+
// v1.4.0 Phase 7.2 — distinguishable codes. Pre-v1.4.0 the
|
|
4053
|
+
// helper used webhook_invalid_signature for nearly every failure
|
|
4054
|
+
// mode so a customer couldn't separate replay-attack signals
|
|
4055
|
+
// from wrong-secret signals in alerting.
|
|
3788
4056
|
{
|
|
3789
|
-
code: "
|
|
4057
|
+
code: "webhook_signature_mismatch",
|
|
3790
4058
|
type: "authentication_error",
|
|
3791
|
-
description: "
|
|
3792
|
-
resolution: "Confirm the secret matches
|
|
4059
|
+
description: "Webhook HMAC didn't verify against any configured secret (wrong-secret / stale rotation signal).",
|
|
4060
|
+
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.",
|
|
3793
4061
|
retryable: false
|
|
3794
4062
|
},
|
|
3795
4063
|
{
|
|
3796
|
-
code: "
|
|
4064
|
+
code: "webhook_timestamp_outside_tolerance",
|
|
4065
|
+
type: "authentication_error",
|
|
4066
|
+
description: "Webhook timestamp drift exceeds the configured replay-tolerance window (default 5 minutes; replay-attack signal).",
|
|
4067
|
+
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.",
|
|
4068
|
+
retryable: false
|
|
4069
|
+
},
|
|
4070
|
+
{
|
|
4071
|
+
code: "webhook_timestamp_missing",
|
|
3797
4072
|
type: "authentication_error",
|
|
3798
|
-
description: "
|
|
3799
|
-
resolution: "
|
|
4073
|
+
description: "Webhook signature header is absent or has no `t=` timestamp segment \u2014 the timestamp gate cannot be verified.",
|
|
4074
|
+
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.",
|
|
4075
|
+
retryable: false
|
|
4076
|
+
},
|
|
4077
|
+
{
|
|
4078
|
+
code: "webhook_payload_not_json",
|
|
4079
|
+
type: "authentication_error",
|
|
4080
|
+
description: "Webhook signature verified but the body isn't valid JSON \u2014 payload tampered post-signing or source bug.",
|
|
4081
|
+
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.",
|
|
4082
|
+
retryable: false
|
|
4083
|
+
},
|
|
4084
|
+
{
|
|
4085
|
+
code: "webhook_invalid_tolerance",
|
|
4086
|
+
type: "configuration_error",
|
|
4087
|
+
description: "verifyWebhookSignature() called with a non-finite / negative / above-24h-cap replayToleranceMs (would silently disable replay protection).",
|
|
4088
|
+
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.",
|
|
3800
4089
|
retryable: false
|
|
3801
4090
|
},
|
|
3802
4091
|
{
|
|
@@ -3805,6 +4094,101 @@ var _CROSSDECK_ERROR_CODES = Object.freeze([
|
|
|
3805
4094
|
description: "verifyWebhookSignature() was called without a signing secret.",
|
|
3806
4095
|
resolution: "Pass the secret from your Crossdeck dashboard \u2192 Webhooks page. Never hardcode in source \u2014 read from an env var.",
|
|
3807
4096
|
retryable: false
|
|
4097
|
+
},
|
|
4098
|
+
{
|
|
4099
|
+
code: "webhook_invalid_signature",
|
|
4100
|
+
type: "authentication_error",
|
|
4101
|
+
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.",
|
|
4102
|
+
resolution: "Migrate alert rules to the more specific v1.4.0 codes \u2014 they distinguish replay-attack signals from wrong-secret signals.",
|
|
4103
|
+
retryable: false
|
|
4104
|
+
},
|
|
4105
|
+
{
|
|
4106
|
+
code: "webhook_replay_window_exceeded",
|
|
4107
|
+
type: "authentication_error",
|
|
4108
|
+
description: "DEPRECATED in v1.4.0 \u2014 renamed to webhook_timestamp_outside_tolerance.",
|
|
4109
|
+
resolution: "Update alerts to webhook_timestamp_outside_tolerance.",
|
|
4110
|
+
retryable: false
|
|
4111
|
+
},
|
|
4112
|
+
// ----- Backend-emitted codes (v1.4.0 Phase 6.2 backfill) -----
|
|
4113
|
+
// Mirror of backend/src/api/v1-errors.ts ApiErrorCode. Same set
|
|
4114
|
+
// as the Web SDK ships — keep these synchronised so a developer
|
|
4115
|
+
// hitting any code via either SDK gets the same remediation.
|
|
4116
|
+
{
|
|
4117
|
+
code: "missing_api_key",
|
|
4118
|
+
type: "authentication_error",
|
|
4119
|
+
description: "No Authorization header (or Crossdeck-Api-Key header) on the request.",
|
|
4120
|
+
resolution: "Confirm the CrossdeckServer was constructed with a cd_sk_\u2026 secretKey. Re-check env vars in production deployments.",
|
|
4121
|
+
retryable: false
|
|
4122
|
+
},
|
|
4123
|
+
{
|
|
4124
|
+
code: "invalid_api_key",
|
|
4125
|
+
type: "authentication_error",
|
|
4126
|
+
description: "The secret key is malformed, unknown, or doesn't resolve to a project.",
|
|
4127
|
+
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.",
|
|
4128
|
+
retryable: false
|
|
4129
|
+
},
|
|
4130
|
+
{
|
|
4131
|
+
code: "key_revoked",
|
|
4132
|
+
type: "authentication_error",
|
|
4133
|
+
description: "The secret key was revoked in the dashboard.",
|
|
4134
|
+
resolution: "Mint a fresh key in dashboard \u2192 API keys \u2192 Create new. The revoked key cannot be reactivated.",
|
|
4135
|
+
retryable: false
|
|
4136
|
+
},
|
|
4137
|
+
{
|
|
4138
|
+
code: "env_mismatch",
|
|
4139
|
+
type: "permission_error",
|
|
4140
|
+
description: "The key's env prefix doesn't match the resolved app's configured env.",
|
|
4141
|
+
resolution: "Use a cd_sk_live_ key with a production app, cd_sk_test_ with a sandbox app. Crossing breaks the env lock.",
|
|
4142
|
+
retryable: false
|
|
4143
|
+
},
|
|
4144
|
+
{
|
|
4145
|
+
code: "idempotency_key_in_use",
|
|
4146
|
+
type: "invalid_request_error",
|
|
4147
|
+
description: "An Idempotency-Key was reused for a request with a different body (Stripe-grade contract).",
|
|
4148
|
+
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.",
|
|
4149
|
+
retryable: false
|
|
4150
|
+
},
|
|
4151
|
+
{
|
|
4152
|
+
code: "rate_limited",
|
|
4153
|
+
type: "rate_limit_error",
|
|
4154
|
+
description: "Request rate exceeded the project's per-second cap.",
|
|
4155
|
+
resolution: "Honour Retry-After (managed retries do this automatically). For custom paths, throttle to <100 req/s/key.",
|
|
4156
|
+
retryable: true
|
|
4157
|
+
},
|
|
4158
|
+
{
|
|
4159
|
+
code: "internal_error",
|
|
4160
|
+
type: "internal_error",
|
|
4161
|
+
description: "Server-side issue. Safe to retry with backoff.",
|
|
4162
|
+
resolution: "Managed retries handle this automatically. If a code path surfaces it to your code, contact support with the requestId.",
|
|
4163
|
+
retryable: true
|
|
4164
|
+
},
|
|
4165
|
+
{
|
|
4166
|
+
code: "google_not_supported",
|
|
4167
|
+
type: "invalid_request_error",
|
|
4168
|
+
description: "POST /purchases/sync with rail=google is gated until the Play Developer API reconciliation worker ships.",
|
|
4169
|
+
resolution: "Until v1.5+, Google Play purchases verify via Real-time Developer Notifications. The Android SDK auto-track path handles this transparently.",
|
|
4170
|
+
retryable: false
|
|
4171
|
+
},
|
|
4172
|
+
{
|
|
4173
|
+
code: "stripe_not_supported",
|
|
4174
|
+
type: "invalid_request_error",
|
|
4175
|
+
description: "POST /purchases/sync with rail=stripe is unsupported \u2014 Stripe webhooks deliver evidence server-side.",
|
|
4176
|
+
resolution: "Wire Stripe via the standard Checkout / Customer Portal flow; Crossdeck reconciles via the platform webhook automatically.",
|
|
4177
|
+
retryable: false
|
|
4178
|
+
},
|
|
4179
|
+
{
|
|
4180
|
+
code: "missing_required_param",
|
|
4181
|
+
type: "invalid_request_error",
|
|
4182
|
+
description: "A required field is absent from the request body.",
|
|
4183
|
+
resolution: "The error.message identifies the missing field. Refer to the SDK's TypeScript types for canonical shapes.",
|
|
4184
|
+
retryable: false
|
|
4185
|
+
},
|
|
4186
|
+
{
|
|
4187
|
+
code: "invalid_param_value",
|
|
4188
|
+
type: "invalid_request_error",
|
|
4189
|
+
description: "A field is present but the value failed validation.",
|
|
4190
|
+
resolution: "Read error.message for the field + reason. SDK-managed call sites should never emit this \u2014 file a bug if you do.",
|
|
4191
|
+
retryable: false
|
|
3808
4192
|
}
|
|
3809
4193
|
]);
|
|
3810
4194
|
function isCrossdeckErrorCode(code) {
|
|
@@ -3818,6 +4202,7 @@ function getErrorCode(code) {
|
|
|
3818
4202
|
// src/webhooks.ts
|
|
3819
4203
|
import { createHmac, timingSafeEqual } from "crypto";
|
|
3820
4204
|
var DEFAULT_REPLAY_TOLERANCE_MS = 5 * 60 * 1e3;
|
|
4205
|
+
var MAX_REPLAY_TOLERANCE_MS = 24 * 60 * 60 * 1e3;
|
|
3821
4206
|
function verifyWebhookSignature(payload, signatureHeader, secret, options = {}) {
|
|
3822
4207
|
const secrets = normaliseSecrets(secret);
|
|
3823
4208
|
if (secrets.length === 0) {
|
|
@@ -3827,34 +4212,56 @@ function verifyWebhookSignature(payload, signatureHeader, secret, options = {})
|
|
|
3827
4212
|
message: "verifyWebhookSignature requires a non-empty secret. Read it from process.env.CROSSDECK_WEBHOOK_SECRET \u2014 never hardcode in source."
|
|
3828
4213
|
});
|
|
3829
4214
|
}
|
|
4215
|
+
const requestedTolerance = options.replayToleranceMs;
|
|
4216
|
+
let tolerance;
|
|
4217
|
+
if (requestedTolerance === void 0) {
|
|
4218
|
+
tolerance = DEFAULT_REPLAY_TOLERANCE_MS;
|
|
4219
|
+
} else if (typeof requestedTolerance !== "number" || !Number.isFinite(requestedTolerance)) {
|
|
4220
|
+
throw new CrossdeckError({
|
|
4221
|
+
type: "configuration_error",
|
|
4222
|
+
code: "webhook_invalid_tolerance",
|
|
4223
|
+
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.`
|
|
4224
|
+
});
|
|
4225
|
+
} else if (requestedTolerance < 0) {
|
|
4226
|
+
throw new CrossdeckError({
|
|
4227
|
+
type: "configuration_error",
|
|
4228
|
+
code: "webhook_invalid_tolerance",
|
|
4229
|
+
message: `replayToleranceMs must be \u2265 0. Got ${requestedTolerance}.`
|
|
4230
|
+
});
|
|
4231
|
+
} else if (requestedTolerance > MAX_REPLAY_TOLERANCE_MS) {
|
|
4232
|
+
throw new CrossdeckError({
|
|
4233
|
+
type: "configuration_error",
|
|
4234
|
+
code: "webhook_invalid_tolerance",
|
|
4235
|
+
message: `replayToleranceMs must not exceed ${MAX_REPLAY_TOLERANCE_MS}ms (24h). Got ${requestedTolerance}ms \u2014 a window that wide defeats replay protection.`
|
|
4236
|
+
});
|
|
4237
|
+
} else {
|
|
4238
|
+
tolerance = requestedTolerance;
|
|
4239
|
+
}
|
|
3830
4240
|
const header = normaliseHeader(signatureHeader);
|
|
3831
4241
|
const parsed = parseSignatureHeader(header);
|
|
3832
4242
|
if (!parsed) {
|
|
3833
4243
|
throw new CrossdeckError({
|
|
3834
4244
|
type: "authentication_error",
|
|
3835
|
-
code: "
|
|
3836
|
-
message: "Webhook signature header is missing or
|
|
4245
|
+
code: "webhook_timestamp_missing",
|
|
4246
|
+
message: "Webhook signature header is missing, malformed, or has no `t=` timestamp segment. Expected 'Crossdeck-Signature: t=<unix>,v1=<hex>'."
|
|
3837
4247
|
});
|
|
3838
4248
|
}
|
|
3839
|
-
const
|
|
3840
|
-
|
|
3841
|
-
|
|
3842
|
-
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
|
|
3846
|
-
|
|
3847
|
-
|
|
3848
|
-
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.`
|
|
3849
|
-
});
|
|
3850
|
-
}
|
|
4249
|
+
const now = (options.now ?? Date.now)();
|
|
4250
|
+
const timestampMs = parsed.timestampSec * 1e3;
|
|
4251
|
+
const drift = Math.abs(now - timestampMs);
|
|
4252
|
+
if (drift > tolerance) {
|
|
4253
|
+
throw new CrossdeckError({
|
|
4254
|
+
type: "authentication_error",
|
|
4255
|
+
code: "webhook_timestamp_outside_tolerance",
|
|
4256
|
+
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.`
|
|
4257
|
+
});
|
|
3851
4258
|
}
|
|
3852
4259
|
const signedPayload = `${parsed.timestampSec}.${payload}`;
|
|
3853
4260
|
const expectedBuf = Buffer.from(parsed.signature, "hex");
|
|
3854
4261
|
if (expectedBuf.length === 0) {
|
|
3855
4262
|
throw new CrossdeckError({
|
|
3856
4263
|
type: "authentication_error",
|
|
3857
|
-
code: "
|
|
4264
|
+
code: "webhook_signature_mismatch",
|
|
3858
4265
|
message: "Webhook signature is not a valid hex string."
|
|
3859
4266
|
});
|
|
3860
4267
|
}
|
|
@@ -3865,8 +4272,8 @@ function verifyWebhookSignature(payload, signatureHeader, secret, options = {})
|
|
|
3865
4272
|
if (!anyMatch) {
|
|
3866
4273
|
throw new CrossdeckError({
|
|
3867
4274
|
type: "authentication_error",
|
|
3868
|
-
code: "
|
|
3869
|
-
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)."
|
|
4275
|
+
code: "webhook_signature_mismatch",
|
|
4276
|
+
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)."
|
|
3870
4277
|
});
|
|
3871
4278
|
}
|
|
3872
4279
|
try {
|
|
@@ -3874,7 +4281,7 @@ function verifyWebhookSignature(payload, signatureHeader, secret, options = {})
|
|
|
3874
4281
|
} catch {
|
|
3875
4282
|
throw new CrossdeckError({
|
|
3876
4283
|
type: "authentication_error",
|
|
3877
|
-
code: "
|
|
4284
|
+
code: "webhook_payload_not_json",
|
|
3878
4285
|
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."
|
|
3879
4286
|
});
|
|
3880
4287
|
}
|
|
@@ -3912,44 +4319,471 @@ function normaliseSecrets(input) {
|
|
|
3912
4319
|
return arr.filter((s) => typeof s === "string" && s.length > 0);
|
|
3913
4320
|
}
|
|
3914
4321
|
|
|
3915
|
-
// src/
|
|
3916
|
-
var
|
|
3917
|
-
var
|
|
3918
|
-
var
|
|
3919
|
-
|
|
3920
|
-
|
|
3921
|
-
|
|
3922
|
-
|
|
3923
|
-
|
|
3924
|
-
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
|
|
4322
|
+
// src/_contracts-bundled.ts
|
|
4323
|
+
var BUNDLED_IN = "@cross-deck/node@1.5.0";
|
|
4324
|
+
var SDK_VERSION2 = "1.5.0";
|
|
4325
|
+
var BUNDLED_CONTRACTS = Object.freeze([
|
|
4326
|
+
{
|
|
4327
|
+
"id": "documentation-honesty",
|
|
4328
|
+
"pillar": "webhooks",
|
|
4329
|
+
"status": "enforced",
|
|
4330
|
+
"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.",
|
|
4331
|
+
"appliesTo": [
|
|
4332
|
+
"node",
|
|
4333
|
+
"backend"
|
|
4334
|
+
],
|
|
4335
|
+
"codeRef": [
|
|
4336
|
+
"sdks/node/src/webhooks.ts",
|
|
4337
|
+
"docs/rail-webhooks/index.html",
|
|
4338
|
+
"docs/webhooks-receive/index.html"
|
|
4339
|
+
],
|
|
4340
|
+
"testRef": [
|
|
4341
|
+
{
|
|
4342
|
+
"file": "sdks/node/src/webhooks.ts",
|
|
4343
|
+
"name": "[ROADMAP \u2014 v1.4.0 honesty note]"
|
|
4344
|
+
},
|
|
4345
|
+
{
|
|
4346
|
+
"file": "docs/rail-webhooks/index.html",
|
|
4347
|
+
"name": "Outbound push-to-your-backend webhooks are <strong>roadmap</strong>"
|
|
4348
|
+
},
|
|
4349
|
+
{
|
|
4350
|
+
"file": "docs/webhooks-receive/index.html",
|
|
4351
|
+
"name": "This feature is on the roadmap"
|
|
4352
|
+
}
|
|
4353
|
+
],
|
|
4354
|
+
"registeredAt": "2026-05-26",
|
|
4355
|
+
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 7.1",
|
|
4356
|
+
"bundledIn": "@cross-deck/node@1.5.0"
|
|
4357
|
+
},
|
|
4358
|
+
{
|
|
4359
|
+
"id": "error-envelope-shape",
|
|
4360
|
+
"pillar": "errors",
|
|
4361
|
+
"status": "enforced",
|
|
4362
|
+
"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.",
|
|
4363
|
+
"appliesTo": [
|
|
4364
|
+
"web",
|
|
4365
|
+
"node",
|
|
4366
|
+
"react-native",
|
|
4367
|
+
"swift",
|
|
4368
|
+
"android",
|
|
4369
|
+
"backend"
|
|
4370
|
+
],
|
|
4371
|
+
"codeRef": [
|
|
4372
|
+
"backend/src/api/v1-errors.ts",
|
|
4373
|
+
"sdks/web/src/errors.ts",
|
|
4374
|
+
"sdks/node/src/errors.ts",
|
|
4375
|
+
"sdks/react-native/src/errors.ts",
|
|
4376
|
+
"sdks/swift/Sources/Crossdeck/Errors.swift",
|
|
4377
|
+
"sdks/android/crossdeck/src/main/kotlin/com/crossdeck/Errors.kt"
|
|
4378
|
+
],
|
|
4379
|
+
"testRef": [
|
|
4380
|
+
{
|
|
4381
|
+
"file": "sdks/swift/Tests/CrossdeckTests/ErrorsTests.swift",
|
|
4382
|
+
"name": "test_errorEnvelope_fallsBackOnGarbageBody"
|
|
4383
|
+
},
|
|
4384
|
+
{
|
|
4385
|
+
"file": "sdks/swift/Tests/CrossdeckTests/ErrorsTests.swift",
|
|
4386
|
+
"name": "test_errorEnvelope_reads_XRequestId_fallback"
|
|
4387
|
+
},
|
|
4388
|
+
{
|
|
4389
|
+
"file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/ErrorTypeWireVocabTest.kt",
|
|
4390
|
+
"name": "backend 500 response parses to INTERNAL_ERROR"
|
|
4391
|
+
}
|
|
4392
|
+
],
|
|
4393
|
+
"registeredAt": "2026-05-26",
|
|
4394
|
+
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 8 (codifies existing contract)",
|
|
4395
|
+
"bundledIn": "@cross-deck/node@1.5.0"
|
|
4396
|
+
},
|
|
4397
|
+
{
|
|
4398
|
+
"id": "flush-interval-parity",
|
|
4399
|
+
"pillar": "analytics",
|
|
4400
|
+
"status": "enforced",
|
|
4401
|
+
"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.",
|
|
4402
|
+
"appliesTo": [
|
|
4403
|
+
"web",
|
|
4404
|
+
"node",
|
|
4405
|
+
"react-native",
|
|
4406
|
+
"swift",
|
|
4407
|
+
"android"
|
|
4408
|
+
],
|
|
4409
|
+
"codeRef": [
|
|
4410
|
+
"sdks/web/src/crossdeck.ts",
|
|
4411
|
+
"sdks/node/src/crossdeck-server.ts",
|
|
4412
|
+
"sdks/react-native/src/crossdeck.ts",
|
|
4413
|
+
"sdks/swift/Sources/Crossdeck/EventQueue.swift",
|
|
4414
|
+
"sdks/android/crossdeck/src/main/kotlin/com/crossdeck/EventQueue.kt"
|
|
4415
|
+
],
|
|
4416
|
+
"testRef": [
|
|
4417
|
+
{
|
|
4418
|
+
"file": "sdks/swift/Sources/Crossdeck/EventQueue.swift",
|
|
4419
|
+
"name": "flushIntervalMs: Int = 2_000"
|
|
4420
|
+
},
|
|
4421
|
+
{
|
|
4422
|
+
"file": "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/EventQueue.kt",
|
|
4423
|
+
"name": "flushIntervalMs: Long = 2_000L"
|
|
4424
|
+
},
|
|
4425
|
+
{
|
|
4426
|
+
"file": "sdks/web/src/crossdeck.ts",
|
|
4427
|
+
"name": "options.eventFlushIntervalMs ?? 2000"
|
|
4428
|
+
},
|
|
4429
|
+
{
|
|
4430
|
+
"file": "sdks/node/src/crossdeck-server.ts",
|
|
4431
|
+
"name": "options.eventFlushIntervalMs ?? 2000"
|
|
4432
|
+
},
|
|
4433
|
+
{
|
|
4434
|
+
"file": "sdks/react-native/src/crossdeck.ts",
|
|
4435
|
+
"name": "options.eventFlushIntervalMs ?? 2000"
|
|
4436
|
+
}
|
|
4437
|
+
],
|
|
4438
|
+
"registeredAt": "2026-05-26",
|
|
4439
|
+
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 3.3",
|
|
4440
|
+
"bundledIn": "@cross-deck/node@1.5.0"
|
|
4441
|
+
},
|
|
4442
|
+
{
|
|
4443
|
+
"id": "idempotency-key-deterministic",
|
|
4444
|
+
"pillar": "revenue",
|
|
4445
|
+
"status": "enforced",
|
|
4446
|
+
"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.",
|
|
4447
|
+
"appliesTo": [
|
|
4448
|
+
"web",
|
|
4449
|
+
"node",
|
|
4450
|
+
"react-native",
|
|
4451
|
+
"swift",
|
|
4452
|
+
"android",
|
|
4453
|
+
"backend"
|
|
4454
|
+
],
|
|
4455
|
+
"codeRef": [
|
|
4456
|
+
"sdks/web/src/idempotency-key.ts",
|
|
4457
|
+
"sdks/web/src/crossdeck.ts",
|
|
4458
|
+
"sdks/react-native/src/idempotency-key.ts",
|
|
4459
|
+
"sdks/react-native/src/crossdeck.ts",
|
|
4460
|
+
"sdks/node/src/idempotency-key.ts",
|
|
4461
|
+
"sdks/node/src/crossdeck-server.ts",
|
|
4462
|
+
"sdks/swift/Sources/Crossdeck/IdempotencyKey.swift",
|
|
4463
|
+
"sdks/swift/Sources/Crossdeck/Crossdeck.swift",
|
|
4464
|
+
"sdks/android/crossdeck/src/main/kotlin/com/crossdeck/IdempotencyKey.kt",
|
|
4465
|
+
"sdks/android/crossdeck/src/main/kotlin/com/crossdeck/Crossdeck.kt",
|
|
4466
|
+
"backend/src/lib/idempotency-response-cache.ts",
|
|
4467
|
+
"backend/src/api/v1-purchases.ts"
|
|
4468
|
+
],
|
|
4469
|
+
"testRef": [
|
|
4470
|
+
{
|
|
4471
|
+
"file": "sdks/web/tests/idempotency-key.test.ts",
|
|
4472
|
+
"name": "cross-SDK oracle \u2014 apple JWS pins canonical vector"
|
|
4473
|
+
},
|
|
4474
|
+
{
|
|
4475
|
+
"file": "sdks/web/tests/idempotency-key.test.ts",
|
|
4476
|
+
"name": "is deterministic: same body twice -> identical key"
|
|
4477
|
+
},
|
|
4478
|
+
{
|
|
4479
|
+
"file": "sdks/web/tests/idempotency-key.test.ts",
|
|
4480
|
+
"name": "same identifier under different rails -> different keys"
|
|
4481
|
+
},
|
|
4482
|
+
{
|
|
4483
|
+
"file": "sdks/web/tests/idempotency-key.test.ts",
|
|
4484
|
+
"name": "never silently falls back to a random key on missing identifier"
|
|
4485
|
+
},
|
|
4486
|
+
{
|
|
4487
|
+
"file": "sdks/react-native/tests/idempotency-key.test.ts",
|
|
4488
|
+
"name": "is deterministic"
|
|
4489
|
+
},
|
|
4490
|
+
{
|
|
4491
|
+
"file": "sdks/react-native/tests/idempotency-key.test.ts",
|
|
4492
|
+
"name": "cross-SDK oracle \u2014 apple JWS pins canonical vector"
|
|
4493
|
+
},
|
|
4494
|
+
{
|
|
4495
|
+
"file": "sdks/node/tests/idempotency-key.test.ts",
|
|
4496
|
+
"name": "is deterministic"
|
|
4497
|
+
},
|
|
4498
|
+
{
|
|
4499
|
+
"file": "sdks/node/tests/idempotency-key.test.ts",
|
|
4500
|
+
"name": "rail namespacing prevents cross-rail collisions"
|
|
4501
|
+
},
|
|
4502
|
+
{
|
|
4503
|
+
"file": "sdks/node/tests/idempotency-key.test.ts",
|
|
4504
|
+
"name": "apple JWS produces the canonical pinned UUID across all 5 SDKs"
|
|
4505
|
+
},
|
|
4506
|
+
{
|
|
4507
|
+
"file": "backend/tests/unit/idempotency-response-cache.test.ts",
|
|
4508
|
+
"name": "is deterministic for the same input"
|
|
4509
|
+
},
|
|
4510
|
+
{
|
|
4511
|
+
"file": "backend/tests/unit/idempotency-response-cache.test.ts",
|
|
4512
|
+
"name": "injects idempotent_replay: true into a JSON object body"
|
|
4513
|
+
},
|
|
4514
|
+
{
|
|
4515
|
+
"file": "backend/tests/unit/idempotency-response-cache.test.ts",
|
|
4516
|
+
"name": "matches Stripe's 24-hour idempotency window"
|
|
4517
|
+
},
|
|
4518
|
+
{
|
|
4519
|
+
"file": "sdks/swift/Tests/CrossdeckTests/IdempotencyKeyTests.swift",
|
|
4520
|
+
"name": "test_crossSdkOracle_appleJWS"
|
|
4521
|
+
},
|
|
4522
|
+
{
|
|
4523
|
+
"file": "sdks/swift/Tests/CrossdeckTests/IdempotencyKeyTests.swift",
|
|
4524
|
+
"name": "test_railNamespacing_preventsCrossRailCollisions"
|
|
4525
|
+
},
|
|
4526
|
+
{
|
|
4527
|
+
"file": "sdks/swift/Tests/CrossdeckTests/IdempotencyKeyTests.swift",
|
|
4528
|
+
"name": "test_missingIdentifier_returnsNil"
|
|
4529
|
+
},
|
|
4530
|
+
{
|
|
4531
|
+
"file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/IdempotencyKeyTest.kt",
|
|
4532
|
+
"name": "cross-SDK oracle for apple JWS"
|
|
4533
|
+
},
|
|
4534
|
+
{
|
|
4535
|
+
"file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/IdempotencyKeyTest.kt",
|
|
4536
|
+
"name": "rail namespacing prevents cross-rail collisions"
|
|
4537
|
+
},
|
|
4538
|
+
{
|
|
4539
|
+
"file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/IdempotencyKeyTest.kt",
|
|
4540
|
+
"name": "missing identifier returns null - never silent random fallback"
|
|
4541
|
+
}
|
|
4542
|
+
],
|
|
4543
|
+
"registeredAt": "2026-05-26",
|
|
4544
|
+
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 2.2.a + 2.2.b + 2.2.c",
|
|
4545
|
+
"bundledIn": "@cross-deck/node@1.5.0"
|
|
4546
|
+
},
|
|
4547
|
+
{
|
|
4548
|
+
"id": "node-pii-scrubber",
|
|
4549
|
+
"pillar": "analytics",
|
|
4550
|
+
"status": "enforced",
|
|
4551
|
+
"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.",
|
|
4552
|
+
"appliesTo": [
|
|
4553
|
+
"node"
|
|
4554
|
+
],
|
|
4555
|
+
"codeRef": [
|
|
4556
|
+
"sdks/node/src/crossdeck-server.ts",
|
|
4557
|
+
"sdks/node/src/types.ts",
|
|
4558
|
+
"sdks/node/src/consent.ts"
|
|
4559
|
+
],
|
|
4560
|
+
"testRef": [
|
|
4561
|
+
{
|
|
4562
|
+
"file": "sdks/node/tests/track-pii-scrub.test.ts",
|
|
4563
|
+
"name": "by default redacts email-shaped values to <email>"
|
|
4564
|
+
},
|
|
4565
|
+
{
|
|
4566
|
+
"file": "sdks/node/tests/track-pii-scrub.test.ts",
|
|
4567
|
+
"name": "redacts card-number-shaped values to <card>"
|
|
4568
|
+
},
|
|
4569
|
+
{
|
|
4570
|
+
"file": "sdks/node/tests/track-pii-scrub.test.ts",
|
|
4571
|
+
"name": "walks nested maps + arrays"
|
|
4572
|
+
},
|
|
4573
|
+
{
|
|
4574
|
+
"file": "sdks/node/tests/track-pii-scrub.test.ts",
|
|
4575
|
+
"name": "scrubPii: false preserves the raw payload (opt-out)"
|
|
4576
|
+
},
|
|
4577
|
+
{
|
|
4578
|
+
"file": "sdks/node/tests/track-pii-scrub.test.ts",
|
|
4579
|
+
"name": "scrubPii: true is the default when option is omitted"
|
|
4580
|
+
}
|
|
4581
|
+
],
|
|
4582
|
+
"registeredAt": "2026-05-26",
|
|
4583
|
+
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 3.1",
|
|
4584
|
+
"bundledIn": "@cross-deck/node@1.5.0"
|
|
4585
|
+
},
|
|
4586
|
+
{
|
|
4587
|
+
"id": "node-shutdown-awaits-flush",
|
|
4588
|
+
"pillar": "lifecycle",
|
|
4589
|
+
"status": "enforced",
|
|
4590
|
+
"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().",
|
|
4591
|
+
"appliesTo": [
|
|
4592
|
+
"node"
|
|
4593
|
+
],
|
|
4594
|
+
"codeRef": [
|
|
4595
|
+
"sdks/node/src/crossdeck-server.ts"
|
|
4596
|
+
],
|
|
4597
|
+
"testRef": [
|
|
4598
|
+
{
|
|
4599
|
+
"file": "sdks/node/tests/shutdown-flush.test.ts",
|
|
4600
|
+
"name": "async shutdown() flushes queued events before clearing"
|
|
4601
|
+
},
|
|
4602
|
+
{
|
|
4603
|
+
"file": "sdks/node/tests/shutdown-flush.test.ts",
|
|
4604
|
+
"name": "async shutdown() proceeds with teardown even if flush fails"
|
|
4605
|
+
},
|
|
4606
|
+
{
|
|
4607
|
+
"file": "sdks/node/tests/shutdown-flush.test.ts",
|
|
4608
|
+
"name": "sync shutdownSync() warns when the buffer has events at teardown"
|
|
4609
|
+
},
|
|
4610
|
+
{
|
|
4611
|
+
"file": "sdks/node/tests/shutdown-flush.test.ts",
|
|
4612
|
+
"name": "[Symbol.asyncDispose] equals await server.shutdown()"
|
|
4613
|
+
}
|
|
4614
|
+
],
|
|
4615
|
+
"registeredAt": "2026-05-26",
|
|
4616
|
+
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 5.4",
|
|
4617
|
+
"bundledIn": "@cross-deck/node@1.5.0"
|
|
4618
|
+
},
|
|
4619
|
+
{
|
|
4620
|
+
"id": "sdk-error-codes-catalogue",
|
|
4621
|
+
"pillar": "errors",
|
|
4622
|
+
"status": "enforced",
|
|
4623
|
+
"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.",
|
|
4624
|
+
"appliesTo": [
|
|
4625
|
+
"web",
|
|
4626
|
+
"node"
|
|
4627
|
+
],
|
|
4628
|
+
"codeRef": [
|
|
4629
|
+
"sdks/web/src/error-codes.ts",
|
|
4630
|
+
"sdks/node/src/error-codes.ts",
|
|
4631
|
+
"backend/src/api/v1-errors.ts"
|
|
4632
|
+
],
|
|
4633
|
+
"testRef": [
|
|
4634
|
+
{
|
|
4635
|
+
"file": "sdks/web/tests/error-codes-backfill.test.ts",
|
|
4636
|
+
"name": "includes backend code"
|
|
4637
|
+
},
|
|
4638
|
+
{
|
|
4639
|
+
"file": "sdks/web/tests/error-codes-backfill.test.ts",
|
|
4640
|
+
"name": "invalid_api_key resolution points at the dashboard"
|
|
4641
|
+
},
|
|
4642
|
+
{
|
|
4643
|
+
"file": "sdks/web/tests/error-codes-backfill.test.ts",
|
|
4644
|
+
"name": "idempotency_key_in_use resolution mentions Stripe-grade contract"
|
|
4645
|
+
},
|
|
4646
|
+
{
|
|
4647
|
+
"file": "sdks/web/tests/error-codes-backfill.test.ts",
|
|
4648
|
+
"name": "identity-lock codes carry permission_error type"
|
|
4649
|
+
},
|
|
4650
|
+
{
|
|
4651
|
+
"file": "sdks/web/tests/error-codes-backfill.test.ts",
|
|
4652
|
+
"name": "no entry has an empty description or resolution"
|
|
4653
|
+
}
|
|
4654
|
+
],
|
|
4655
|
+
"registeredAt": "2026-05-26",
|
|
4656
|
+
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 6.2",
|
|
4657
|
+
"bundledIn": "@cross-deck/node@1.5.0"
|
|
4658
|
+
},
|
|
4659
|
+
{
|
|
4660
|
+
"id": "sync-purchases-funnel-parity",
|
|
4661
|
+
"pillar": "analytics",
|
|
4662
|
+
"status": "enforced",
|
|
4663
|
+
"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`.",
|
|
4664
|
+
"appliesTo": [
|
|
4665
|
+
"web",
|
|
4666
|
+
"node",
|
|
4667
|
+
"react-native",
|
|
4668
|
+
"swift",
|
|
4669
|
+
"android"
|
|
4670
|
+
],
|
|
4671
|
+
"codeRef": [
|
|
4672
|
+
"sdks/web/src/crossdeck.ts",
|
|
4673
|
+
"sdks/node/src/crossdeck-server.ts",
|
|
4674
|
+
"sdks/react-native/src/crossdeck.ts",
|
|
4675
|
+
"sdks/swift/Sources/Crossdeck/Crossdeck.swift",
|
|
4676
|
+
"sdks/android/crossdeck/src/main/kotlin/com/crossdeck/Crossdeck.kt"
|
|
4677
|
+
],
|
|
4678
|
+
"testRef": [
|
|
4679
|
+
{
|
|
4680
|
+
"file": "sdks/web/tests/sync-purchases-funnel.test.ts",
|
|
4681
|
+
"name": "emits purchase.completed after a successful sync"
|
|
4682
|
+
},
|
|
4683
|
+
{
|
|
4684
|
+
"file": "sdks/web/tests/sync-purchases-funnel.test.ts",
|
|
4685
|
+
"name": "carries idempotent_replay=true when backend replied from cache"
|
|
4686
|
+
}
|
|
4687
|
+
],
|
|
4688
|
+
"registeredAt": "2026-05-26",
|
|
4689
|
+
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 3.5",
|
|
4690
|
+
"bundledIn": "@cross-deck/node@1.5.0"
|
|
4691
|
+
},
|
|
4692
|
+
{
|
|
4693
|
+
"id": "verifier-timestamp-mandatory",
|
|
4694
|
+
"pillar": "webhooks",
|
|
4695
|
+
"status": "enforced",
|
|
4696
|
+
"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.",
|
|
4697
|
+
"appliesTo": [
|
|
4698
|
+
"node"
|
|
4699
|
+
],
|
|
4700
|
+
"codeRef": [
|
|
4701
|
+
"sdks/node/src/webhooks.ts",
|
|
4702
|
+
"sdks/node/src/error-codes.ts"
|
|
4703
|
+
],
|
|
4704
|
+
"testRef": [
|
|
4705
|
+
{
|
|
4706
|
+
"file": "sdks/node/tests/webhooks.test.ts",
|
|
4707
|
+
"name": "tolerance of 0 still enforces the replay window (v1.4.0 \u2014 cannot disable)"
|
|
4708
|
+
},
|
|
4709
|
+
{
|
|
4710
|
+
"file": "sdks/node/tests/webhooks.test.ts",
|
|
4711
|
+
"name": "rejects Infinity tolerance (would silently disable replay protection)"
|
|
4712
|
+
},
|
|
4713
|
+
{
|
|
4714
|
+
"file": "sdks/node/tests/webhooks.test.ts",
|
|
4715
|
+
"name": "rejects NaN tolerance"
|
|
4716
|
+
},
|
|
4717
|
+
{
|
|
4718
|
+
"file": "sdks/node/tests/webhooks.test.ts",
|
|
4719
|
+
"name": "rejects negative tolerance"
|
|
4720
|
+
},
|
|
4721
|
+
{
|
|
4722
|
+
"file": "sdks/node/tests/webhooks.test.ts",
|
|
4723
|
+
"name": "rejects tolerance above the 24h cap"
|
|
4724
|
+
},
|
|
4725
|
+
{
|
|
4726
|
+
"file": "sdks/node/tests/webhooks.test.ts",
|
|
4727
|
+
"name": "rejects non-number tolerance (null / string)"
|
|
4728
|
+
},
|
|
4729
|
+
{
|
|
4730
|
+
"file": "sdks/node/tests/webhooks.test.ts",
|
|
4731
|
+
"name": "accepts tolerance exactly at the 24h cap"
|
|
4732
|
+
},
|
|
4733
|
+
{
|
|
4734
|
+
"file": "sdks/node/tests/webhooks.test.ts",
|
|
4735
|
+
"name": "malformed header (no t= or no v1=) throws webhook_timestamp_missing"
|
|
4736
|
+
},
|
|
4737
|
+
{
|
|
4738
|
+
"file": "sdks/node/tests/webhooks.test.ts",
|
|
4739
|
+
"name": "valid signature but non-JSON payload throws webhook_payload_not_json"
|
|
4740
|
+
}
|
|
4741
|
+
],
|
|
4742
|
+
"registeredAt": "2026-05-26",
|
|
4743
|
+
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 7.2",
|
|
4744
|
+
"bundledIn": "@cross-deck/node@1.5.0"
|
|
3937
4745
|
}
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
|
|
3944
|
-
|
|
4746
|
+
]);
|
|
4747
|
+
|
|
4748
|
+
// src/contracts.ts
|
|
4749
|
+
var CrossdeckContracts = {
|
|
4750
|
+
all() {
|
|
4751
|
+
return BUNDLED_CONTRACTS.filter((c) => c.status === "enforced");
|
|
4752
|
+
},
|
|
4753
|
+
allIncludingHistorical() {
|
|
4754
|
+
return BUNDLED_CONTRACTS;
|
|
4755
|
+
},
|
|
4756
|
+
byId(id) {
|
|
4757
|
+
return BUNDLED_CONTRACTS.find((c) => c.id === id);
|
|
4758
|
+
},
|
|
4759
|
+
byPillar(pillar) {
|
|
4760
|
+
return BUNDLED_CONTRACTS.filter(
|
|
4761
|
+
(c) => c.pillar === pillar && c.status === "enforced"
|
|
4762
|
+
);
|
|
4763
|
+
},
|
|
4764
|
+
withStatus(status) {
|
|
4765
|
+
return BUNDLED_CONTRACTS.filter((c) => c.status === status);
|
|
4766
|
+
},
|
|
4767
|
+
sdkVersion: SDK_VERSION2,
|
|
4768
|
+
bundledIn: BUNDLED_IN,
|
|
4769
|
+
/**
|
|
4770
|
+
* Resolve a failing test back to the contract it exercises.
|
|
4771
|
+
* Used by test-framework hooks to find the contract id of a
|
|
4772
|
+
* failed contract test so `reportContractFailure(...)` can stamp
|
|
4773
|
+
* the right `contract_id` on the emitted event.
|
|
4774
|
+
*/
|
|
4775
|
+
findByTestName(name) {
|
|
4776
|
+
return BUNDLED_CONTRACTS.find(
|
|
4777
|
+
(c) => c.testRef.some((ref) => ref.name === name)
|
|
4778
|
+
);
|
|
3945
4779
|
}
|
|
3946
|
-
|
|
3947
|
-
}
|
|
4780
|
+
};
|
|
3948
4781
|
export {
|
|
3949
4782
|
CROSSDECK_API_VERSION,
|
|
3950
4783
|
CROSSDECK_ERROR_CODES,
|
|
3951
4784
|
CrossdeckAuthenticationError,
|
|
3952
4785
|
CrossdeckConfigurationError,
|
|
4786
|
+
CrossdeckContracts,
|
|
3953
4787
|
CrossdeckError,
|
|
3954
4788
|
CrossdeckInternalError,
|
|
3955
4789
|
CrossdeckNetworkError,
|