@cross-deck/node 1.5.2 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -1,7 +1,9 @@
1
1
  "use strict";
2
+ var __create = Object.create;
2
3
  var __defProp = Object.defineProperty;
3
4
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
5
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
5
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
6
8
  var __export = (target, all) => {
7
9
  for (var name in all)
@@ -15,6 +17,14 @@ var __copyProps = (to, from, except, desc) => {
15
17
  }
16
18
  return to;
17
19
  };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
18
28
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
29
 
20
30
  // src/index.ts
@@ -24,6 +34,7 @@ __export(index_exports, {
24
34
  CROSSDECK_ERROR_CODES: () => CROSSDECK_ERROR_CODES,
25
35
  CrossdeckAuthenticationError: () => CrossdeckAuthenticationError,
26
36
  CrossdeckConfigurationError: () => CrossdeckConfigurationError,
37
+ CrossdeckContracts: () => CrossdeckContracts,
27
38
  CrossdeckError: () => CrossdeckError,
28
39
  CrossdeckInternalError: () => CrossdeckInternalError,
29
40
  CrossdeckNetworkError: () => CrossdeckNetworkError,
@@ -363,10 +374,85 @@ function byteLength(s) {
363
374
  return s.length * 4;
364
375
  }
365
376
 
377
+ // src/_diagnostic-telemetry.ts
378
+ var https = __toESM(require("https"));
379
+
366
380
  // src/_version.ts
367
- var SDK_VERSION = "1.3.1";
381
+ var SDK_VERSION = "1.6.0";
368
382
  var SDK_NAME = "@cross-deck/node";
369
383
 
384
+ // src/_diagnostic-telemetry.ts
385
+ var DIAGNOSTIC_TELEMETRY_ENDPOINT = "https://api.cross-deck.com/v1/sdk/diagnostic";
386
+ var DIAGNOSTIC_TELEMETRY_PUBLISHABLE_KEY = "cd_pub_live_9490e7aa029c432abf";
387
+ function isDiagnosticTelemetryEnabled() {
388
+ return !DIAGNOSTIC_TELEMETRY_PUBLISHABLE_KEY.startsWith(
389
+ "cd_pub_RELIABILITY_PLACEHOLDER"
390
+ );
391
+ }
392
+ var DIAGNOSTIC_TELEMETRY_ALLOWED_KEYS = /* @__PURE__ */ new Set([
393
+ "contract_id",
394
+ "sdk_version",
395
+ "sdk_platform",
396
+ "failure_reason",
397
+ "run_context",
398
+ "run_id",
399
+ "test_file",
400
+ "test_name",
401
+ "device_class"
402
+ ]);
403
+ function filterDiagnosticPayload(payload) {
404
+ const filtered = {};
405
+ for (const [k, v] of Object.entries(payload)) {
406
+ if (DIAGNOSTIC_TELEMETRY_ALLOWED_KEYS.has(k) && typeof v === "string") {
407
+ filtered[k] = v;
408
+ }
409
+ }
410
+ return filtered;
411
+ }
412
+ function sendDiagnosticTelemetry(payload) {
413
+ if (!isDiagnosticTelemetryEnabled()) return;
414
+ const filtered = filterDiagnosticPayload(payload);
415
+ if (Object.keys(filtered).length === 0) return;
416
+ const body = JSON.stringify(filtered);
417
+ let parsed;
418
+ try {
419
+ parsed = new URL(DIAGNOSTIC_TELEMETRY_ENDPOINT);
420
+ } catch {
421
+ return;
422
+ }
423
+ try {
424
+ const req = https.request({
425
+ method: "POST",
426
+ hostname: parsed.hostname,
427
+ port: parsed.port || 443,
428
+ path: parsed.pathname + parsed.search,
429
+ // Short timeout — reliability telemetry must never stall the
430
+ // host server. A failed POST is acceptable; a hung POST is not.
431
+ timeout: 5e3,
432
+ headers: {
433
+ "Content-Type": "application/json",
434
+ "Content-Length": Buffer.byteLength(body, "utf8").toString(),
435
+ Authorization: `Bearer ${DIAGNOSTIC_TELEMETRY_PUBLISHABLE_KEY}`,
436
+ "Crossdeck-Sdk-Version": `${SDK_NAME}@${SDK_VERSION}`
437
+ }
438
+ });
439
+ req.on("error", () => {
440
+ });
441
+ req.on("timeout", () => {
442
+ try {
443
+ req.destroy();
444
+ } catch {
445
+ }
446
+ });
447
+ req.on("response", (res) => {
448
+ res.resume();
449
+ });
450
+ req.write(body);
451
+ req.end();
452
+ } catch {
453
+ }
454
+ }
455
+
370
456
  // src/http.ts
371
457
  var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
372
458
  var DEFAULT_TIMEOUT_MS = 15e3;
@@ -876,6 +962,11 @@ var EventQueue = class {
876
962
  try {
877
963
  const env = this.cfg.envelope();
878
964
  const body = {
965
+ // Event Envelope v1 §1 — schema/wire version the server uses to
966
+ // decide whether it can parse this payload. Integer 1; only bumped
967
+ // on a breaking wire change. Distinct from sdk.version (which
968
+ // answers "which build?") — two questions, two fields.
969
+ envelopeVersion: 1,
879
970
  events: batch,
880
971
  sdk: env.sdk
881
972
  };
@@ -1860,6 +1951,25 @@ function runtimeInfoToProperties(info) {
1860
1951
  if (info.appVersion) out.appVersion = info.appVersion;
1861
1952
  return out;
1862
1953
  }
1954
+ function buildEventContext(info, sdkName, sdkVersion) {
1955
+ const ctx = {
1956
+ // Common fields (spec §4, all platforms)
1957
+ os: info.platform,
1958
+ osVersion: info.platformRelease,
1959
+ appVersion: info.appVersion ?? null,
1960
+ sdkName,
1961
+ sdkVersion,
1962
+ // locale / timezone: Node has no process-level locale by default;
1963
+ // surface them from the environment if available, otherwise null.
1964
+ locale: typeof process !== "undefined" && process.env["LANG"] || null,
1965
+ timezone: typeof Intl !== "undefined" && Intl.DateTimeFormat().resolvedOptions().timeZone || null,
1966
+ // Node-specific runtime context (spec §4 Node section)
1967
+ nodeVersion: info.nodeVersion,
1968
+ host: info.host,
1969
+ region: info.region ?? null
1970
+ };
1971
+ return ctx;
1972
+ }
1863
1973
 
1864
1974
  // src/flush-on-exit.ts
1865
1975
  var SIGNALS = ["SIGTERM", "SIGINT"];
@@ -2396,6 +2506,63 @@ var EntitlementCache = class {
2396
2506
  }
2397
2507
  };
2398
2508
 
2509
+ // src/consent.ts
2510
+ var EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
2511
+ var CARD_PATTERN = /\b\d(?:[ -]?\d){12,18}\b/g;
2512
+ var REPLACEMENT_EMAIL = "<email>";
2513
+ var REPLACEMENT_CARD = "<card>";
2514
+ function scrubPii(value) {
2515
+ if (!value) return value;
2516
+ return value.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL).replace(CARD_PATTERN, REPLACEMENT_CARD);
2517
+ }
2518
+ function scrubPiiFromProperties(properties) {
2519
+ const out = {};
2520
+ for (const k of Object.keys(properties)) {
2521
+ out[k] = scrubValue(properties[k]);
2522
+ }
2523
+ return out;
2524
+ }
2525
+ function scrubValue(v) {
2526
+ if (typeof v === "string") return scrubPii(v);
2527
+ if (Array.isArray(v)) return v.map(scrubValue);
2528
+ if (v && typeof v === "object" && v.constructor === Object) {
2529
+ return scrubPiiFromProperties(v);
2530
+ }
2531
+ return v;
2532
+ }
2533
+
2534
+ // src/idempotency-key.ts
2535
+ var import_node_crypto = require("crypto");
2536
+ function formatAsUuid(hex) {
2537
+ return [
2538
+ hex.slice(0, 8),
2539
+ hex.slice(8, 12),
2540
+ hex.slice(12, 16),
2541
+ hex.slice(16, 20),
2542
+ hex.slice(20, 32)
2543
+ ].join("-");
2544
+ }
2545
+ function sha256Hex(input) {
2546
+ return (0, import_node_crypto.createHash)("sha256").update(input, "utf8").digest("hex");
2547
+ }
2548
+ function deriveIdempotencyKeyForPurchase(body) {
2549
+ let identifier;
2550
+ if (body.rail === "apple") {
2551
+ identifier = body.signedTransactionInfo ?? "";
2552
+ } else if (body.rail === "google") {
2553
+ identifier = body.purchaseToken ?? "";
2554
+ } else {
2555
+ identifier = "";
2556
+ }
2557
+ if (!identifier) {
2558
+ throw new Error(
2559
+ `deriveIdempotencyKeyForPurchase: no stable identifier in body (rail=${body.rail}). Apple needs signedTransactionInfo; Google needs purchaseToken.`
2560
+ );
2561
+ }
2562
+ const namespaced = `crossdeck:purchases/sync:${body.rail}:${identifier}`;
2563
+ return formatAsUuid(sha256Hex(namespaced));
2564
+ }
2565
+
2399
2566
  // src/debug.ts
2400
2567
  var SENSITIVE_KEY_PATTERNS = [
2401
2568
  /^email$/i,
@@ -2455,6 +2622,10 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
2455
2622
  baseUrl;
2456
2623
  appId;
2457
2624
  env;
2625
+ /** PII scrubber toggle. Default true — parity with Web/RN/Swift.
2626
+ * Pre-v1.4.0 the Node SDK shipped track() payloads UNREDACTED,
2627
+ * a privacy contract drift versus the README. */
2628
+ scrubPii;
2458
2629
  secretKeyPrefix;
2459
2630
  /**
2460
2631
  * Process-stable pseudo-anonymous ID. Used as the default identity
@@ -2466,6 +2637,8 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
2466
2637
  processAnonymousId;
2467
2638
  runtime;
2468
2639
  runtimeProperties;
2640
+ /** Envelope v1 §4 context object — built once at SDK init, reused on every event. */
2641
+ eventContext;
2469
2642
  breadcrumbs;
2470
2643
  eventQueue;
2471
2644
  errorTracker;
@@ -2483,6 +2656,23 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
2483
2656
  */
2484
2657
  entitlementStore;
2485
2658
  debug;
2659
+ /**
2660
+ * Event Envelope v1 §3 — per-session monotonic sequence counter.
2661
+ *
2662
+ * Node has no mobile "session" lifecycle (no app launch / background /
2663
+ * foreground). We model a session as the SDK instance lifetime: the
2664
+ * counter starts at 0 on construction and increments once per
2665
+ * `track()` call. `session.started` is the boot-telemetry event
2666
+ * (the first `track()` called from `emitBootTelemetryEvent()`), so
2667
+ * the seq will be 0 for that event, matching the spec's "reset to 0
2668
+ * at session.started" clause — by construction, it's the zeroth event
2669
+ * of this instance's lifecycle.
2670
+ *
2671
+ * The counter persists for the entire process lifetime of the SDK
2672
+ * instance (spec §3 clause 1: background/foreground does not reset).
2673
+ * A new `CrossdeckServer` construction is the only reset (new session).
2674
+ */
2675
+ sessionSeq = 0;
2486
2676
  /**
2487
2677
  * Alias map — `developerUserId` / `anonymousId` → canonical
2488
2678
  * `crossdeckCustomerId`. Populated by `getEntitlements()` so a
@@ -2500,6 +2690,15 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
2500
2690
  errorContext = {};
2501
2691
  errorTags = {};
2502
2692
  errorBeforeSend = null;
2693
+ /**
2694
+ * Dedup gate for `sdk.shutdown`. Both `shutdown()` (async) and
2695
+ * `shutdownSync()` need to emit so direct callers of EITHER see
2696
+ * the event (the async path's listener guarantees pre-launch
2697
+ * tests, the sync path covers `Symbol.dispose` + tests that call
2698
+ * `shutdownSync()` directly). Without this flag, `shutdown()`'s
2699
+ * tail call into `shutdownSync()` would emit twice.
2700
+ */
2701
+ didEmitShutdown = false;
2503
2702
  constructor(options) {
2504
2703
  super();
2505
2704
  if (!options.secretKey || !options.secretKey.startsWith("cd_sk_")) {
@@ -2514,6 +2713,7 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
2514
2713
  this.baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
2515
2714
  this.env = inferEnvFromKey(options.secretKey);
2516
2715
  this.secretKeyPrefix = maskSecretKey(options.secretKey);
2716
+ this.scrubPii = options.scrubPii !== false;
2517
2717
  this.http = new HttpClient({
2518
2718
  secretKey: options.secretKey,
2519
2719
  baseUrl: this.baseUrl,
@@ -2532,6 +2732,7 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
2532
2732
  appVersion: options.appVersion
2533
2733
  });
2534
2734
  this.runtimeProperties = runtimeInfoToProperties(this.runtime);
2735
+ this.eventContext = buildEventContext(this.runtime, SDK_NAME, this.sdkVersion);
2535
2736
  this.breadcrumbs = new BreadcrumbBuffer(options.breadcrumbsMaxSize ?? 50);
2536
2737
  this.superProps = new SuperPropertyStore();
2537
2738
  this.entitlementCache = new EntitlementCache({
@@ -2553,7 +2754,9 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
2553
2754
  this.eventQueue = new EventQueue({
2554
2755
  http: this.http,
2555
2756
  batchSize: options.eventFlushBatchSize ?? 20,
2556
- intervalMs: options.eventFlushIntervalMs ?? 1500,
2757
+ // v1.4.0 Phase 3.3 — flush interval default parity at 2000ms
2758
+ // across every SDK. Per-instance override stays.
2759
+ intervalMs: options.eventFlushIntervalMs ?? 2e3,
2557
2760
  envelope: () => ({
2558
2761
  appId: this.appId,
2559
2762
  // Ship env on every batch so the backend can cross-check
@@ -2932,6 +3135,34 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
2932
3135
  * `uncaughtException` has no per-request context; without the
2933
3136
  * auto-fill, the event would be rejected at queue enqueue.
2934
3137
  */
3138
+ /**
3139
+ * Emit `crossdeck.contract_failed` to the Crossdeck reliability
3140
+ * endpoint — single-fire, one-way, never visible in the customer's
3141
+ * dashboard. Goes over a dedicated HTTP path with the reliability
3142
+ * publishable key embedded at build time; the customer's track()
3143
+ * pipeline never carries `crossdeck.*` events. This is the
3144
+ * independent-controller flow described in Privacy Policy §6
3145
+ * ("Flow B"). The wire shape is fixed by the schema-lock contract
3146
+ * at `contracts/diagnostics/contract-failed-payload-schema-lock.json`.
3147
+ */
3148
+ reportContractFailure(input) {
3149
+ const payload = {
3150
+ contract_id: input.contractId,
3151
+ sdk_version: SDK_VERSION,
3152
+ sdk_platform: "node",
3153
+ failure_reason: input.failureReason,
3154
+ run_context: input.runContext,
3155
+ run_id: input.runId
3156
+ };
3157
+ if (input.testRef) {
3158
+ payload.test_file = input.testRef.file;
3159
+ payload.test_name = input.testRef.name;
3160
+ }
3161
+ if (input.deviceClass) {
3162
+ payload.device_class = input.deviceClass;
3163
+ }
3164
+ sendDiagnosticTelemetry(payload);
3165
+ }
2935
3166
  track(event) {
2936
3167
  if (!event.name) {
2937
3168
  throw new CrossdeckError({
@@ -2940,7 +3171,8 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
2940
3171
  message: "track(event) requires a non-empty event.name."
2941
3172
  });
2942
3173
  }
2943
- const sanitized = sanitizePropertyBag(event.properties, "event properties") ?? {};
3174
+ const validated = sanitizePropertyBag(event.properties, "event properties") ?? {};
3175
+ const sanitized = this.scrubPii ? scrubPiiFromProperties(validated) : validated;
2944
3176
  if (this.debug.enabled) {
2945
3177
  const flagged = findSensitivePropertyKeys(sanitized);
2946
3178
  if (flagged.length > 0) {
@@ -2952,7 +3184,6 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
2952
3184
  }
2953
3185
  }
2954
3186
  const properties = {
2955
- ...this.runtimeProperties,
2956
3187
  ...this.superProps.getSuperProperties(),
2957
3188
  ...sanitized
2958
3189
  };
@@ -2961,10 +3192,14 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
2961
3192
  properties.$groups = groupIds;
2962
3193
  }
2963
3194
  const identity = this.resolveIdentity(event);
3195
+ const seq = this.sessionSeq++;
3196
+ const timestamp = event.timestamp ?? Date.now();
2964
3197
  const queued = {
2965
3198
  eventId: event.eventId ?? mintId("evt", 8),
2966
3199
  name: event.name,
2967
- timestamp: event.timestamp ?? Date.now(),
3200
+ timestamp,
3201
+ seq,
3202
+ context: this.eventContext,
2968
3203
  properties,
2969
3204
  ...identity
2970
3205
  };
@@ -3003,6 +3238,8 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
3003
3238
  }
3004
3239
  const normalized = events.map((event) => this.normalizeIngestEvent(event));
3005
3240
  const body = {
3241
+ // Event Envelope v1 §1 — wire version (parity with the queue path).
3242
+ envelopeVersion: 1,
3006
3243
  events: normalized,
3007
3244
  sdk: { name: SDK_NAME, version: this.sdkVersion },
3008
3245
  // Match the queue's batch envelope (see event-queue.ts) — backend
@@ -3076,11 +3313,25 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
3076
3313
  });
3077
3314
  }
3078
3315
  const rail = input.rail ?? "apple";
3079
- return this.http.request("POST", "/purchases/sync", {
3080
- body: { ...input, rail },
3316
+ const body = { ...input, rail };
3317
+ const idempotencyKey = options?.idempotencyKey ?? deriveIdempotencyKeyForPurchase(body);
3318
+ const result = await this.http.request("POST", "/purchases/sync", {
3319
+ body,
3320
+ idempotencyKey,
3081
3321
  signal: options?.signal,
3082
3322
  timeoutMs: options?.timeoutMs
3083
3323
  });
3324
+ try {
3325
+ const sourceProductId = result.entitlements[0]?.source.productId;
3326
+ const sourceSubscriptionId = result.entitlements[0]?.source.subscriptionId;
3327
+ const props = { rail };
3328
+ if (sourceProductId) props.productId = sourceProductId;
3329
+ if (sourceSubscriptionId) props.subscriptionId = sourceSubscriptionId;
3330
+ if (result.idempotent_replay) props.idempotent_replay = true;
3331
+ this.track({ name: "purchase.completed", properties: props });
3332
+ } catch {
3333
+ }
3334
+ return result;
3084
3335
  }
3085
3336
  // ============================================================
3086
3337
  // Manual entitlement controls + audit — direct HTTP
@@ -3372,12 +3623,56 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
3372
3623
  };
3373
3624
  }
3374
3625
  /**
3375
- * Tear down handlers and clear in-memory state. Tests + custom
3376
- * lifecycle callers only. Production code should rely on
3377
- * `flush-on-exit` instead.
3626
+ * Tear down handlers and clear in-memory state.
3627
+ *
3628
+ * **v1.4.0 bank-grade contract:** `shutdown()` AWAITS `flush()`
3629
+ * before dropping the queue, so callers don't silently lose
3630
+ * every queued event on a clean shutdown. The pre-v1.4.0
3631
+ * behaviour (sync `eventQueue.reset()` with no flush) was the
3632
+ * default for both `shutdown()` and `[Symbol.dispose]`; only
3633
+ * `await using` + `[Symbol.asyncDispose]` flushed correctly.
3634
+ *
3635
+ * Production servers should still prefer `await server.flush()`
3636
+ * (visible) followed by `server.shutdown()` so the flush
3637
+ * outcome is observable — `shutdown()`'s internal flush swallows
3638
+ * errors as a best-effort drain.
3639
+ *
3640
+ * Use [[shutdownSync]] only when the runtime cannot await
3641
+ * (e.g. inside `Symbol.dispose` — see below).
3642
+ */
3643
+ async shutdown(reason = "shutdown") {
3644
+ if (!this.didEmitShutdown) {
3645
+ this.emit("sdk.shutdown", { reason });
3646
+ this.didEmitShutdown = true;
3647
+ }
3648
+ try {
3649
+ await this.flush();
3650
+ } catch {
3651
+ }
3652
+ this.shutdownSync(reason);
3653
+ }
3654
+ /**
3655
+ * Synchronous teardown — drops the in-memory queue WITHOUT
3656
+ * flushing, then clears all in-memory state. Used by
3657
+ * `[Symbol.dispose]` (which has no await) and tests that need
3658
+ * an unconditional sync wipe. Production code should use
3659
+ * [[shutdown]] (async) instead so queued events are flushed.
3660
+ *
3661
+ * A queue with items at sync-shutdown logs a warning recommending
3662
+ * `[Symbol.asyncDispose]` or `await server.shutdown()` — silent
3663
+ * loss is incompatible with the bank-grade contract.
3378
3664
  */
3379
- shutdown(reason = "shutdown") {
3380
- this.emit("sdk.shutdown", { reason });
3665
+ shutdownSync(reason = "shutdown") {
3666
+ if (!this.didEmitShutdown) {
3667
+ this.emit("sdk.shutdown", { reason });
3668
+ this.didEmitShutdown = true;
3669
+ }
3670
+ const queuedCount = this.eventQueue.getStats().buffered;
3671
+ if (queuedCount > 0 && reason !== "asyncDispose") {
3672
+ console.warn(
3673
+ `[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.`
3674
+ );
3675
+ }
3381
3676
  this.errorTracker?.uninstall();
3382
3677
  this.flushOnExit?.uninstall();
3383
3678
  this.eventQueue.reset();
@@ -3491,28 +3786,28 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
3491
3786
  * // ... use server ...
3492
3787
  * // at end of block, server[Symbol.dispose]() runs automatically
3493
3788
  *
3494
- * `Symbol.dispose` is synchronous so we can't await `flush()` here
3495
- * for that, use `await using` + `[Symbol.asyncDispose]()`. This
3496
- * sync variant just calls `shutdown()` (handler cleanup +
3497
- * in-memory state wipe).
3789
+ * **`Symbol.dispose` is synchronous so it CANNOT await the queue
3790
+ * flush.** A queue with pending events at sync-dispose time will
3791
+ * be DROPPED `shutdownSync` warns to the console when this
3792
+ * happens. For the common case of "drain the queue before
3793
+ * exit", switch to `await using` + `[Symbol.asyncDispose]` (or
3794
+ * call `await server.shutdown()` explicitly before the variable
3795
+ * goes out of scope).
3498
3796
  */
3499
3797
  [Symbol.dispose]() {
3500
- this.shutdown("dispose");
3798
+ this.shutdownSync("dispose");
3501
3799
  }
3502
3800
  /**
3503
3801
  * Async disposal hook — runs when an `await using` declaration
3504
- * exits scope. Awaits `flush()` THEN runs `shutdown()`. Use this
3505
- * variant when the caller needs the queue drained before exit
3506
- * (the common case for serverless handlers).
3802
+ * exits scope. Awaits the bank-grade `shutdown()` which flushes
3803
+ * the queue THEN tears down. Use this variant for any code path
3804
+ * that owns queued events at exit (serverless handlers,
3805
+ * background workers, end-of-request hooks).
3507
3806
  *
3508
3807
  * await using server = new CrossdeckServer({ ... });
3509
3808
  */
3510
3809
  async [Symbol.asyncDispose]() {
3511
- try {
3512
- await this.flush();
3513
- } catch {
3514
- }
3515
- this.shutdown("asyncDispose");
3810
+ await this.shutdown("asyncDispose");
3516
3811
  }
3517
3812
  // ============================================================
3518
3813
  reportCapturedError(captured) {
@@ -3932,18 +4227,43 @@ var _CROSSDECK_ERROR_CODES = Object.freeze([
3932
4227
  retryable: false
3933
4228
  },
3934
4229
  // ----- Webhook verification (Node-specific) -----
4230
+ // v1.4.0 Phase 7.2 — distinguishable codes. Pre-v1.4.0 the
4231
+ // helper used webhook_invalid_signature for nearly every failure
4232
+ // mode so a customer couldn't separate replay-attack signals
4233
+ // from wrong-secret signals in alerting.
3935
4234
  {
3936
- code: "webhook_invalid_signature",
4235
+ code: "webhook_signature_mismatch",
3937
4236
  type: "authentication_error",
3938
- description: "The webhook signature header did not verify against the supplied secret.",
3939
- 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.",
4237
+ description: "Webhook HMAC didn't verify against any configured secret (wrong-secret / stale rotation signal).",
4238
+ 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.",
3940
4239
  retryable: false
3941
4240
  },
3942
4241
  {
3943
- code: "webhook_replay_window_exceeded",
4242
+ code: "webhook_timestamp_outside_tolerance",
3944
4243
  type: "authentication_error",
3945
- description: "The webhook timestamp is older than the replay-tolerance window (default 5 minutes).",
3946
- 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.",
4244
+ description: "Webhook timestamp drift exceeds the configured replay-tolerance window (default 5 minutes; replay-attack signal).",
4245
+ 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.",
4246
+ retryable: false
4247
+ },
4248
+ {
4249
+ code: "webhook_timestamp_missing",
4250
+ type: "authentication_error",
4251
+ description: "Webhook signature header is absent or has no `t=` timestamp segment \u2014 the timestamp gate cannot be verified.",
4252
+ 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.",
4253
+ retryable: false
4254
+ },
4255
+ {
4256
+ code: "webhook_payload_not_json",
4257
+ type: "authentication_error",
4258
+ description: "Webhook signature verified but the body isn't valid JSON \u2014 payload tampered post-signing or source bug.",
4259
+ 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.",
4260
+ retryable: false
4261
+ },
4262
+ {
4263
+ code: "webhook_invalid_tolerance",
4264
+ type: "configuration_error",
4265
+ description: "verifyWebhookSignature() called with a non-finite / negative / above-24h-cap replayToleranceMs (would silently disable replay protection).",
4266
+ 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.",
3947
4267
  retryable: false
3948
4268
  },
3949
4269
  {
@@ -3952,6 +4272,101 @@ var _CROSSDECK_ERROR_CODES = Object.freeze([
3952
4272
  description: "verifyWebhookSignature() was called without a signing secret.",
3953
4273
  resolution: "Pass the secret from your Crossdeck dashboard \u2192 Webhooks page. Never hardcode in source \u2014 read from an env var.",
3954
4274
  retryable: false
4275
+ },
4276
+ {
4277
+ code: "webhook_invalid_signature",
4278
+ type: "authentication_error",
4279
+ 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.",
4280
+ resolution: "Migrate alert rules to the more specific v1.4.0 codes \u2014 they distinguish replay-attack signals from wrong-secret signals.",
4281
+ retryable: false
4282
+ },
4283
+ {
4284
+ code: "webhook_replay_window_exceeded",
4285
+ type: "authentication_error",
4286
+ description: "DEPRECATED in v1.4.0 \u2014 renamed to webhook_timestamp_outside_tolerance.",
4287
+ resolution: "Update alerts to webhook_timestamp_outside_tolerance.",
4288
+ retryable: false
4289
+ },
4290
+ // ----- Backend-emitted codes (v1.4.0 Phase 6.2 backfill) -----
4291
+ // Mirror of backend/src/api/v1-errors.ts ApiErrorCode. Same set
4292
+ // as the Web SDK ships — keep these synchronised so a developer
4293
+ // hitting any code via either SDK gets the same remediation.
4294
+ {
4295
+ code: "missing_api_key",
4296
+ type: "authentication_error",
4297
+ description: "No Authorization header (or Crossdeck-Api-Key header) on the request.",
4298
+ resolution: "Confirm the CrossdeckServer was constructed with a cd_sk_\u2026 secretKey. Re-check env vars in production deployments.",
4299
+ retryable: false
4300
+ },
4301
+ {
4302
+ code: "invalid_api_key",
4303
+ type: "authentication_error",
4304
+ description: "The secret key is malformed, unknown, or doesn't resolve to a project.",
4305
+ 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.",
4306
+ retryable: false
4307
+ },
4308
+ {
4309
+ code: "key_revoked",
4310
+ type: "authentication_error",
4311
+ description: "The secret key was revoked in the dashboard.",
4312
+ resolution: "Mint a fresh key in dashboard \u2192 API keys \u2192 Create new. The revoked key cannot be reactivated.",
4313
+ retryable: false
4314
+ },
4315
+ {
4316
+ code: "env_mismatch",
4317
+ type: "permission_error",
4318
+ description: "The key's env prefix doesn't match the resolved app's configured env.",
4319
+ resolution: "Use a cd_sk_live_ key with a production app, cd_sk_test_ with a sandbox app. Crossing breaks the env lock.",
4320
+ retryable: false
4321
+ },
4322
+ {
4323
+ code: "idempotency_key_in_use",
4324
+ type: "invalid_request_error",
4325
+ description: "An Idempotency-Key was reused for a request with a different body (Stripe-grade contract).",
4326
+ 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.",
4327
+ retryable: false
4328
+ },
4329
+ {
4330
+ code: "rate_limited",
4331
+ type: "rate_limit_error",
4332
+ description: "Request rate exceeded the project's per-second cap.",
4333
+ resolution: "Honour Retry-After (managed retries do this automatically). For custom paths, throttle to <100 req/s/key.",
4334
+ retryable: true
4335
+ },
4336
+ {
4337
+ code: "internal_error",
4338
+ type: "internal_error",
4339
+ description: "Server-side issue. Safe to retry with backoff.",
4340
+ resolution: "Managed retries handle this automatically. If a code path surfaces it to your code, contact support with the requestId.",
4341
+ retryable: true
4342
+ },
4343
+ {
4344
+ code: "google_not_supported",
4345
+ type: "invalid_request_error",
4346
+ description: "POST /purchases/sync with rail=google is gated until the Play Developer API reconciliation worker ships.",
4347
+ resolution: "Until v1.5+, Google Play purchases verify via Real-time Developer Notifications. The Android SDK auto-track path handles this transparently.",
4348
+ retryable: false
4349
+ },
4350
+ {
4351
+ code: "stripe_not_supported",
4352
+ type: "invalid_request_error",
4353
+ description: "POST /purchases/sync with rail=stripe is unsupported \u2014 Stripe webhooks deliver evidence server-side.",
4354
+ resolution: "Wire Stripe via the standard Checkout / Customer Portal flow; Crossdeck reconciles via the platform webhook automatically.",
4355
+ retryable: false
4356
+ },
4357
+ {
4358
+ code: "missing_required_param",
4359
+ type: "invalid_request_error",
4360
+ description: "A required field is absent from the request body.",
4361
+ resolution: "The error.message identifies the missing field. Refer to the SDK's TypeScript types for canonical shapes.",
4362
+ retryable: false
4363
+ },
4364
+ {
4365
+ code: "invalid_param_value",
4366
+ type: "invalid_request_error",
4367
+ description: "A field is present but the value failed validation.",
4368
+ resolution: "Read error.message for the field + reason. SDK-managed call sites should never emit this \u2014 file a bug if you do.",
4369
+ retryable: false
3955
4370
  }
3956
4371
  ]);
3957
4372
  function isCrossdeckErrorCode(code) {
@@ -3963,8 +4378,9 @@ function getErrorCode(code) {
3963
4378
  }
3964
4379
 
3965
4380
  // src/webhooks.ts
3966
- var import_node_crypto = require("crypto");
4381
+ var import_node_crypto2 = require("crypto");
3967
4382
  var DEFAULT_REPLAY_TOLERANCE_MS = 5 * 60 * 1e3;
4383
+ var MAX_REPLAY_TOLERANCE_MS = 24 * 60 * 60 * 1e3;
3968
4384
  function verifyWebhookSignature(payload, signatureHeader, secret, options = {}) {
3969
4385
  const secrets = normaliseSecrets(secret);
3970
4386
  if (secrets.length === 0) {
@@ -3974,46 +4390,68 @@ function verifyWebhookSignature(payload, signatureHeader, secret, options = {})
3974
4390
  message: "verifyWebhookSignature requires a non-empty secret. Read it from process.env.CROSSDECK_WEBHOOK_SECRET \u2014 never hardcode in source."
3975
4391
  });
3976
4392
  }
4393
+ const requestedTolerance = options.replayToleranceMs;
4394
+ let tolerance;
4395
+ if (requestedTolerance === void 0) {
4396
+ tolerance = DEFAULT_REPLAY_TOLERANCE_MS;
4397
+ } else if (typeof requestedTolerance !== "number" || !Number.isFinite(requestedTolerance)) {
4398
+ throw new CrossdeckError({
4399
+ type: "configuration_error",
4400
+ code: "webhook_invalid_tolerance",
4401
+ 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.`
4402
+ });
4403
+ } else if (requestedTolerance < 0) {
4404
+ throw new CrossdeckError({
4405
+ type: "configuration_error",
4406
+ code: "webhook_invalid_tolerance",
4407
+ message: `replayToleranceMs must be \u2265 0. Got ${requestedTolerance}.`
4408
+ });
4409
+ } else if (requestedTolerance > MAX_REPLAY_TOLERANCE_MS) {
4410
+ throw new CrossdeckError({
4411
+ type: "configuration_error",
4412
+ code: "webhook_invalid_tolerance",
4413
+ message: `replayToleranceMs must not exceed ${MAX_REPLAY_TOLERANCE_MS}ms (24h). Got ${requestedTolerance}ms \u2014 a window that wide defeats replay protection.`
4414
+ });
4415
+ } else {
4416
+ tolerance = requestedTolerance;
4417
+ }
3977
4418
  const header = normaliseHeader(signatureHeader);
3978
4419
  const parsed = parseSignatureHeader(header);
3979
4420
  if (!parsed) {
3980
4421
  throw new CrossdeckError({
3981
4422
  type: "authentication_error",
3982
- code: "webhook_invalid_signature",
3983
- message: "Webhook signature header is missing or malformed. Expected 'Crossdeck-Signature: t=<unix>,v1=<hex>'."
4423
+ code: "webhook_timestamp_missing",
4424
+ message: "Webhook signature header is missing, malformed, or has no `t=` timestamp segment. Expected 'Crossdeck-Signature: t=<unix>,v1=<hex>'."
3984
4425
  });
3985
4426
  }
3986
- const tolerance = options.replayToleranceMs ?? DEFAULT_REPLAY_TOLERANCE_MS;
3987
- if (tolerance > 0) {
3988
- const now = (options.now ?? Date.now)();
3989
- const timestampMs = parsed.timestampSec * 1e3;
3990
- const drift = Math.abs(now - timestampMs);
3991
- if (drift > tolerance) {
3992
- throw new CrossdeckError({
3993
- type: "authentication_error",
3994
- code: "webhook_replay_window_exceeded",
3995
- 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.`
3996
- });
3997
- }
4427
+ const now = (options.now ?? Date.now)();
4428
+ const timestampMs = parsed.timestampSec * 1e3;
4429
+ const drift = Math.abs(now - timestampMs);
4430
+ if (drift > tolerance) {
4431
+ throw new CrossdeckError({
4432
+ type: "authentication_error",
4433
+ code: "webhook_timestamp_outside_tolerance",
4434
+ 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.`
4435
+ });
3998
4436
  }
3999
4437
  const signedPayload = `${parsed.timestampSec}.${payload}`;
4000
4438
  const expectedBuf = Buffer.from(parsed.signature, "hex");
4001
4439
  if (expectedBuf.length === 0) {
4002
4440
  throw new CrossdeckError({
4003
4441
  type: "authentication_error",
4004
- code: "webhook_invalid_signature",
4442
+ code: "webhook_signature_mismatch",
4005
4443
  message: "Webhook signature is not a valid hex string."
4006
4444
  });
4007
4445
  }
4008
4446
  const anyMatch = secrets.some((s) => {
4009
- const computed = (0, import_node_crypto.createHmac)("sha256", s).update(signedPayload).digest();
4010
- return computed.length === expectedBuf.length && (0, import_node_crypto.timingSafeEqual)(computed, expectedBuf);
4447
+ const computed = (0, import_node_crypto2.createHmac)("sha256", s).update(signedPayload).digest();
4448
+ return computed.length === expectedBuf.length && (0, import_node_crypto2.timingSafeEqual)(computed, expectedBuf);
4011
4449
  });
4012
4450
  if (!anyMatch) {
4013
4451
  throw new CrossdeckError({
4014
4452
  type: "authentication_error",
4015
- code: "webhook_invalid_signature",
4016
- 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)."
4453
+ code: "webhook_signature_mismatch",
4454
+ 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)."
4017
4455
  });
4018
4456
  }
4019
4457
  try {
@@ -4021,13 +4459,13 @@ function verifyWebhookSignature(payload, signatureHeader, secret, options = {})
4021
4459
  } catch {
4022
4460
  throw new CrossdeckError({
4023
4461
  type: "authentication_error",
4024
- code: "webhook_invalid_signature",
4462
+ code: "webhook_payload_not_json",
4025
4463
  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."
4026
4464
  });
4027
4465
  }
4028
4466
  }
4029
4467
  function signWebhookPayload(payload, secret, timestampSec) {
4030
- return (0, import_node_crypto.createHmac)("sha256", secret).update(`${timestampSec}.${payload}`).digest("hex");
4468
+ return (0, import_node_crypto2.createHmac)("sha256", secret).update(`${timestampSec}.${payload}`).digest("hex");
4031
4469
  }
4032
4470
  function parseSignatureHeader(header) {
4033
4471
  if (!header) return null;
@@ -4059,36 +4497,599 @@ function normaliseSecrets(input) {
4059
4497
  return arr.filter((s) => typeof s === "string" && s.length > 0);
4060
4498
  }
4061
4499
 
4062
- // src/consent.ts
4063
- var EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
4064
- var CARD_PATTERN = /\b\d(?:[ -]?\d){12,18}\b/g;
4065
- var REPLACEMENT_EMAIL = "<email>";
4066
- var REPLACEMENT_CARD = "<card>";
4067
- function scrubPii(value) {
4068
- if (!value) return value;
4069
- return value.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL).replace(CARD_PATTERN, REPLACEMENT_CARD);
4070
- }
4071
- function scrubPiiFromProperties(properties) {
4072
- const out = {};
4073
- for (const k of Object.keys(properties)) {
4074
- out[k] = scrubValue(properties[k]);
4500
+ // src/_contracts-bundled.ts
4501
+ var BUNDLED_IN = "@cross-deck/node@1.6.0";
4502
+ var SDK_VERSION2 = "1.6.0";
4503
+ var BUNDLED_CONTRACTS = Object.freeze([
4504
+ {
4505
+ "id": "contract-failed-payload-schema-lock",
4506
+ "pillar": "diagnostics",
4507
+ "status": "enforced",
4508
+ "claim": "The `crossdeck.contract_failed` event payload contains ONLY the named diagnostic fields and never any end-user personal data. The wire shape is fixed \u2014 adding a new field requires (1) a pull request that updates this contract's `allowedFields` set, (2) a Privacy Policy \xA76 amendment, and (3) the Customer Disclosure Template / SDK Data Collection Reference \xA7B updates. Per-SDK assertion tests enforce the field set on every release. The `verification_phase` field is a categorical bucket \u2014 values are restricted to `boot` (the SDK self-test ran on Crossdeck.start) or `hot_path` (a verifier observed a real customer-triggered operation). The categorical nature is what preserves the diagnostic-only-not-personal classification. This is the structural guarantee that backs the independent-controller lawful basis in the Privacy Policy: the payload remains diagnostic-only, not personal, so the legitimate-interest analysis stays valid as the SDK evolves.",
4509
+ "appliesTo": [
4510
+ "web",
4511
+ "node",
4512
+ "swift",
4513
+ "android",
4514
+ "react-native"
4515
+ ],
4516
+ "allowedFields": {
4517
+ "required": [
4518
+ "contract_id",
4519
+ "sdk_version",
4520
+ "sdk_platform",
4521
+ "failure_reason",
4522
+ "run_context",
4523
+ "run_id"
4524
+ ],
4525
+ "optional": [
4526
+ "test_file",
4527
+ "test_name",
4528
+ "device_class",
4529
+ "verification_phase"
4530
+ ],
4531
+ "forbidden": [
4532
+ "anonymousId",
4533
+ "developerUserId",
4534
+ "crossdeckCustomerId",
4535
+ "email",
4536
+ "ip",
4537
+ "user_agent",
4538
+ "message",
4539
+ "stack",
4540
+ "stack_trace",
4541
+ "frames",
4542
+ "exception_message",
4543
+ "url",
4544
+ "path",
4545
+ "screen",
4546
+ "title",
4547
+ "label",
4548
+ "text",
4549
+ "ariaLabel",
4550
+ "accessibilityLabel",
4551
+ "contentDescription",
4552
+ "session_id",
4553
+ "sessionId"
4554
+ ]
4555
+ },
4556
+ "transport": "Telemetry is single-fire to the Crossdeck reliability endpoint only \u2014 NOT the customer's appId. The customer's track() pipeline never carries `crossdeck.*` events; the customer's dashboard never shows individual contract failures. Operational telemetry flows one-way to the Crossdeck operations team for SDK reliability purposes (legitimate interest, independent-controller flow per Privacy Policy \xA76). The reliability endpoint is hardcoded at SDK build time; the publishable key for the reliability project is embedded as a constant and rejects writes that don't match the schema.",
4557
+ "codeRef": [
4558
+ "sdks/web/src/crossdeck.ts",
4559
+ "sdks/node/src/crossdeck-server.ts",
4560
+ "sdks/swift/Sources/Crossdeck/Crossdeck.swift",
4561
+ "sdks/swift/Sources/Crossdeck/_DiagnosticTelemetry.swift",
4562
+ "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/Crossdeck.kt",
4563
+ "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/_DiagnosticTelemetry.kt",
4564
+ "sdks/react-native/src/crossdeck.ts",
4565
+ "backend/src/api/v1-sdk-diagnostic.ts",
4566
+ "sdks/web/src/_diagnostic-telemetry.ts",
4567
+ "sdks/node/src/_diagnostic-telemetry.ts",
4568
+ "sdks/react-native/src/_diagnostic-telemetry.ts"
4569
+ ],
4570
+ "testRef": [
4571
+ {
4572
+ "file": "sdks/web/tests/contract-failed-schema-lock.test.ts",
4573
+ "name": "reportContractFailure payload conforms to schema-lock"
4574
+ },
4575
+ {
4576
+ "file": "sdks/node/tests/contract-failed-schema-lock.test.ts",
4577
+ "name": "reportContractFailure payload conforms to schema-lock"
4578
+ },
4579
+ {
4580
+ "file": "sdks/swift/Tests/CrossdeckTests/ContractFailedSchemaLockTests.swift",
4581
+ "name": "test_reportContractFailure_payloadFieldsAreInAllowList"
4582
+ },
4583
+ {
4584
+ "file": "sdks/swift/Tests/CrossdeckTests/ContractFailedSchemaLockTests.swift",
4585
+ "name": "test_reportContractFailure_doesNotEnterCustomerTrackPipeline"
4586
+ },
4587
+ {
4588
+ "file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/ContractFailedSchemaLockTest.kt",
4589
+ "name": "reportContractFailure payload conforms to schema-lock"
4590
+ },
4591
+ {
4592
+ "file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/ContractFailedSchemaLockTest.kt",
4593
+ "name": "reportContractFailure does not enter customer track pipeline"
4594
+ },
4595
+ {
4596
+ "file": "sdks/react-native/tests/contract-failed-schema-lock.test.ts",
4597
+ "name": "reportContractFailure payload conforms to schema-lock"
4598
+ },
4599
+ {
4600
+ "file": "backend/tests/unit/v1-sdk-diagnostic.test.ts",
4601
+ "name": "forbidden fields are enumerated in the schema-lock contract"
4602
+ },
4603
+ {
4604
+ "file": "backend/tests/unit/v1-sdk-diagnostic.test.ts",
4605
+ "name": "required fields are enumerated in the schema-lock contract"
4606
+ },
4607
+ {
4608
+ "file": "backend/tests/unit/v1-sdk-diagnostic.test.ts",
4609
+ "name": "regression guard: never returns a raw IP"
4610
+ },
4611
+ {
4612
+ "file": "backend/tests/unit/v1-sdk-diagnostic.test.ts",
4613
+ "name": "verification_phase is in the optional field set"
4614
+ }
4615
+ ],
4616
+ "registeredAt": "2026-05-27",
4617
+ "firstRegisteredIn": "Diagnostic telemetry single-fire + schema-lock \u2014 independent-controller flow",
4618
+ "privacyReferences": [
4619
+ "legal/privacy/index.html#sdk-diagnostic",
4620
+ "legal/customer-disclosure/index.html#flow-b",
4621
+ "legal/security/index.html#diagnostic",
4622
+ "legal/sdk-data/index.html#b-diagnostic"
4623
+ ],
4624
+ "bundledIn": "@cross-deck/node@1.6.0"
4625
+ },
4626
+ {
4627
+ "id": "documentation-honesty",
4628
+ "pillar": "webhooks",
4629
+ "status": "enforced",
4630
+ "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.",
4631
+ "appliesTo": [
4632
+ "node",
4633
+ "backend"
4634
+ ],
4635
+ "codeRef": [
4636
+ "sdks/node/src/webhooks.ts",
4637
+ "docs/rail-webhooks/index.html",
4638
+ "docs/webhooks-receive/index.html"
4639
+ ],
4640
+ "testRef": [
4641
+ {
4642
+ "file": "sdks/node/src/webhooks.ts",
4643
+ "name": "[ROADMAP \u2014 v1.4.0 honesty note]"
4644
+ },
4645
+ {
4646
+ "file": "docs/rail-webhooks/index.html",
4647
+ "name": "Outbound push-to-your-backend webhooks are <strong>roadmap</strong>"
4648
+ },
4649
+ {
4650
+ "file": "docs/webhooks-receive/index.html",
4651
+ "name": "This feature is on the roadmap"
4652
+ }
4653
+ ],
4654
+ "registeredAt": "2026-05-26",
4655
+ "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 7.1",
4656
+ "bundledIn": "@cross-deck/node@1.6.0"
4657
+ },
4658
+ {
4659
+ "id": "error-envelope-shape",
4660
+ "pillar": "errors",
4661
+ "status": "enforced",
4662
+ "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.",
4663
+ "appliesTo": [
4664
+ "web",
4665
+ "node",
4666
+ "react-native",
4667
+ "swift",
4668
+ "android",
4669
+ "backend"
4670
+ ],
4671
+ "codeRef": [
4672
+ "backend/src/api/v1-errors.ts",
4673
+ "sdks/web/src/errors.ts",
4674
+ "sdks/node/src/errors.ts",
4675
+ "sdks/react-native/src/errors.ts",
4676
+ "sdks/swift/Sources/Crossdeck/Errors.swift",
4677
+ "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/Errors.kt"
4678
+ ],
4679
+ "testRef": [
4680
+ {
4681
+ "file": "sdks/swift/Tests/CrossdeckTests/ErrorsTests.swift",
4682
+ "name": "test_errorEnvelope_fallsBackOnGarbageBody"
4683
+ },
4684
+ {
4685
+ "file": "sdks/swift/Tests/CrossdeckTests/ErrorsTests.swift",
4686
+ "name": "test_errorEnvelope_reads_XRequestId_fallback"
4687
+ },
4688
+ {
4689
+ "file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/ErrorTypeWireVocabTest.kt",
4690
+ "name": "backend 500 response parses to INTERNAL_ERROR"
4691
+ }
4692
+ ],
4693
+ "registeredAt": "2026-05-26",
4694
+ "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 8 (codifies existing contract)",
4695
+ "bundledIn": "@cross-deck/node@1.6.0"
4696
+ },
4697
+ {
4698
+ "id": "flush-interval-parity",
4699
+ "pillar": "analytics",
4700
+ "status": "enforced",
4701
+ "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.",
4702
+ "appliesTo": [
4703
+ "web",
4704
+ "node",
4705
+ "react-native",
4706
+ "swift",
4707
+ "android"
4708
+ ],
4709
+ "codeRef": [
4710
+ "sdks/web/src/crossdeck.ts",
4711
+ "sdks/node/src/crossdeck-server.ts",
4712
+ "sdks/react-native/src/crossdeck.ts",
4713
+ "sdks/swift/Sources/Crossdeck/EventQueue.swift",
4714
+ "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/EventQueue.kt"
4715
+ ],
4716
+ "testRef": [
4717
+ {
4718
+ "file": "sdks/swift/Sources/Crossdeck/EventQueue.swift",
4719
+ "name": "flushIntervalMs: Int = 2_000"
4720
+ },
4721
+ {
4722
+ "file": "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/EventQueue.kt",
4723
+ "name": "flushIntervalMs: Long = 2_000L"
4724
+ },
4725
+ {
4726
+ "file": "sdks/web/src/crossdeck.ts",
4727
+ "name": "options.eventFlushIntervalMs ?? 2000"
4728
+ },
4729
+ {
4730
+ "file": "sdks/node/src/crossdeck-server.ts",
4731
+ "name": "options.eventFlushIntervalMs ?? 2000"
4732
+ },
4733
+ {
4734
+ "file": "sdks/react-native/src/crossdeck.ts",
4735
+ "name": "options.eventFlushIntervalMs ?? 2000"
4736
+ }
4737
+ ],
4738
+ "registeredAt": "2026-05-26",
4739
+ "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 3.3",
4740
+ "bundledIn": "@cross-deck/node@1.6.0"
4741
+ },
4742
+ {
4743
+ "id": "idempotency-key-deterministic",
4744
+ "pillar": "revenue",
4745
+ "status": "enforced",
4746
+ "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.",
4747
+ "appliesTo": [
4748
+ "web",
4749
+ "node",
4750
+ "react-native",
4751
+ "swift",
4752
+ "android",
4753
+ "backend"
4754
+ ],
4755
+ "codeRef": [
4756
+ "sdks/web/src/idempotency-key.ts",
4757
+ "sdks/web/src/crossdeck.ts",
4758
+ "sdks/react-native/src/idempotency-key.ts",
4759
+ "sdks/react-native/src/crossdeck.ts",
4760
+ "sdks/node/src/idempotency-key.ts",
4761
+ "sdks/node/src/crossdeck-server.ts",
4762
+ "sdks/swift/Sources/Crossdeck/IdempotencyKey.swift",
4763
+ "sdks/swift/Sources/Crossdeck/Crossdeck.swift",
4764
+ "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/IdempotencyKey.kt",
4765
+ "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/Crossdeck.kt",
4766
+ "backend/src/lib/idempotency-response-cache.ts",
4767
+ "backend/src/api/v1-purchases.ts"
4768
+ ],
4769
+ "testRef": [
4770
+ {
4771
+ "file": "sdks/web/tests/idempotency-key.test.ts",
4772
+ "name": "cross-SDK oracle \u2014 apple JWS pins canonical vector"
4773
+ },
4774
+ {
4775
+ "file": "sdks/web/tests/idempotency-key.test.ts",
4776
+ "name": "is deterministic: same body twice -> identical key"
4777
+ },
4778
+ {
4779
+ "file": "sdks/web/tests/idempotency-key.test.ts",
4780
+ "name": "same identifier under different rails -> different keys"
4781
+ },
4782
+ {
4783
+ "file": "sdks/web/tests/idempotency-key.test.ts",
4784
+ "name": "never silently falls back to a random key on missing identifier"
4785
+ },
4786
+ {
4787
+ "file": "sdks/react-native/tests/idempotency-key.test.ts",
4788
+ "name": "is deterministic"
4789
+ },
4790
+ {
4791
+ "file": "sdks/react-native/tests/idempotency-key.test.ts",
4792
+ "name": "cross-SDK oracle \u2014 apple JWS pins canonical vector"
4793
+ },
4794
+ {
4795
+ "file": "sdks/node/tests/idempotency-key.test.ts",
4796
+ "name": "is deterministic"
4797
+ },
4798
+ {
4799
+ "file": "sdks/node/tests/idempotency-key.test.ts",
4800
+ "name": "rail namespacing prevents cross-rail collisions"
4801
+ },
4802
+ {
4803
+ "file": "sdks/node/tests/idempotency-key.test.ts",
4804
+ "name": "apple JWS produces the canonical pinned UUID across all 5 SDKs"
4805
+ },
4806
+ {
4807
+ "file": "backend/tests/unit/idempotency-response-cache.test.ts",
4808
+ "name": "is deterministic for the same input"
4809
+ },
4810
+ {
4811
+ "file": "backend/tests/unit/idempotency-response-cache.test.ts",
4812
+ "name": "injects idempotent_replay: true into a JSON object body"
4813
+ },
4814
+ {
4815
+ "file": "backend/tests/unit/idempotency-response-cache.test.ts",
4816
+ "name": "matches Stripe's 24-hour idempotency window"
4817
+ },
4818
+ {
4819
+ "file": "sdks/swift/Tests/CrossdeckTests/IdempotencyKeyTests.swift",
4820
+ "name": "test_crossSdkOracle_appleJWS"
4821
+ },
4822
+ {
4823
+ "file": "sdks/swift/Tests/CrossdeckTests/IdempotencyKeyTests.swift",
4824
+ "name": "test_railNamespacing_preventsCrossRailCollisions"
4825
+ },
4826
+ {
4827
+ "file": "sdks/swift/Tests/CrossdeckTests/IdempotencyKeyTests.swift",
4828
+ "name": "test_missingIdentifier_returnsNil"
4829
+ },
4830
+ {
4831
+ "file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/IdempotencyKeyTest.kt",
4832
+ "name": "cross-SDK oracle for apple JWS"
4833
+ },
4834
+ {
4835
+ "file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/IdempotencyKeyTest.kt",
4836
+ "name": "rail namespacing prevents cross-rail collisions"
4837
+ },
4838
+ {
4839
+ "file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/IdempotencyKeyTest.kt",
4840
+ "name": "missing identifier returns null - never silent random fallback"
4841
+ }
4842
+ ],
4843
+ "registeredAt": "2026-05-26",
4844
+ "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 2.2.a + 2.2.b + 2.2.c",
4845
+ "bundledIn": "@cross-deck/node@1.6.0"
4846
+ },
4847
+ {
4848
+ "id": "node-pii-scrubber",
4849
+ "pillar": "analytics",
4850
+ "status": "enforced",
4851
+ "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.",
4852
+ "appliesTo": [
4853
+ "node"
4854
+ ],
4855
+ "codeRef": [
4856
+ "sdks/node/src/crossdeck-server.ts",
4857
+ "sdks/node/src/types.ts",
4858
+ "sdks/node/src/consent.ts"
4859
+ ],
4860
+ "testRef": [
4861
+ {
4862
+ "file": "sdks/node/tests/track-pii-scrub.test.ts",
4863
+ "name": "by default redacts email-shaped values to <email>"
4864
+ },
4865
+ {
4866
+ "file": "sdks/node/tests/track-pii-scrub.test.ts",
4867
+ "name": "redacts card-number-shaped values to <card>"
4868
+ },
4869
+ {
4870
+ "file": "sdks/node/tests/track-pii-scrub.test.ts",
4871
+ "name": "walks nested maps + arrays"
4872
+ },
4873
+ {
4874
+ "file": "sdks/node/tests/track-pii-scrub.test.ts",
4875
+ "name": "scrubPii: false preserves the raw payload (opt-out)"
4876
+ },
4877
+ {
4878
+ "file": "sdks/node/tests/track-pii-scrub.test.ts",
4879
+ "name": "scrubPii: true is the default when option is omitted"
4880
+ }
4881
+ ],
4882
+ "registeredAt": "2026-05-26",
4883
+ "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 3.1",
4884
+ "bundledIn": "@cross-deck/node@1.6.0"
4885
+ },
4886
+ {
4887
+ "id": "node-shutdown-awaits-flush",
4888
+ "pillar": "lifecycle",
4889
+ "status": "enforced",
4890
+ "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().",
4891
+ "appliesTo": [
4892
+ "node"
4893
+ ],
4894
+ "codeRef": [
4895
+ "sdks/node/src/crossdeck-server.ts"
4896
+ ],
4897
+ "testRef": [
4898
+ {
4899
+ "file": "sdks/node/tests/shutdown-flush.test.ts",
4900
+ "name": "async shutdown() flushes queued events before clearing"
4901
+ },
4902
+ {
4903
+ "file": "sdks/node/tests/shutdown-flush.test.ts",
4904
+ "name": "async shutdown() proceeds with teardown even if flush fails"
4905
+ },
4906
+ {
4907
+ "file": "sdks/node/tests/shutdown-flush.test.ts",
4908
+ "name": "sync shutdownSync() warns when the buffer has events at teardown"
4909
+ },
4910
+ {
4911
+ "file": "sdks/node/tests/shutdown-flush.test.ts",
4912
+ "name": "[Symbol.asyncDispose] equals await server.shutdown()"
4913
+ }
4914
+ ],
4915
+ "registeredAt": "2026-05-26",
4916
+ "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 5.4",
4917
+ "bundledIn": "@cross-deck/node@1.6.0"
4918
+ },
4919
+ {
4920
+ "id": "sdk-error-codes-catalogue",
4921
+ "pillar": "errors",
4922
+ "status": "enforced",
4923
+ "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.",
4924
+ "appliesTo": [
4925
+ "web",
4926
+ "node"
4927
+ ],
4928
+ "codeRef": [
4929
+ "sdks/web/src/error-codes.ts",
4930
+ "sdks/node/src/error-codes.ts",
4931
+ "sdks/web/src/_contract-verifiers.ts",
4932
+ "backend/src/api/v1-errors.ts"
4933
+ ],
4934
+ "testRef": [
4935
+ {
4936
+ "file": "sdks/web/tests/contract-verifiers.test.ts",
4937
+ "name": "sdk-error-codes-catalogue covers every backend wire code with remediation"
4938
+ },
4939
+ {
4940
+ "file": "sdks/web/tests/error-codes-backfill.test.ts",
4941
+ "name": "includes backend code"
4942
+ },
4943
+ {
4944
+ "file": "sdks/web/tests/error-codes-backfill.test.ts",
4945
+ "name": "invalid_api_key resolution points at the dashboard"
4946
+ },
4947
+ {
4948
+ "file": "sdks/web/tests/error-codes-backfill.test.ts",
4949
+ "name": "idempotency_key_in_use resolution mentions Stripe-grade contract"
4950
+ },
4951
+ {
4952
+ "file": "sdks/web/tests/error-codes-backfill.test.ts",
4953
+ "name": "identity-lock codes carry permission_error type"
4954
+ },
4955
+ {
4956
+ "file": "sdks/web/tests/error-codes-backfill.test.ts",
4957
+ "name": "no entry has an empty description or resolution"
4958
+ }
4959
+ ],
4960
+ "registeredAt": "2026-05-26",
4961
+ "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 6.2",
4962
+ "bundledIn": "@cross-deck/node@1.6.0"
4963
+ },
4964
+ {
4965
+ "id": "sync-purchases-funnel-parity",
4966
+ "pillar": "analytics",
4967
+ "status": "enforced",
4968
+ "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`.",
4969
+ "appliesTo": [
4970
+ "web",
4971
+ "node",
4972
+ "react-native",
4973
+ "swift",
4974
+ "android"
4975
+ ],
4976
+ "codeRef": [
4977
+ "sdks/web/src/crossdeck.ts",
4978
+ "sdks/node/src/crossdeck-server.ts",
4979
+ "sdks/react-native/src/crossdeck.ts",
4980
+ "sdks/swift/Sources/Crossdeck/Crossdeck.swift",
4981
+ "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/Crossdeck.kt"
4982
+ ],
4983
+ "testRef": [
4984
+ {
4985
+ "file": "sdks/web/tests/sync-purchases-funnel.test.ts",
4986
+ "name": "emits purchase.completed after a successful sync"
4987
+ },
4988
+ {
4989
+ "file": "sdks/web/tests/sync-purchases-funnel.test.ts",
4990
+ "name": "carries idempotent_replay=true when backend replied from cache"
4991
+ }
4992
+ ],
4993
+ "registeredAt": "2026-05-26",
4994
+ "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 3.5",
4995
+ "bundledIn": "@cross-deck/node@1.6.0"
4996
+ },
4997
+ {
4998
+ "id": "verifier-timestamp-mandatory",
4999
+ "pillar": "webhooks",
5000
+ "status": "enforced",
5001
+ "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.",
5002
+ "appliesTo": [
5003
+ "node"
5004
+ ],
5005
+ "codeRef": [
5006
+ "sdks/node/src/webhooks.ts",
5007
+ "sdks/node/src/error-codes.ts"
5008
+ ],
5009
+ "testRef": [
5010
+ {
5011
+ "file": "sdks/node/tests/webhooks.test.ts",
5012
+ "name": "tolerance of 0 still enforces the replay window (v1.4.0 \u2014 cannot disable)"
5013
+ },
5014
+ {
5015
+ "file": "sdks/node/tests/webhooks.test.ts",
5016
+ "name": "rejects Infinity tolerance (would silently disable replay protection)"
5017
+ },
5018
+ {
5019
+ "file": "sdks/node/tests/webhooks.test.ts",
5020
+ "name": "rejects NaN tolerance"
5021
+ },
5022
+ {
5023
+ "file": "sdks/node/tests/webhooks.test.ts",
5024
+ "name": "rejects negative tolerance"
5025
+ },
5026
+ {
5027
+ "file": "sdks/node/tests/webhooks.test.ts",
5028
+ "name": "rejects tolerance above the 24h cap"
5029
+ },
5030
+ {
5031
+ "file": "sdks/node/tests/webhooks.test.ts",
5032
+ "name": "rejects non-number tolerance (null / string)"
5033
+ },
5034
+ {
5035
+ "file": "sdks/node/tests/webhooks.test.ts",
5036
+ "name": "accepts tolerance exactly at the 24h cap"
5037
+ },
5038
+ {
5039
+ "file": "sdks/node/tests/webhooks.test.ts",
5040
+ "name": "malformed header (no t= or no v1=) throws webhook_timestamp_missing"
5041
+ },
5042
+ {
5043
+ "file": "sdks/node/tests/webhooks.test.ts",
5044
+ "name": "valid signature but non-JSON payload throws webhook_payload_not_json"
5045
+ }
5046
+ ],
5047
+ "registeredAt": "2026-05-26",
5048
+ "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 7.2",
5049
+ "bundledIn": "@cross-deck/node@1.6.0"
4075
5050
  }
4076
- return out;
4077
- }
4078
- function scrubValue(v) {
4079
- if (typeof v === "string") return scrubPii(v);
4080
- if (Array.isArray(v)) return v.map(scrubValue);
4081
- if (v && typeof v === "object" && v.constructor === Object) {
4082
- return scrubPiiFromProperties(v);
5051
+ ]);
5052
+
5053
+ // src/contracts.ts
5054
+ var CrossdeckContracts = {
5055
+ all() {
5056
+ return BUNDLED_CONTRACTS.filter((c) => c.status === "enforced");
5057
+ },
5058
+ allIncludingHistorical() {
5059
+ return BUNDLED_CONTRACTS;
5060
+ },
5061
+ byId(id) {
5062
+ return BUNDLED_CONTRACTS.find((c) => c.id === id);
5063
+ },
5064
+ byPillar(pillar) {
5065
+ return BUNDLED_CONTRACTS.filter(
5066
+ (c) => c.pillar === pillar && c.status === "enforced"
5067
+ );
5068
+ },
5069
+ withStatus(status) {
5070
+ return BUNDLED_CONTRACTS.filter((c) => c.status === status);
5071
+ },
5072
+ sdkVersion: SDK_VERSION2,
5073
+ bundledIn: BUNDLED_IN,
5074
+ /**
5075
+ * Resolve a failing test back to the contract it exercises.
5076
+ * Used by test-framework hooks to find the contract id of a
5077
+ * failed contract test so `reportContractFailure(...)` can stamp
5078
+ * the right `contract_id` on the emitted event.
5079
+ */
5080
+ findByTestName(name) {
5081
+ return BUNDLED_CONTRACTS.find(
5082
+ (c) => c.testRef.some((ref) => ref.name === name)
5083
+ );
4083
5084
  }
4084
- return v;
4085
- }
5085
+ };
4086
5086
  // Annotate the CommonJS export names for ESM import in node:
4087
5087
  0 && (module.exports = {
4088
5088
  CROSSDECK_API_VERSION,
4089
5089
  CROSSDECK_ERROR_CODES,
4090
5090
  CrossdeckAuthenticationError,
4091
5091
  CrossdeckConfigurationError,
5092
+ CrossdeckContracts,
4092
5093
  CrossdeckError,
4093
5094
  CrossdeckInternalError,
4094
5095
  CrossdeckNetworkError,