@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/dist/index.cjs CHANGED
@@ -24,6 +24,7 @@ __export(index_exports, {
24
24
  CROSSDECK_ERROR_CODES: () => CROSSDECK_ERROR_CODES,
25
25
  CrossdeckAuthenticationError: () => CrossdeckAuthenticationError,
26
26
  CrossdeckConfigurationError: () => CrossdeckConfigurationError,
27
+ CrossdeckContracts: () => CrossdeckContracts,
27
28
  CrossdeckError: () => CrossdeckError,
28
29
  CrossdeckInternalError: () => CrossdeckInternalError,
29
30
  CrossdeckNetworkError: () => CrossdeckNetworkError,
@@ -363,9 +364,11 @@ function byteLength(s) {
363
364
  return s.length * 4;
364
365
  }
365
366
 
366
- // src/http.ts
367
+ // src/_version.ts
368
+ var SDK_VERSION = "1.4.2";
367
369
  var SDK_NAME = "@cross-deck/node";
368
- var SDK_VERSION = "1.2.0";
370
+
371
+ // src/http.ts
369
372
  var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
370
373
  var DEFAULT_TIMEOUT_MS = 15e3;
371
374
  var CROSSDECK_API_VERSION = "2025-01-01";
@@ -878,6 +881,7 @@ var EventQueue = class {
878
881
  sdk: env.sdk
879
882
  };
880
883
  if (env.appId) body.appId = env.appId;
884
+ if (env.environment) body.environment = env.environment;
881
885
  const result = await this.cfg.http.request("POST", "/events", {
882
886
  body,
883
887
  idempotencyKey: batchId
@@ -896,6 +900,20 @@ var EventQueue = class {
896
900
  } catch (err) {
897
901
  const message = err instanceof Error ? err.message : String(err);
898
902
  this.lastError = message;
903
+ if (isPermanent4xx(err)) {
904
+ const droppedCount = batch.length;
905
+ this.pendingBatch = null;
906
+ this.pendingBatchId = null;
907
+ this.inFlight -= droppedCount;
908
+ this.dropped += droppedCount;
909
+ this.cfg.onDrop?.(droppedCount);
910
+ this.cfg.onPermanentFailure?.({
911
+ status: err.status ?? 0,
912
+ droppedCount,
913
+ lastError: message
914
+ });
915
+ return null;
916
+ }
899
917
  const retryAfterMs = extractRetryAfterMs(err);
900
918
  const delay = this.retry.nextDelay(retryAfterMs);
901
919
  this.scheduleRetry(delay);
@@ -974,6 +992,14 @@ function extractRetryAfterMs(err) {
974
992
  }
975
993
  return void 0;
976
994
  }
995
+ function isPermanent4xx(err) {
996
+ if (!err || typeof err !== "object") return false;
997
+ const status = err.status;
998
+ if (typeof status !== "number" || !Number.isFinite(status)) return false;
999
+ if (status < 400 || status >= 500) return false;
1000
+ if (status === 408 || status === 429) return false;
1001
+ return true;
1002
+ }
977
1003
  function defaultScheduler(fn, ms) {
978
1004
  const id = setTimeout(fn, ms);
979
1005
  if (typeof id.unref === "function") {
@@ -1263,16 +1289,18 @@ var ErrorTracker = class {
1263
1289
  const url = typeof input === "string" ? input : input?.url ?? "";
1264
1290
  const method = (init.method || "GET").toUpperCase();
1265
1291
  const start = Date.now();
1266
- tracker.opts.breadcrumbs.add({
1267
- timestamp: start,
1268
- category: "http",
1269
- message: `${method} ${url}`,
1270
- data: { url, method }
1271
- });
1292
+ if (!isSelfRequest(url, tracker.opts.selfHostname)) {
1293
+ tracker.opts.breadcrumbs.add({
1294
+ timestamp: start,
1295
+ category: "http",
1296
+ message: `${method} ${url}`,
1297
+ data: { url, method }
1298
+ });
1299
+ }
1272
1300
  try {
1273
1301
  const response = await origFetch(...args);
1274
1302
  if (response.status >= 500 && tracker.opts.isConsented()) {
1275
- if (!url.includes("api.cross-deck.com")) {
1303
+ if (!isSelfRequest(url, tracker.opts.selfHostname)) {
1276
1304
  tracker.captureHttp({
1277
1305
  url,
1278
1306
  method,
@@ -1390,9 +1418,10 @@ var ErrorTracker = class {
1390
1418
  if (!this.passesSample(err)) return;
1391
1419
  if (!this.passesRateLimit(err)) return;
1392
1420
  let finalErr = err;
1393
- if (this.opts.beforeSend) {
1421
+ const hook = this.opts.beforeSend?.();
1422
+ if (hook) {
1394
1423
  try {
1395
- finalErr = this.opts.beforeSend(err);
1424
+ finalErr = hook(err);
1396
1425
  } catch {
1397
1426
  finalErr = err;
1398
1427
  }
@@ -1618,6 +1647,22 @@ function safeClone(v) {
1618
1647
  function safeStringify3(v) {
1619
1648
  return coerceErrorPayload(v).message;
1620
1649
  }
1650
+ function extractSelfHostname(baseUrl) {
1651
+ if (!baseUrl || typeof baseUrl !== "string") return null;
1652
+ try {
1653
+ return new URL(baseUrl).hostname.toLowerCase();
1654
+ } catch {
1655
+ return null;
1656
+ }
1657
+ }
1658
+ function isSelfRequest(requestUrl, selfHostname) {
1659
+ if (!selfHostname || !requestUrl) return false;
1660
+ try {
1661
+ return new URL(requestUrl).hostname.toLowerCase() === selfHostname;
1662
+ } catch {
1663
+ return false;
1664
+ }
1665
+ }
1621
1666
 
1622
1667
  // src/runtime-info.ts
1623
1668
  var import_node_os = require("os");
@@ -2352,6 +2397,63 @@ var EntitlementCache = class {
2352
2397
  }
2353
2398
  };
2354
2399
 
2400
+ // src/consent.ts
2401
+ var EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
2402
+ var CARD_PATTERN = /\b\d(?:[ -]?\d){12,18}\b/g;
2403
+ var REPLACEMENT_EMAIL = "<email>";
2404
+ var REPLACEMENT_CARD = "<card>";
2405
+ function scrubPii(value) {
2406
+ if (!value) return value;
2407
+ return value.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL).replace(CARD_PATTERN, REPLACEMENT_CARD);
2408
+ }
2409
+ function scrubPiiFromProperties(properties) {
2410
+ const out = {};
2411
+ for (const k of Object.keys(properties)) {
2412
+ out[k] = scrubValue(properties[k]);
2413
+ }
2414
+ return out;
2415
+ }
2416
+ function scrubValue(v) {
2417
+ if (typeof v === "string") return scrubPii(v);
2418
+ if (Array.isArray(v)) return v.map(scrubValue);
2419
+ if (v && typeof v === "object" && v.constructor === Object) {
2420
+ return scrubPiiFromProperties(v);
2421
+ }
2422
+ return v;
2423
+ }
2424
+
2425
+ // src/idempotency-key.ts
2426
+ var import_node_crypto = require("crypto");
2427
+ function formatAsUuid(hex) {
2428
+ return [
2429
+ hex.slice(0, 8),
2430
+ hex.slice(8, 12),
2431
+ hex.slice(12, 16),
2432
+ hex.slice(16, 20),
2433
+ hex.slice(20, 32)
2434
+ ].join("-");
2435
+ }
2436
+ function sha256Hex(input) {
2437
+ return (0, import_node_crypto.createHash)("sha256").update(input, "utf8").digest("hex");
2438
+ }
2439
+ function deriveIdempotencyKeyForPurchase(body) {
2440
+ let identifier;
2441
+ if (body.rail === "apple") {
2442
+ identifier = body.signedTransactionInfo ?? "";
2443
+ } else if (body.rail === "google") {
2444
+ identifier = body.purchaseToken ?? "";
2445
+ } else {
2446
+ identifier = "";
2447
+ }
2448
+ if (!identifier) {
2449
+ throw new Error(
2450
+ `deriveIdempotencyKeyForPurchase: no stable identifier in body (rail=${body.rail}). Apple needs signedTransactionInfo; Google needs purchaseToken.`
2451
+ );
2452
+ }
2453
+ const namespaced = `crossdeck:purchases/sync:${body.rail}:${identifier}`;
2454
+ return formatAsUuid(sha256Hex(namespaced));
2455
+ }
2456
+
2355
2457
  // src/debug.ts
2356
2458
  var SENSITIVE_KEY_PATTERNS = [
2357
2459
  /^email$/i,
@@ -2411,6 +2513,10 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
2411
2513
  baseUrl;
2412
2514
  appId;
2413
2515
  env;
2516
+ /** PII scrubber toggle. Default true — parity with Web/RN/Swift.
2517
+ * Pre-v1.4.0 the Node SDK shipped track() payloads UNREDACTED,
2518
+ * a privacy contract drift versus the README. */
2519
+ scrubPii;
2414
2520
  secretKeyPrefix;
2415
2521
  /**
2416
2522
  * Process-stable pseudo-anonymous ID. Used as the default identity
@@ -2456,6 +2562,15 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
2456
2562
  errorContext = {};
2457
2563
  errorTags = {};
2458
2564
  errorBeforeSend = null;
2565
+ /**
2566
+ * Dedup gate for `sdk.shutdown`. Both `shutdown()` (async) and
2567
+ * `shutdownSync()` need to emit so direct callers of EITHER see
2568
+ * the event (the async path's listener guarantees pre-launch
2569
+ * tests, the sync path covers `Symbol.dispose` + tests that call
2570
+ * `shutdownSync()` directly). Without this flag, `shutdown()`'s
2571
+ * tail call into `shutdownSync()` would emit twice.
2572
+ */
2573
+ didEmitShutdown = false;
2459
2574
  constructor(options) {
2460
2575
  super();
2461
2576
  if (!options.secretKey || !options.secretKey.startsWith("cd_sk_")) {
@@ -2470,6 +2585,7 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
2470
2585
  this.baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
2471
2586
  this.env = inferEnvFromKey(options.secretKey);
2472
2587
  this.secretKeyPrefix = maskSecretKey(options.secretKey);
2588
+ this.scrubPii = options.scrubPii !== false;
2473
2589
  this.http = new HttpClient({
2474
2590
  secretKey: options.secretKey,
2475
2591
  baseUrl: this.baseUrl,
@@ -2509,9 +2625,16 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
2509
2625
  this.eventQueue = new EventQueue({
2510
2626
  http: this.http,
2511
2627
  batchSize: options.eventFlushBatchSize ?? 20,
2512
- intervalMs: options.eventFlushIntervalMs ?? 1500,
2628
+ // v1.4.0 Phase 3.3 — flush interval default parity at 2000ms
2629
+ // across every SDK. Per-instance override stays.
2630
+ intervalMs: options.eventFlushIntervalMs ?? 2e3,
2513
2631
  envelope: () => ({
2514
2632
  appId: this.appId,
2633
+ // Ship env on every batch so the backend can cross-check
2634
+ // against the API-key-derived env and reject mismatches
2635
+ // loudly (env_mismatch). Web has always done this; node now
2636
+ // matches so defence-in-depth is symmetric across SDKs.
2637
+ environment: this.env,
2515
2638
  sdk: { name: SDK_NAME, version: this.sdkVersion }
2516
2639
  }),
2517
2640
  onDrop: (count) => {
@@ -2527,6 +2650,20 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
2527
2650
  nextRetryMs: info.delayMs
2528
2651
  });
2529
2652
  },
2653
+ onPermanentFailure: (info) => {
2654
+ const headline = `[crossdeck] Event batch DROPPED (status ${info.status}): ${info.lastError}. ${info.droppedCount} event(s) lost \u2014 check your secret key + app config.`;
2655
+ console.error(headline);
2656
+ this.debug.emit(
2657
+ "sdk.flush_permanent_failure",
2658
+ headline,
2659
+ { ...info }
2660
+ );
2661
+ this.emit("queue.permanent_failure", {
2662
+ status: info.status,
2663
+ droppedCount: info.droppedCount,
2664
+ error: info.lastError
2665
+ });
2666
+ },
2530
2667
  onFirstFlushSuccess: () => {
2531
2668
  this.debug.emit("sdk.first_event_sent", "First batch landed.");
2532
2669
  }
@@ -2541,14 +2678,20 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
2541
2678
  report: (err) => this.reportCapturedError(err),
2542
2679
  getContext: () => ({ ...this.errorContext }),
2543
2680
  getTags: () => ({ ...this.errorTags }),
2544
- beforeSend: null,
2545
- // wired via setErrorBeforeSend; ErrorTracker reads it through the live ref below
2546
- isConsented: () => true
2547
- });
2548
- const trackerOpts = this.errorTracker.opts;
2549
- Object.defineProperty(trackerOpts, "beforeSend", {
2550
- get: () => this.errorBeforeSend,
2551
- configurable: true
2681
+ // GETTER, not a captured value — `setErrorBeforeSend()` mutates
2682
+ // `this.errorBeforeSend` after init() and the tracker MUST pick
2683
+ // up the new hook on the next error. Pre-fix we worked around
2684
+ // a captured-by-value field with `Object.defineProperty` on the
2685
+ // tracker's private opts; the contract is now a real getter so
2686
+ // we just hand it the closure and the hack is gone.
2687
+ beforeSend: () => this.errorBeforeSend,
2688
+ isConsented: () => true,
2689
+ // Derived from the configured baseUrl at construction time.
2690
+ // Used by the fetch wrapper to skip captureHttp on Crossdeck's
2691
+ // own requests — pre-fix the skip was hardcoded to
2692
+ // `api.cross-deck.com` and broke for customers on staging /
2693
+ // regional / self-hosted base URLs (recursive capture loop).
2694
+ selfHostname: extractSelfHostname(this.baseUrl)
2552
2695
  });
2553
2696
  this.errorTracker.install();
2554
2697
  }
@@ -2561,6 +2704,7 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
2561
2704
  });
2562
2705
  this.flushOnExit.install();
2563
2706
  }
2707
+ this.emitDurabilityWarning();
2564
2708
  if (options.testMode !== true && options.bootHeartbeat !== false) {
2565
2709
  setImmediate(() => {
2566
2710
  void this.heartbeat().catch((err) => {
@@ -2570,25 +2714,16 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
2570
2714
  { message: err instanceof Error ? err.message : String(err) }
2571
2715
  );
2572
2716
  });
2573
- this.emitBootTelemetry();
2717
+ this.emitBootTelemetryEvent();
2574
2718
  });
2575
2719
  }
2576
2720
  }
2577
2721
  /**
2578
- * Emit the one-time `sdk.boot` telemetry event and, when the runtime
2579
- * is serverless with no `entitlementStore`, the honest "no cold-start
2580
- * durability" warning.
2581
- *
2582
- * Why a `track()` event and not the heartbeat: `GET /v1/sdk/heartbeat`
2583
- * carries no request body, so it cannot transport a structured
2584
- * `durability` fact. The event pipeline can — every `track()` event
2585
- * lands as an aggregatable document the backend can query, so
2586
- * Crossdeck can compute fleet-wide "% serverless-with-no-durable-
2587
- * store" from `sdk.boot` events (denominator = all `sdk.boot`,
2588
- * numerator = those with `durability.coldStartDurable === false`).
2589
- * The event rides the existing batched + retried + idempotent queue
2590
- * and is drained by flush-on-exit, so it survives a serverless
2591
- * teardown — it is NOT a local-only debug log.
2722
+ * Emit the honest "no cold-start durability" warning when the runtime
2723
+ * is serverless AND no `entitlementStore` is wired. Local-only debug
2724
+ * signal — no network call, no phone-home. Safe to fire from the
2725
+ * constructor before `setImmediate` because there is no I/O on this
2726
+ * path.
2592
2727
  *
2593
2728
  * `isServerless` AND no store is the gap: a cold start begins with an
2594
2729
  * empty in-memory cache and a brief Crossdeck outage in that window
@@ -2596,14 +2731,15 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
2596
2731
  * unavoidable without a store — so the SDK STATES it (a
2597
2732
  * `sdk.no_durable_store` debug warning) rather than hiding it.
2598
2733
  *
2599
- * Called once, from the deferred boot block so it inherits the
2600
- * `testMode` / `bootHeartbeat:false` opt-outs and never fires before
2601
- * the constructor returns.
2734
+ * Audit P1 #9: this used to live INSIDE `emitBootTelemetry()` which
2735
+ * itself sat inside the `bootHeartbeat` gate, so any developer who
2736
+ * set `bootHeartbeat: false` silently disabled the entire reason
2737
+ * `entitlementStore` exists. Now split: warning fires
2738
+ * unconditionally; the boot phone-home stays gated.
2602
2739
  */
2603
- emitBootTelemetry() {
2740
+ emitDurabilityWarning() {
2604
2741
  const isServerless = this.runtime.isServerless;
2605
2742
  const hasStore = this.entitlementStore !== null;
2606
- const coldStartDurable = hasStore || !isServerless;
2607
2743
  if (isServerless && !hasStore) {
2608
2744
  this.debug.emit(
2609
2745
  "sdk.no_durable_store",
@@ -2611,6 +2747,26 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
2611
2747
  { host: this.runtime.host, isServerless, durableStore: false }
2612
2748
  );
2613
2749
  }
2750
+ }
2751
+ /**
2752
+ * Emit the one-time `sdk.boot` telemetry event — the aggregatable
2753
+ * fact the backend pivots on (compute fleet-wide
2754
+ * "% serverless-with-no-durable-store"). Rides the batched + retried
2755
+ * + idempotent queue and is drained by flush-on-exit, so it survives
2756
+ * a serverless teardown.
2757
+ *
2758
+ * Why a `track()` event and not the heartbeat: `GET /v1/sdk/heartbeat`
2759
+ * carries no request body, so it cannot transport a structured
2760
+ * `durability` fact.
2761
+ *
2762
+ * Gated by `bootHeartbeat` (and `testMode`) because it IS a phone-
2763
+ * home — the unconditional surface is `emitDurabilityWarning()`,
2764
+ * which has no network call.
2765
+ */
2766
+ emitBootTelemetryEvent() {
2767
+ const isServerless = this.runtime.isServerless;
2768
+ const hasStore = this.entitlementStore !== null;
2769
+ const coldStartDurable = hasStore || !isServerless;
2614
2770
  try {
2615
2771
  this.track({
2616
2772
  name: "sdk.boot",
@@ -2850,6 +3006,38 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
2850
3006
  * `uncaughtException` has no per-request context; without the
2851
3007
  * auto-fill, the event would be rejected at queue enqueue.
2852
3008
  */
3009
+ /**
3010
+ * Emit `crossdeck.contract_failed` with the canonical property
3011
+ * shape. Same wire shape every Crossdeck SDK uses for contract
3012
+ * verification telemetry — see `contracts/README.md` for the
3013
+ * full pattern. No new endpoint, no special path; goes through
3014
+ * the standard server-side `track()` pipeline.
3015
+ */
3016
+ reportContractFailure(input) {
3017
+ const props = {
3018
+ contract_id: input.contractId,
3019
+ sdk_version: SDK_VERSION,
3020
+ sdk_platform: "node",
3021
+ failure_reason: input.failureReason,
3022
+ run_context: input.runContext,
3023
+ run_id: input.runId
3024
+ };
3025
+ if (input.testRef) {
3026
+ props.test_file = input.testRef.file;
3027
+ props.test_name = input.testRef.name;
3028
+ }
3029
+ if (input.extra) {
3030
+ for (const [k, v] of Object.entries(input.extra)) {
3031
+ if (props[k] === void 0) props[k] = v;
3032
+ }
3033
+ }
3034
+ this.track({
3035
+ name: "crossdeck.contract_failed",
3036
+ properties: props
3037
+ // No identity hint — these events are about the SDK / dogfood
3038
+ // run itself, not a specific end-user.
3039
+ });
3040
+ }
2853
3041
  track(event) {
2854
3042
  if (!event.name) {
2855
3043
  throw new CrossdeckError({
@@ -2858,7 +3046,8 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
2858
3046
  message: "track(event) requires a non-empty event.name."
2859
3047
  });
2860
3048
  }
2861
- const sanitized = sanitizePropertyBag(event.properties, "event properties") ?? {};
3049
+ const validated = sanitizePropertyBag(event.properties, "event properties") ?? {};
3050
+ const sanitized = this.scrubPii ? scrubPiiFromProperties(validated) : validated;
2862
3051
  if (this.debug.enabled) {
2863
3052
  const flagged = findSensitivePropertyKeys(sanitized);
2864
3053
  if (flagged.length > 0) {
@@ -2922,7 +3111,13 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
2922
3111
  const normalized = events.map((event) => this.normalizeIngestEvent(event));
2923
3112
  const body = {
2924
3113
  events: normalized,
2925
- sdk: { name: SDK_NAME, version: this.sdkVersion }
3114
+ sdk: { name: SDK_NAME, version: this.sdkVersion },
3115
+ // Match the queue's batch envelope (see event-queue.ts) — backend
3116
+ // cross-checks `environment` against the API-key-derived env and
3117
+ // rejects mismatches loudly (env_mismatch). Pre-fix this direct
3118
+ // ingest path skipped env, so a "live key, env: sandbox"
3119
+ // misconfig fell through silently for the bulk-import path.
3120
+ environment: this.env
2926
3121
  };
2927
3122
  if (this.appId) body.appId = this.appId;
2928
3123
  return this.http.request("POST", "/events", {
@@ -2987,11 +3182,26 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
2987
3182
  message: "syncPurchases requires a signedTransactionInfo string."
2988
3183
  });
2989
3184
  }
2990
- return this.http.request("POST", "/purchases/sync", {
2991
- body: { rail: input.rail ?? "apple", ...input },
3185
+ const rail = input.rail ?? "apple";
3186
+ const body = { ...input, rail };
3187
+ const idempotencyKey = options?.idempotencyKey ?? deriveIdempotencyKeyForPurchase(body);
3188
+ const result = await this.http.request("POST", "/purchases/sync", {
3189
+ body,
3190
+ idempotencyKey,
2992
3191
  signal: options?.signal,
2993
3192
  timeoutMs: options?.timeoutMs
2994
3193
  });
3194
+ try {
3195
+ const sourceProductId = result.entitlements[0]?.source.productId;
3196
+ const sourceSubscriptionId = result.entitlements[0]?.source.subscriptionId;
3197
+ const props = { rail };
3198
+ if (sourceProductId) props.productId = sourceProductId;
3199
+ if (sourceSubscriptionId) props.subscriptionId = sourceSubscriptionId;
3200
+ if (result.idempotent_replay) props.idempotent_replay = true;
3201
+ this.track({ name: "purchase.completed", properties: props });
3202
+ } catch {
3203
+ }
3204
+ return result;
2995
3205
  }
2996
3206
  // ============================================================
2997
3207
  // Manual entitlement controls + audit — direct HTTP
@@ -3283,12 +3493,56 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
3283
3493
  };
3284
3494
  }
3285
3495
  /**
3286
- * Tear down handlers and clear in-memory state. Tests + custom
3287
- * lifecycle callers only. Production code should rely on
3288
- * `flush-on-exit` instead.
3496
+ * Tear down handlers and clear in-memory state.
3497
+ *
3498
+ * **v1.4.0 bank-grade contract:** `shutdown()` AWAITS `flush()`
3499
+ * before dropping the queue, so callers don't silently lose
3500
+ * every queued event on a clean shutdown. The pre-v1.4.0
3501
+ * behaviour (sync `eventQueue.reset()` with no flush) was the
3502
+ * default for both `shutdown()` and `[Symbol.dispose]`; only
3503
+ * `await using` + `[Symbol.asyncDispose]` flushed correctly.
3504
+ *
3505
+ * Production servers should still prefer `await server.flush()`
3506
+ * (visible) followed by `server.shutdown()` so the flush
3507
+ * outcome is observable — `shutdown()`'s internal flush swallows
3508
+ * errors as a best-effort drain.
3509
+ *
3510
+ * Use [[shutdownSync]] only when the runtime cannot await
3511
+ * (e.g. inside `Symbol.dispose` — see below).
3289
3512
  */
3290
- shutdown(reason = "shutdown") {
3291
- this.emit("sdk.shutdown", { reason });
3513
+ async shutdown(reason = "shutdown") {
3514
+ if (!this.didEmitShutdown) {
3515
+ this.emit("sdk.shutdown", { reason });
3516
+ this.didEmitShutdown = true;
3517
+ }
3518
+ try {
3519
+ await this.flush();
3520
+ } catch {
3521
+ }
3522
+ this.shutdownSync(reason);
3523
+ }
3524
+ /**
3525
+ * Synchronous teardown — drops the in-memory queue WITHOUT
3526
+ * flushing, then clears all in-memory state. Used by
3527
+ * `[Symbol.dispose]` (which has no await) and tests that need
3528
+ * an unconditional sync wipe. Production code should use
3529
+ * [[shutdown]] (async) instead so queued events are flushed.
3530
+ *
3531
+ * A queue with items at sync-shutdown logs a warning recommending
3532
+ * `[Symbol.asyncDispose]` or `await server.shutdown()` — silent
3533
+ * loss is incompatible with the bank-grade contract.
3534
+ */
3535
+ shutdownSync(reason = "shutdown") {
3536
+ if (!this.didEmitShutdown) {
3537
+ this.emit("sdk.shutdown", { reason });
3538
+ this.didEmitShutdown = true;
3539
+ }
3540
+ const queuedCount = this.eventQueue.getStats().buffered;
3541
+ if (queuedCount > 0 && reason !== "asyncDispose") {
3542
+ console.warn(
3543
+ `[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.`
3544
+ );
3545
+ }
3292
3546
  this.errorTracker?.uninstall();
3293
3547
  this.flushOnExit?.uninstall();
3294
3548
  this.eventQueue.reset();
@@ -3402,28 +3656,28 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
3402
3656
  * // ... use server ...
3403
3657
  * // at end of block, server[Symbol.dispose]() runs automatically
3404
3658
  *
3405
- * `Symbol.dispose` is synchronous so we can't await `flush()` here
3406
- * for that, use `await using` + `[Symbol.asyncDispose]()`. This
3407
- * sync variant just calls `shutdown()` (handler cleanup +
3408
- * in-memory state wipe).
3659
+ * **`Symbol.dispose` is synchronous so it CANNOT await the queue
3660
+ * flush.** A queue with pending events at sync-dispose time will
3661
+ * be DROPPED `shutdownSync` warns to the console when this
3662
+ * happens. For the common case of "drain the queue before
3663
+ * exit", switch to `await using` + `[Symbol.asyncDispose]` (or
3664
+ * call `await server.shutdown()` explicitly before the variable
3665
+ * goes out of scope).
3409
3666
  */
3410
3667
  [Symbol.dispose]() {
3411
- this.shutdown("dispose");
3668
+ this.shutdownSync("dispose");
3412
3669
  }
3413
3670
  /**
3414
3671
  * Async disposal hook — runs when an `await using` declaration
3415
- * exits scope. Awaits `flush()` THEN runs `shutdown()`. Use this
3416
- * variant when the caller needs the queue drained before exit
3417
- * (the common case for serverless handlers).
3672
+ * exits scope. Awaits the bank-grade `shutdown()` which flushes
3673
+ * the queue THEN tears down. Use this variant for any code path
3674
+ * that owns queued events at exit (serverless handlers,
3675
+ * background workers, end-of-request hooks).
3418
3676
  *
3419
3677
  * await using server = new CrossdeckServer({ ... });
3420
3678
  */
3421
3679
  async [Symbol.asyncDispose]() {
3422
- try {
3423
- await this.flush();
3424
- } catch {
3425
- }
3426
- this.shutdown("asyncDispose");
3680
+ await this.shutdown("asyncDispose");
3427
3681
  }
3428
3682
  // ============================================================
3429
3683
  reportCapturedError(captured) {
@@ -3583,10 +3837,21 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
3583
3837
  * Resolve any hint shape (canonical customerId / userId hint /
3584
3838
  * anonymousId hint / raw string) to a `crossdeckCustomerId` if we
3585
3839
  * have a cache entry for it.
3840
+ *
3841
+ * String overload is STRICT on the canonical-id shape. Pre-fix
3842
+ * `isFresh(raw)` treated any string with a cache entry as a valid
3843
+ * canonical id — if tenant A's userId happened to collide with
3844
+ * tenant B's crossdeckCustomerId, A's call would resolve to B's
3845
+ * cached entitlements. Bounded by the `cdcust_` prefix convention
3846
+ * (which both SDKs and the backend mint, see
3847
+ * backend/src/lib/customers.ts) — anything else is treated purely
3848
+ * as an alias lookup, never as a canonical id. Audit P1 #19.
3586
3849
  */
3587
3850
  resolveCacheCustomerId(hint) {
3588
3851
  if (typeof hint === "string") {
3589
- if (this.entitlementCache.isFresh(hint)) return hint;
3852
+ if (hint.startsWith("cdcust_") && this.entitlementCache.isFresh(hint)) {
3853
+ return hint;
3854
+ }
3590
3855
  return this.customerIdAliases.get(hint) ?? null;
3591
3856
  }
3592
3857
  if (hint.customerId) return hint.customerId;
@@ -3832,18 +4097,43 @@ var _CROSSDECK_ERROR_CODES = Object.freeze([
3832
4097
  retryable: false
3833
4098
  },
3834
4099
  // ----- Webhook verification (Node-specific) -----
4100
+ // v1.4.0 Phase 7.2 — distinguishable codes. Pre-v1.4.0 the
4101
+ // helper used webhook_invalid_signature for nearly every failure
4102
+ // mode so a customer couldn't separate replay-attack signals
4103
+ // from wrong-secret signals in alerting.
3835
4104
  {
3836
- code: "webhook_invalid_signature",
4105
+ code: "webhook_signature_mismatch",
3837
4106
  type: "authentication_error",
3838
- description: "The webhook signature header did not verify against the supplied secret.",
3839
- resolution: "Confirm the secret matches the one in your Crossdeck dashboard \u2192 Webhooks page. If the request is genuinely from Crossdeck, the secret is wrong, stale, or recently rotated.",
4107
+ description: "Webhook HMAC didn't verify against any configured secret (wrong-secret / stale rotation signal).",
4108
+ 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.",
3840
4109
  retryable: false
3841
4110
  },
3842
4111
  {
3843
- code: "webhook_replay_window_exceeded",
4112
+ code: "webhook_timestamp_outside_tolerance",
4113
+ type: "authentication_error",
4114
+ description: "Webhook timestamp drift exceeds the configured replay-tolerance window (default 5 minutes; replay-attack signal).",
4115
+ 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.",
4116
+ retryable: false
4117
+ },
4118
+ {
4119
+ code: "webhook_timestamp_missing",
3844
4120
  type: "authentication_error",
3845
- description: "The webhook timestamp is older than the replay-tolerance window (default 5 minutes).",
3846
- resolution: "The webhook is either replayed or your receiving clock is wildly skewed. Verify NTP on the receiving host. Increase replayToleranceMs only if you accept the replay-attack risk.",
4121
+ description: "Webhook signature header is absent or has no `t=` timestamp segment \u2014 the timestamp gate cannot be verified.",
4122
+ 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.",
4123
+ retryable: false
4124
+ },
4125
+ {
4126
+ code: "webhook_payload_not_json",
4127
+ type: "authentication_error",
4128
+ description: "Webhook signature verified but the body isn't valid JSON \u2014 payload tampered post-signing or source bug.",
4129
+ 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.",
4130
+ retryable: false
4131
+ },
4132
+ {
4133
+ code: "webhook_invalid_tolerance",
4134
+ type: "configuration_error",
4135
+ description: "verifyWebhookSignature() called with a non-finite / negative / above-24h-cap replayToleranceMs (would silently disable replay protection).",
4136
+ 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.",
3847
4137
  retryable: false
3848
4138
  },
3849
4139
  {
@@ -3852,6 +4142,101 @@ var _CROSSDECK_ERROR_CODES = Object.freeze([
3852
4142
  description: "verifyWebhookSignature() was called without a signing secret.",
3853
4143
  resolution: "Pass the secret from your Crossdeck dashboard \u2192 Webhooks page. Never hardcode in source \u2014 read from an env var.",
3854
4144
  retryable: false
4145
+ },
4146
+ {
4147
+ code: "webhook_invalid_signature",
4148
+ type: "authentication_error",
4149
+ 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.",
4150
+ resolution: "Migrate alert rules to the more specific v1.4.0 codes \u2014 they distinguish replay-attack signals from wrong-secret signals.",
4151
+ retryable: false
4152
+ },
4153
+ {
4154
+ code: "webhook_replay_window_exceeded",
4155
+ type: "authentication_error",
4156
+ description: "DEPRECATED in v1.4.0 \u2014 renamed to webhook_timestamp_outside_tolerance.",
4157
+ resolution: "Update alerts to webhook_timestamp_outside_tolerance.",
4158
+ retryable: false
4159
+ },
4160
+ // ----- Backend-emitted codes (v1.4.0 Phase 6.2 backfill) -----
4161
+ // Mirror of backend/src/api/v1-errors.ts ApiErrorCode. Same set
4162
+ // as the Web SDK ships — keep these synchronised so a developer
4163
+ // hitting any code via either SDK gets the same remediation.
4164
+ {
4165
+ code: "missing_api_key",
4166
+ type: "authentication_error",
4167
+ description: "No Authorization header (or Crossdeck-Api-Key header) on the request.",
4168
+ resolution: "Confirm the CrossdeckServer was constructed with a cd_sk_\u2026 secretKey. Re-check env vars in production deployments.",
4169
+ retryable: false
4170
+ },
4171
+ {
4172
+ code: "invalid_api_key",
4173
+ type: "authentication_error",
4174
+ description: "The secret key is malformed, unknown, or doesn't resolve to a project.",
4175
+ 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.",
4176
+ retryable: false
4177
+ },
4178
+ {
4179
+ code: "key_revoked",
4180
+ type: "authentication_error",
4181
+ description: "The secret key was revoked in the dashboard.",
4182
+ resolution: "Mint a fresh key in dashboard \u2192 API keys \u2192 Create new. The revoked key cannot be reactivated.",
4183
+ retryable: false
4184
+ },
4185
+ {
4186
+ code: "env_mismatch",
4187
+ type: "permission_error",
4188
+ description: "The key's env prefix doesn't match the resolved app's configured env.",
4189
+ resolution: "Use a cd_sk_live_ key with a production app, cd_sk_test_ with a sandbox app. Crossing breaks the env lock.",
4190
+ retryable: false
4191
+ },
4192
+ {
4193
+ code: "idempotency_key_in_use",
4194
+ type: "invalid_request_error",
4195
+ description: "An Idempotency-Key was reused for a request with a different body (Stripe-grade contract).",
4196
+ 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.",
4197
+ retryable: false
4198
+ },
4199
+ {
4200
+ code: "rate_limited",
4201
+ type: "rate_limit_error",
4202
+ description: "Request rate exceeded the project's per-second cap.",
4203
+ resolution: "Honour Retry-After (managed retries do this automatically). For custom paths, throttle to <100 req/s/key.",
4204
+ retryable: true
4205
+ },
4206
+ {
4207
+ code: "internal_error",
4208
+ type: "internal_error",
4209
+ description: "Server-side issue. Safe to retry with backoff.",
4210
+ resolution: "Managed retries handle this automatically. If a code path surfaces it to your code, contact support with the requestId.",
4211
+ retryable: true
4212
+ },
4213
+ {
4214
+ code: "google_not_supported",
4215
+ type: "invalid_request_error",
4216
+ description: "POST /purchases/sync with rail=google is gated until the Play Developer API reconciliation worker ships.",
4217
+ resolution: "Until v1.5+, Google Play purchases verify via Real-time Developer Notifications. The Android SDK auto-track path handles this transparently.",
4218
+ retryable: false
4219
+ },
4220
+ {
4221
+ code: "stripe_not_supported",
4222
+ type: "invalid_request_error",
4223
+ description: "POST /purchases/sync with rail=stripe is unsupported \u2014 Stripe webhooks deliver evidence server-side.",
4224
+ resolution: "Wire Stripe via the standard Checkout / Customer Portal flow; Crossdeck reconciles via the platform webhook automatically.",
4225
+ retryable: false
4226
+ },
4227
+ {
4228
+ code: "missing_required_param",
4229
+ type: "invalid_request_error",
4230
+ description: "A required field is absent from the request body.",
4231
+ resolution: "The error.message identifies the missing field. Refer to the SDK's TypeScript types for canonical shapes.",
4232
+ retryable: false
4233
+ },
4234
+ {
4235
+ code: "invalid_param_value",
4236
+ type: "invalid_request_error",
4237
+ description: "A field is present but the value failed validation.",
4238
+ resolution: "Read error.message for the field + reason. SDK-managed call sites should never emit this \u2014 file a bug if you do.",
4239
+ retryable: false
3855
4240
  }
3856
4241
  ]);
3857
4242
  function isCrossdeckErrorCode(code) {
@@ -3863,8 +4248,9 @@ function getErrorCode(code) {
3863
4248
  }
3864
4249
 
3865
4250
  // src/webhooks.ts
3866
- var import_node_crypto = require("crypto");
4251
+ var import_node_crypto2 = require("crypto");
3867
4252
  var DEFAULT_REPLAY_TOLERANCE_MS = 5 * 60 * 1e3;
4253
+ var MAX_REPLAY_TOLERANCE_MS = 24 * 60 * 60 * 1e3;
3868
4254
  function verifyWebhookSignature(payload, signatureHeader, secret, options = {}) {
3869
4255
  const secrets = normaliseSecrets(secret);
3870
4256
  if (secrets.length === 0) {
@@ -3874,46 +4260,68 @@ function verifyWebhookSignature(payload, signatureHeader, secret, options = {})
3874
4260
  message: "verifyWebhookSignature requires a non-empty secret. Read it from process.env.CROSSDECK_WEBHOOK_SECRET \u2014 never hardcode in source."
3875
4261
  });
3876
4262
  }
4263
+ const requestedTolerance = options.replayToleranceMs;
4264
+ let tolerance;
4265
+ if (requestedTolerance === void 0) {
4266
+ tolerance = DEFAULT_REPLAY_TOLERANCE_MS;
4267
+ } else if (typeof requestedTolerance !== "number" || !Number.isFinite(requestedTolerance)) {
4268
+ throw new CrossdeckError({
4269
+ type: "configuration_error",
4270
+ code: "webhook_invalid_tolerance",
4271
+ 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.`
4272
+ });
4273
+ } else if (requestedTolerance < 0) {
4274
+ throw new CrossdeckError({
4275
+ type: "configuration_error",
4276
+ code: "webhook_invalid_tolerance",
4277
+ message: `replayToleranceMs must be \u2265 0. Got ${requestedTolerance}.`
4278
+ });
4279
+ } else if (requestedTolerance > MAX_REPLAY_TOLERANCE_MS) {
4280
+ throw new CrossdeckError({
4281
+ type: "configuration_error",
4282
+ code: "webhook_invalid_tolerance",
4283
+ message: `replayToleranceMs must not exceed ${MAX_REPLAY_TOLERANCE_MS}ms (24h). Got ${requestedTolerance}ms \u2014 a window that wide defeats replay protection.`
4284
+ });
4285
+ } else {
4286
+ tolerance = requestedTolerance;
4287
+ }
3877
4288
  const header = normaliseHeader(signatureHeader);
3878
4289
  const parsed = parseSignatureHeader(header);
3879
4290
  if (!parsed) {
3880
4291
  throw new CrossdeckError({
3881
4292
  type: "authentication_error",
3882
- code: "webhook_invalid_signature",
3883
- message: "Webhook signature header is missing or malformed. Expected 'Crossdeck-Signature: t=<unix>,v1=<hex>'."
4293
+ code: "webhook_timestamp_missing",
4294
+ message: "Webhook signature header is missing, malformed, or has no `t=` timestamp segment. Expected 'Crossdeck-Signature: t=<unix>,v1=<hex>'."
3884
4295
  });
3885
4296
  }
3886
- const tolerance = options.replayToleranceMs ?? DEFAULT_REPLAY_TOLERANCE_MS;
3887
- if (tolerance > 0) {
3888
- const now = (options.now ?? Date.now)();
3889
- const timestampMs = parsed.timestampSec * 1e3;
3890
- const drift = Math.abs(now - timestampMs);
3891
- if (drift > tolerance) {
3892
- throw new CrossdeckError({
3893
- type: "authentication_error",
3894
- code: "webhook_replay_window_exceeded",
3895
- 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.`
3896
- });
3897
- }
4297
+ const now = (options.now ?? Date.now)();
4298
+ const timestampMs = parsed.timestampSec * 1e3;
4299
+ const drift = Math.abs(now - timestampMs);
4300
+ if (drift > tolerance) {
4301
+ throw new CrossdeckError({
4302
+ type: "authentication_error",
4303
+ code: "webhook_timestamp_outside_tolerance",
4304
+ 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.`
4305
+ });
3898
4306
  }
3899
4307
  const signedPayload = `${parsed.timestampSec}.${payload}`;
3900
4308
  const expectedBuf = Buffer.from(parsed.signature, "hex");
3901
4309
  if (expectedBuf.length === 0) {
3902
4310
  throw new CrossdeckError({
3903
4311
  type: "authentication_error",
3904
- code: "webhook_invalid_signature",
4312
+ code: "webhook_signature_mismatch",
3905
4313
  message: "Webhook signature is not a valid hex string."
3906
4314
  });
3907
4315
  }
3908
4316
  const anyMatch = secrets.some((s) => {
3909
- const computed = (0, import_node_crypto.createHmac)("sha256", s).update(signedPayload).digest();
3910
- return computed.length === expectedBuf.length && (0, import_node_crypto.timingSafeEqual)(computed, expectedBuf);
4317
+ const computed = (0, import_node_crypto2.createHmac)("sha256", s).update(signedPayload).digest();
4318
+ return computed.length === expectedBuf.length && (0, import_node_crypto2.timingSafeEqual)(computed, expectedBuf);
3911
4319
  });
3912
4320
  if (!anyMatch) {
3913
4321
  throw new CrossdeckError({
3914
4322
  type: "authentication_error",
3915
- code: "webhook_invalid_signature",
3916
- 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)."
4323
+ code: "webhook_signature_mismatch",
4324
+ 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)."
3917
4325
  });
3918
4326
  }
3919
4327
  try {
@@ -3921,13 +4329,13 @@ function verifyWebhookSignature(payload, signatureHeader, secret, options = {})
3921
4329
  } catch {
3922
4330
  throw new CrossdeckError({
3923
4331
  type: "authentication_error",
3924
- code: "webhook_invalid_signature",
4332
+ code: "webhook_payload_not_json",
3925
4333
  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."
3926
4334
  });
3927
4335
  }
3928
4336
  }
3929
4337
  function signWebhookPayload(payload, secret, timestampSec) {
3930
- return (0, import_node_crypto.createHmac)("sha256", secret).update(`${timestampSec}.${payload}`).digest("hex");
4338
+ return (0, import_node_crypto2.createHmac)("sha256", secret).update(`${timestampSec}.${payload}`).digest("hex");
3931
4339
  }
3932
4340
  function parseSignatureHeader(header) {
3933
4341
  if (!header) return null;
@@ -3959,45 +4367,472 @@ function normaliseSecrets(input) {
3959
4367
  return arr.filter((s) => typeof s === "string" && s.length > 0);
3960
4368
  }
3961
4369
 
3962
- // src/consent.ts
3963
- var EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
3964
- var CARD_PATTERN = /\b\d(?:[ -]?\d){12,18}\b/g;
3965
- var REPLACEMENT_EMAIL = "[email]";
3966
- var REPLACEMENT_CARD = "[card]";
3967
- function scrubPii(value) {
3968
- if (!value) return value;
3969
- let out = value;
3970
- if (EMAIL_PATTERN.test(out)) {
3971
- out = out.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL);
3972
- }
3973
- EMAIL_PATTERN.lastIndex = 0;
3974
- if (CARD_PATTERN.test(out)) {
3975
- out = out.replace(CARD_PATTERN, REPLACEMENT_CARD);
3976
- }
3977
- CARD_PATTERN.lastIndex = 0;
3978
- return out;
3979
- }
3980
- function scrubPiiFromProperties(properties) {
3981
- const out = {};
3982
- for (const k of Object.keys(properties)) {
3983
- out[k] = scrubValue(properties[k]);
4370
+ // src/_contracts-bundled.ts
4371
+ var BUNDLED_IN = "@cross-deck/node@1.5.0";
4372
+ var SDK_VERSION2 = "1.5.0";
4373
+ var BUNDLED_CONTRACTS = Object.freeze([
4374
+ {
4375
+ "id": "documentation-honesty",
4376
+ "pillar": "webhooks",
4377
+ "status": "enforced",
4378
+ "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.",
4379
+ "appliesTo": [
4380
+ "node",
4381
+ "backend"
4382
+ ],
4383
+ "codeRef": [
4384
+ "sdks/node/src/webhooks.ts",
4385
+ "docs/rail-webhooks/index.html",
4386
+ "docs/webhooks-receive/index.html"
4387
+ ],
4388
+ "testRef": [
4389
+ {
4390
+ "file": "sdks/node/src/webhooks.ts",
4391
+ "name": "[ROADMAP \u2014 v1.4.0 honesty note]"
4392
+ },
4393
+ {
4394
+ "file": "docs/rail-webhooks/index.html",
4395
+ "name": "Outbound push-to-your-backend webhooks are <strong>roadmap</strong>"
4396
+ },
4397
+ {
4398
+ "file": "docs/webhooks-receive/index.html",
4399
+ "name": "This feature is on the roadmap"
4400
+ }
4401
+ ],
4402
+ "registeredAt": "2026-05-26",
4403
+ "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 7.1",
4404
+ "bundledIn": "@cross-deck/node@1.5.0"
4405
+ },
4406
+ {
4407
+ "id": "error-envelope-shape",
4408
+ "pillar": "errors",
4409
+ "status": "enforced",
4410
+ "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.",
4411
+ "appliesTo": [
4412
+ "web",
4413
+ "node",
4414
+ "react-native",
4415
+ "swift",
4416
+ "android",
4417
+ "backend"
4418
+ ],
4419
+ "codeRef": [
4420
+ "backend/src/api/v1-errors.ts",
4421
+ "sdks/web/src/errors.ts",
4422
+ "sdks/node/src/errors.ts",
4423
+ "sdks/react-native/src/errors.ts",
4424
+ "sdks/swift/Sources/Crossdeck/Errors.swift",
4425
+ "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/Errors.kt"
4426
+ ],
4427
+ "testRef": [
4428
+ {
4429
+ "file": "sdks/swift/Tests/CrossdeckTests/ErrorsTests.swift",
4430
+ "name": "test_errorEnvelope_fallsBackOnGarbageBody"
4431
+ },
4432
+ {
4433
+ "file": "sdks/swift/Tests/CrossdeckTests/ErrorsTests.swift",
4434
+ "name": "test_errorEnvelope_reads_XRequestId_fallback"
4435
+ },
4436
+ {
4437
+ "file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/ErrorTypeWireVocabTest.kt",
4438
+ "name": "backend 500 response parses to INTERNAL_ERROR"
4439
+ }
4440
+ ],
4441
+ "registeredAt": "2026-05-26",
4442
+ "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 8 (codifies existing contract)",
4443
+ "bundledIn": "@cross-deck/node@1.5.0"
4444
+ },
4445
+ {
4446
+ "id": "flush-interval-parity",
4447
+ "pillar": "analytics",
4448
+ "status": "enforced",
4449
+ "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.",
4450
+ "appliesTo": [
4451
+ "web",
4452
+ "node",
4453
+ "react-native",
4454
+ "swift",
4455
+ "android"
4456
+ ],
4457
+ "codeRef": [
4458
+ "sdks/web/src/crossdeck.ts",
4459
+ "sdks/node/src/crossdeck-server.ts",
4460
+ "sdks/react-native/src/crossdeck.ts",
4461
+ "sdks/swift/Sources/Crossdeck/EventQueue.swift",
4462
+ "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/EventQueue.kt"
4463
+ ],
4464
+ "testRef": [
4465
+ {
4466
+ "file": "sdks/swift/Sources/Crossdeck/EventQueue.swift",
4467
+ "name": "flushIntervalMs: Int = 2_000"
4468
+ },
4469
+ {
4470
+ "file": "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/EventQueue.kt",
4471
+ "name": "flushIntervalMs: Long = 2_000L"
4472
+ },
4473
+ {
4474
+ "file": "sdks/web/src/crossdeck.ts",
4475
+ "name": "options.eventFlushIntervalMs ?? 2000"
4476
+ },
4477
+ {
4478
+ "file": "sdks/node/src/crossdeck-server.ts",
4479
+ "name": "options.eventFlushIntervalMs ?? 2000"
4480
+ },
4481
+ {
4482
+ "file": "sdks/react-native/src/crossdeck.ts",
4483
+ "name": "options.eventFlushIntervalMs ?? 2000"
4484
+ }
4485
+ ],
4486
+ "registeredAt": "2026-05-26",
4487
+ "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 3.3",
4488
+ "bundledIn": "@cross-deck/node@1.5.0"
4489
+ },
4490
+ {
4491
+ "id": "idempotency-key-deterministic",
4492
+ "pillar": "revenue",
4493
+ "status": "enforced",
4494
+ "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.",
4495
+ "appliesTo": [
4496
+ "web",
4497
+ "node",
4498
+ "react-native",
4499
+ "swift",
4500
+ "android",
4501
+ "backend"
4502
+ ],
4503
+ "codeRef": [
4504
+ "sdks/web/src/idempotency-key.ts",
4505
+ "sdks/web/src/crossdeck.ts",
4506
+ "sdks/react-native/src/idempotency-key.ts",
4507
+ "sdks/react-native/src/crossdeck.ts",
4508
+ "sdks/node/src/idempotency-key.ts",
4509
+ "sdks/node/src/crossdeck-server.ts",
4510
+ "sdks/swift/Sources/Crossdeck/IdempotencyKey.swift",
4511
+ "sdks/swift/Sources/Crossdeck/Crossdeck.swift",
4512
+ "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/IdempotencyKey.kt",
4513
+ "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/Crossdeck.kt",
4514
+ "backend/src/lib/idempotency-response-cache.ts",
4515
+ "backend/src/api/v1-purchases.ts"
4516
+ ],
4517
+ "testRef": [
4518
+ {
4519
+ "file": "sdks/web/tests/idempotency-key.test.ts",
4520
+ "name": "cross-SDK oracle \u2014 apple JWS pins canonical vector"
4521
+ },
4522
+ {
4523
+ "file": "sdks/web/tests/idempotency-key.test.ts",
4524
+ "name": "is deterministic: same body twice -> identical key"
4525
+ },
4526
+ {
4527
+ "file": "sdks/web/tests/idempotency-key.test.ts",
4528
+ "name": "same identifier under different rails -> different keys"
4529
+ },
4530
+ {
4531
+ "file": "sdks/web/tests/idempotency-key.test.ts",
4532
+ "name": "never silently falls back to a random key on missing identifier"
4533
+ },
4534
+ {
4535
+ "file": "sdks/react-native/tests/idempotency-key.test.ts",
4536
+ "name": "is deterministic"
4537
+ },
4538
+ {
4539
+ "file": "sdks/react-native/tests/idempotency-key.test.ts",
4540
+ "name": "cross-SDK oracle \u2014 apple JWS pins canonical vector"
4541
+ },
4542
+ {
4543
+ "file": "sdks/node/tests/idempotency-key.test.ts",
4544
+ "name": "is deterministic"
4545
+ },
4546
+ {
4547
+ "file": "sdks/node/tests/idempotency-key.test.ts",
4548
+ "name": "rail namespacing prevents cross-rail collisions"
4549
+ },
4550
+ {
4551
+ "file": "sdks/node/tests/idempotency-key.test.ts",
4552
+ "name": "apple JWS produces the canonical pinned UUID across all 5 SDKs"
4553
+ },
4554
+ {
4555
+ "file": "backend/tests/unit/idempotency-response-cache.test.ts",
4556
+ "name": "is deterministic for the same input"
4557
+ },
4558
+ {
4559
+ "file": "backend/tests/unit/idempotency-response-cache.test.ts",
4560
+ "name": "injects idempotent_replay: true into a JSON object body"
4561
+ },
4562
+ {
4563
+ "file": "backend/tests/unit/idempotency-response-cache.test.ts",
4564
+ "name": "matches Stripe's 24-hour idempotency window"
4565
+ },
4566
+ {
4567
+ "file": "sdks/swift/Tests/CrossdeckTests/IdempotencyKeyTests.swift",
4568
+ "name": "test_crossSdkOracle_appleJWS"
4569
+ },
4570
+ {
4571
+ "file": "sdks/swift/Tests/CrossdeckTests/IdempotencyKeyTests.swift",
4572
+ "name": "test_railNamespacing_preventsCrossRailCollisions"
4573
+ },
4574
+ {
4575
+ "file": "sdks/swift/Tests/CrossdeckTests/IdempotencyKeyTests.swift",
4576
+ "name": "test_missingIdentifier_returnsNil"
4577
+ },
4578
+ {
4579
+ "file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/IdempotencyKeyTest.kt",
4580
+ "name": "cross-SDK oracle for apple JWS"
4581
+ },
4582
+ {
4583
+ "file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/IdempotencyKeyTest.kt",
4584
+ "name": "rail namespacing prevents cross-rail collisions"
4585
+ },
4586
+ {
4587
+ "file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/IdempotencyKeyTest.kt",
4588
+ "name": "missing identifier returns null - never silent random fallback"
4589
+ }
4590
+ ],
4591
+ "registeredAt": "2026-05-26",
4592
+ "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 2.2.a + 2.2.b + 2.2.c",
4593
+ "bundledIn": "@cross-deck/node@1.5.0"
4594
+ },
4595
+ {
4596
+ "id": "node-pii-scrubber",
4597
+ "pillar": "analytics",
4598
+ "status": "enforced",
4599
+ "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.",
4600
+ "appliesTo": [
4601
+ "node"
4602
+ ],
4603
+ "codeRef": [
4604
+ "sdks/node/src/crossdeck-server.ts",
4605
+ "sdks/node/src/types.ts",
4606
+ "sdks/node/src/consent.ts"
4607
+ ],
4608
+ "testRef": [
4609
+ {
4610
+ "file": "sdks/node/tests/track-pii-scrub.test.ts",
4611
+ "name": "by default redacts email-shaped values to <email>"
4612
+ },
4613
+ {
4614
+ "file": "sdks/node/tests/track-pii-scrub.test.ts",
4615
+ "name": "redacts card-number-shaped values to <card>"
4616
+ },
4617
+ {
4618
+ "file": "sdks/node/tests/track-pii-scrub.test.ts",
4619
+ "name": "walks nested maps + arrays"
4620
+ },
4621
+ {
4622
+ "file": "sdks/node/tests/track-pii-scrub.test.ts",
4623
+ "name": "scrubPii: false preserves the raw payload (opt-out)"
4624
+ },
4625
+ {
4626
+ "file": "sdks/node/tests/track-pii-scrub.test.ts",
4627
+ "name": "scrubPii: true is the default when option is omitted"
4628
+ }
4629
+ ],
4630
+ "registeredAt": "2026-05-26",
4631
+ "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 3.1",
4632
+ "bundledIn": "@cross-deck/node@1.5.0"
4633
+ },
4634
+ {
4635
+ "id": "node-shutdown-awaits-flush",
4636
+ "pillar": "lifecycle",
4637
+ "status": "enforced",
4638
+ "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().",
4639
+ "appliesTo": [
4640
+ "node"
4641
+ ],
4642
+ "codeRef": [
4643
+ "sdks/node/src/crossdeck-server.ts"
4644
+ ],
4645
+ "testRef": [
4646
+ {
4647
+ "file": "sdks/node/tests/shutdown-flush.test.ts",
4648
+ "name": "async shutdown() flushes queued events before clearing"
4649
+ },
4650
+ {
4651
+ "file": "sdks/node/tests/shutdown-flush.test.ts",
4652
+ "name": "async shutdown() proceeds with teardown even if flush fails"
4653
+ },
4654
+ {
4655
+ "file": "sdks/node/tests/shutdown-flush.test.ts",
4656
+ "name": "sync shutdownSync() warns when the buffer has events at teardown"
4657
+ },
4658
+ {
4659
+ "file": "sdks/node/tests/shutdown-flush.test.ts",
4660
+ "name": "[Symbol.asyncDispose] equals await server.shutdown()"
4661
+ }
4662
+ ],
4663
+ "registeredAt": "2026-05-26",
4664
+ "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 5.4",
4665
+ "bundledIn": "@cross-deck/node@1.5.0"
4666
+ },
4667
+ {
4668
+ "id": "sdk-error-codes-catalogue",
4669
+ "pillar": "errors",
4670
+ "status": "enforced",
4671
+ "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.",
4672
+ "appliesTo": [
4673
+ "web",
4674
+ "node"
4675
+ ],
4676
+ "codeRef": [
4677
+ "sdks/web/src/error-codes.ts",
4678
+ "sdks/node/src/error-codes.ts",
4679
+ "backend/src/api/v1-errors.ts"
4680
+ ],
4681
+ "testRef": [
4682
+ {
4683
+ "file": "sdks/web/tests/error-codes-backfill.test.ts",
4684
+ "name": "includes backend code"
4685
+ },
4686
+ {
4687
+ "file": "sdks/web/tests/error-codes-backfill.test.ts",
4688
+ "name": "invalid_api_key resolution points at the dashboard"
4689
+ },
4690
+ {
4691
+ "file": "sdks/web/tests/error-codes-backfill.test.ts",
4692
+ "name": "idempotency_key_in_use resolution mentions Stripe-grade contract"
4693
+ },
4694
+ {
4695
+ "file": "sdks/web/tests/error-codes-backfill.test.ts",
4696
+ "name": "identity-lock codes carry permission_error type"
4697
+ },
4698
+ {
4699
+ "file": "sdks/web/tests/error-codes-backfill.test.ts",
4700
+ "name": "no entry has an empty description or resolution"
4701
+ }
4702
+ ],
4703
+ "registeredAt": "2026-05-26",
4704
+ "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 6.2",
4705
+ "bundledIn": "@cross-deck/node@1.5.0"
4706
+ },
4707
+ {
4708
+ "id": "sync-purchases-funnel-parity",
4709
+ "pillar": "analytics",
4710
+ "status": "enforced",
4711
+ "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`.",
4712
+ "appliesTo": [
4713
+ "web",
4714
+ "node",
4715
+ "react-native",
4716
+ "swift",
4717
+ "android"
4718
+ ],
4719
+ "codeRef": [
4720
+ "sdks/web/src/crossdeck.ts",
4721
+ "sdks/node/src/crossdeck-server.ts",
4722
+ "sdks/react-native/src/crossdeck.ts",
4723
+ "sdks/swift/Sources/Crossdeck/Crossdeck.swift",
4724
+ "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/Crossdeck.kt"
4725
+ ],
4726
+ "testRef": [
4727
+ {
4728
+ "file": "sdks/web/tests/sync-purchases-funnel.test.ts",
4729
+ "name": "emits purchase.completed after a successful sync"
4730
+ },
4731
+ {
4732
+ "file": "sdks/web/tests/sync-purchases-funnel.test.ts",
4733
+ "name": "carries idempotent_replay=true when backend replied from cache"
4734
+ }
4735
+ ],
4736
+ "registeredAt": "2026-05-26",
4737
+ "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 3.5",
4738
+ "bundledIn": "@cross-deck/node@1.5.0"
4739
+ },
4740
+ {
4741
+ "id": "verifier-timestamp-mandatory",
4742
+ "pillar": "webhooks",
4743
+ "status": "enforced",
4744
+ "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.",
4745
+ "appliesTo": [
4746
+ "node"
4747
+ ],
4748
+ "codeRef": [
4749
+ "sdks/node/src/webhooks.ts",
4750
+ "sdks/node/src/error-codes.ts"
4751
+ ],
4752
+ "testRef": [
4753
+ {
4754
+ "file": "sdks/node/tests/webhooks.test.ts",
4755
+ "name": "tolerance of 0 still enforces the replay window (v1.4.0 \u2014 cannot disable)"
4756
+ },
4757
+ {
4758
+ "file": "sdks/node/tests/webhooks.test.ts",
4759
+ "name": "rejects Infinity tolerance (would silently disable replay protection)"
4760
+ },
4761
+ {
4762
+ "file": "sdks/node/tests/webhooks.test.ts",
4763
+ "name": "rejects NaN tolerance"
4764
+ },
4765
+ {
4766
+ "file": "sdks/node/tests/webhooks.test.ts",
4767
+ "name": "rejects negative tolerance"
4768
+ },
4769
+ {
4770
+ "file": "sdks/node/tests/webhooks.test.ts",
4771
+ "name": "rejects tolerance above the 24h cap"
4772
+ },
4773
+ {
4774
+ "file": "sdks/node/tests/webhooks.test.ts",
4775
+ "name": "rejects non-number tolerance (null / string)"
4776
+ },
4777
+ {
4778
+ "file": "sdks/node/tests/webhooks.test.ts",
4779
+ "name": "accepts tolerance exactly at the 24h cap"
4780
+ },
4781
+ {
4782
+ "file": "sdks/node/tests/webhooks.test.ts",
4783
+ "name": "malformed header (no t= or no v1=) throws webhook_timestamp_missing"
4784
+ },
4785
+ {
4786
+ "file": "sdks/node/tests/webhooks.test.ts",
4787
+ "name": "valid signature but non-JSON payload throws webhook_payload_not_json"
4788
+ }
4789
+ ],
4790
+ "registeredAt": "2026-05-26",
4791
+ "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 7.2",
4792
+ "bundledIn": "@cross-deck/node@1.5.0"
3984
4793
  }
3985
- return out;
3986
- }
3987
- function scrubValue(v) {
3988
- if (typeof v === "string") return scrubPii(v);
3989
- if (Array.isArray(v)) return v.map(scrubValue);
3990
- if (v && typeof v === "object" && v.constructor === Object) {
3991
- return scrubPiiFromProperties(v);
4794
+ ]);
4795
+
4796
+ // src/contracts.ts
4797
+ var CrossdeckContracts = {
4798
+ all() {
4799
+ return BUNDLED_CONTRACTS.filter((c) => c.status === "enforced");
4800
+ },
4801
+ allIncludingHistorical() {
4802
+ return BUNDLED_CONTRACTS;
4803
+ },
4804
+ byId(id) {
4805
+ return BUNDLED_CONTRACTS.find((c) => c.id === id);
4806
+ },
4807
+ byPillar(pillar) {
4808
+ return BUNDLED_CONTRACTS.filter(
4809
+ (c) => c.pillar === pillar && c.status === "enforced"
4810
+ );
4811
+ },
4812
+ withStatus(status) {
4813
+ return BUNDLED_CONTRACTS.filter((c) => c.status === status);
4814
+ },
4815
+ sdkVersion: SDK_VERSION2,
4816
+ bundledIn: BUNDLED_IN,
4817
+ /**
4818
+ * Resolve a failing test back to the contract it exercises.
4819
+ * Used by test-framework hooks to find the contract id of a
4820
+ * failed contract test so `reportContractFailure(...)` can stamp
4821
+ * the right `contract_id` on the emitted event.
4822
+ */
4823
+ findByTestName(name) {
4824
+ return BUNDLED_CONTRACTS.find(
4825
+ (c) => c.testRef.some((ref) => ref.name === name)
4826
+ );
3992
4827
  }
3993
- return v;
3994
- }
4828
+ };
3995
4829
  // Annotate the CommonJS export names for ESM import in node:
3996
4830
  0 && (module.exports = {
3997
4831
  CROSSDECK_API_VERSION,
3998
4832
  CROSSDECK_ERROR_CODES,
3999
4833
  CrossdeckAuthenticationError,
4000
4834
  CrossdeckConfigurationError,
4835
+ CrossdeckContracts,
4001
4836
  CrossdeckError,
4002
4837
  CrossdeckInternalError,
4003
4838
  CrossdeckNetworkError,