@cross-deck/node 1.5.0 → 1.5.2

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.mjs CHANGED
@@ -317,7 +317,7 @@ function byteLength(s) {
317
317
  }
318
318
 
319
319
  // src/_version.ts
320
- var SDK_VERSION = "1.4.2";
320
+ var SDK_VERSION = "1.3.1";
321
321
  var SDK_NAME = "@cross-deck/node";
322
322
 
323
323
  // src/http.ts
@@ -2349,63 +2349,6 @@ var EntitlementCache = class {
2349
2349
  }
2350
2350
  };
2351
2351
 
2352
- // src/consent.ts
2353
- var EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
2354
- var CARD_PATTERN = /\b\d(?:[ -]?\d){12,18}\b/g;
2355
- var REPLACEMENT_EMAIL = "<email>";
2356
- var REPLACEMENT_CARD = "<card>";
2357
- function scrubPii(value) {
2358
- if (!value) return value;
2359
- return value.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL).replace(CARD_PATTERN, REPLACEMENT_CARD);
2360
- }
2361
- function scrubPiiFromProperties(properties) {
2362
- const out = {};
2363
- for (const k of Object.keys(properties)) {
2364
- out[k] = scrubValue(properties[k]);
2365
- }
2366
- return out;
2367
- }
2368
- function scrubValue(v) {
2369
- if (typeof v === "string") return scrubPii(v);
2370
- if (Array.isArray(v)) return v.map(scrubValue);
2371
- if (v && typeof v === "object" && v.constructor === Object) {
2372
- return scrubPiiFromProperties(v);
2373
- }
2374
- return v;
2375
- }
2376
-
2377
- // src/idempotency-key.ts
2378
- import { createHash } from "crypto";
2379
- function formatAsUuid(hex) {
2380
- return [
2381
- hex.slice(0, 8),
2382
- hex.slice(8, 12),
2383
- hex.slice(12, 16),
2384
- hex.slice(16, 20),
2385
- hex.slice(20, 32)
2386
- ].join("-");
2387
- }
2388
- function sha256Hex(input) {
2389
- return createHash("sha256").update(input, "utf8").digest("hex");
2390
- }
2391
- function deriveIdempotencyKeyForPurchase(body) {
2392
- let identifier;
2393
- if (body.rail === "apple") {
2394
- identifier = body.signedTransactionInfo ?? "";
2395
- } else if (body.rail === "google") {
2396
- identifier = body.purchaseToken ?? "";
2397
- } else {
2398
- identifier = "";
2399
- }
2400
- if (!identifier) {
2401
- throw new Error(
2402
- `deriveIdempotencyKeyForPurchase: no stable identifier in body (rail=${body.rail}). Apple needs signedTransactionInfo; Google needs purchaseToken.`
2403
- );
2404
- }
2405
- const namespaced = `crossdeck:purchases/sync:${body.rail}:${identifier}`;
2406
- return formatAsUuid(sha256Hex(namespaced));
2407
- }
2408
-
2409
2352
  // src/debug.ts
2410
2353
  var SENSITIVE_KEY_PATTERNS = [
2411
2354
  /^email$/i,
@@ -2465,10 +2408,6 @@ var CrossdeckServer = class extends EventEmitter {
2465
2408
  baseUrl;
2466
2409
  appId;
2467
2410
  env;
2468
- /** PII scrubber toggle. Default true — parity with Web/RN/Swift.
2469
- * Pre-v1.4.0 the Node SDK shipped track() payloads UNREDACTED,
2470
- * a privacy contract drift versus the README. */
2471
- scrubPii;
2472
2411
  secretKeyPrefix;
2473
2412
  /**
2474
2413
  * Process-stable pseudo-anonymous ID. Used as the default identity
@@ -2514,15 +2453,6 @@ var CrossdeckServer = class extends EventEmitter {
2514
2453
  errorContext = {};
2515
2454
  errorTags = {};
2516
2455
  errorBeforeSend = null;
2517
- /**
2518
- * Dedup gate for `sdk.shutdown`. Both `shutdown()` (async) and
2519
- * `shutdownSync()` need to emit so direct callers of EITHER see
2520
- * the event (the async path's listener guarantees pre-launch
2521
- * tests, the sync path covers `Symbol.dispose` + tests that call
2522
- * `shutdownSync()` directly). Without this flag, `shutdown()`'s
2523
- * tail call into `shutdownSync()` would emit twice.
2524
- */
2525
- didEmitShutdown = false;
2526
2456
  constructor(options) {
2527
2457
  super();
2528
2458
  if (!options.secretKey || !options.secretKey.startsWith("cd_sk_")) {
@@ -2537,7 +2467,6 @@ var CrossdeckServer = class extends EventEmitter {
2537
2467
  this.baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
2538
2468
  this.env = inferEnvFromKey(options.secretKey);
2539
2469
  this.secretKeyPrefix = maskSecretKey(options.secretKey);
2540
- this.scrubPii = options.scrubPii !== false;
2541
2470
  this.http = new HttpClient({
2542
2471
  secretKey: options.secretKey,
2543
2472
  baseUrl: this.baseUrl,
@@ -2577,9 +2506,7 @@ var CrossdeckServer = class extends EventEmitter {
2577
2506
  this.eventQueue = new EventQueue({
2578
2507
  http: this.http,
2579
2508
  batchSize: options.eventFlushBatchSize ?? 20,
2580
- // v1.4.0 Phase 3.3 — flush interval default parity at 2000ms
2581
- // across every SDK. Per-instance override stays.
2582
- intervalMs: options.eventFlushIntervalMs ?? 2e3,
2509
+ intervalMs: options.eventFlushIntervalMs ?? 1500,
2583
2510
  envelope: () => ({
2584
2511
  appId: this.appId,
2585
2512
  // Ship env on every batch so the backend can cross-check
@@ -2958,38 +2885,6 @@ var CrossdeckServer = class extends EventEmitter {
2958
2885
  * `uncaughtException` has no per-request context; without the
2959
2886
  * auto-fill, the event would be rejected at queue enqueue.
2960
2887
  */
2961
- /**
2962
- * Emit `crossdeck.contract_failed` with the canonical property
2963
- * shape. Same wire shape every Crossdeck SDK uses for contract
2964
- * verification telemetry — see `contracts/README.md` for the
2965
- * full pattern. No new endpoint, no special path; goes through
2966
- * the standard server-side `track()` pipeline.
2967
- */
2968
- reportContractFailure(input) {
2969
- const props = {
2970
- contract_id: input.contractId,
2971
- sdk_version: SDK_VERSION,
2972
- sdk_platform: "node",
2973
- failure_reason: input.failureReason,
2974
- run_context: input.runContext,
2975
- run_id: input.runId
2976
- };
2977
- if (input.testRef) {
2978
- props.test_file = input.testRef.file;
2979
- props.test_name = input.testRef.name;
2980
- }
2981
- if (input.extra) {
2982
- for (const [k, v] of Object.entries(input.extra)) {
2983
- if (props[k] === void 0) props[k] = v;
2984
- }
2985
- }
2986
- this.track({
2987
- name: "crossdeck.contract_failed",
2988
- properties: props
2989
- // No identity hint — these events are about the SDK / dogfood
2990
- // run itself, not a specific end-user.
2991
- });
2992
- }
2993
2888
  track(event) {
2994
2889
  if (!event.name) {
2995
2890
  throw new CrossdeckError({
@@ -2998,8 +2893,7 @@ var CrossdeckServer = class extends EventEmitter {
2998
2893
  message: "track(event) requires a non-empty event.name."
2999
2894
  });
3000
2895
  }
3001
- const validated = sanitizePropertyBag(event.properties, "event properties") ?? {};
3002
- const sanitized = this.scrubPii ? scrubPiiFromProperties(validated) : validated;
2896
+ const sanitized = sanitizePropertyBag(event.properties, "event properties") ?? {};
3003
2897
  if (this.debug.enabled) {
3004
2898
  const flagged = findSensitivePropertyKeys(sanitized);
3005
2899
  if (flagged.length > 0) {
@@ -3135,25 +3029,11 @@ var CrossdeckServer = class extends EventEmitter {
3135
3029
  });
3136
3030
  }
3137
3031
  const rail = input.rail ?? "apple";
3138
- const body = { ...input, rail };
3139
- const idempotencyKey = options?.idempotencyKey ?? deriveIdempotencyKeyForPurchase(body);
3140
- const result = await this.http.request("POST", "/purchases/sync", {
3141
- body,
3142
- idempotencyKey,
3032
+ return this.http.request("POST", "/purchases/sync", {
3033
+ body: { ...input, rail },
3143
3034
  signal: options?.signal,
3144
3035
  timeoutMs: options?.timeoutMs
3145
3036
  });
3146
- try {
3147
- const sourceProductId = result.entitlements[0]?.source.productId;
3148
- const sourceSubscriptionId = result.entitlements[0]?.source.subscriptionId;
3149
- const props = { rail };
3150
- if (sourceProductId) props.productId = sourceProductId;
3151
- if (sourceSubscriptionId) props.subscriptionId = sourceSubscriptionId;
3152
- if (result.idempotent_replay) props.idempotent_replay = true;
3153
- this.track({ name: "purchase.completed", properties: props });
3154
- } catch {
3155
- }
3156
- return result;
3157
3037
  }
3158
3038
  // ============================================================
3159
3039
  // Manual entitlement controls + audit — direct HTTP
@@ -3445,56 +3325,12 @@ var CrossdeckServer = class extends EventEmitter {
3445
3325
  };
3446
3326
  }
3447
3327
  /**
3448
- * Tear down handlers and clear in-memory state.
3449
- *
3450
- * **v1.4.0 bank-grade contract:** `shutdown()` AWAITS `flush()`
3451
- * before dropping the queue, so callers don't silently lose
3452
- * every queued event on a clean shutdown. The pre-v1.4.0
3453
- * behaviour (sync `eventQueue.reset()` with no flush) was the
3454
- * default for both `shutdown()` and `[Symbol.dispose]`; only
3455
- * `await using` + `[Symbol.asyncDispose]` flushed correctly.
3456
- *
3457
- * Production servers should still prefer `await server.flush()`
3458
- * (visible) followed by `server.shutdown()` so the flush
3459
- * outcome is observable — `shutdown()`'s internal flush swallows
3460
- * errors as a best-effort drain.
3461
- *
3462
- * Use [[shutdownSync]] only when the runtime cannot await
3463
- * (e.g. inside `Symbol.dispose` — see below).
3328
+ * Tear down handlers and clear in-memory state. Tests + custom
3329
+ * lifecycle callers only. Production code should rely on
3330
+ * `flush-on-exit` instead.
3464
3331
  */
3465
- async shutdown(reason = "shutdown") {
3466
- if (!this.didEmitShutdown) {
3467
- this.emit("sdk.shutdown", { reason });
3468
- this.didEmitShutdown = true;
3469
- }
3470
- try {
3471
- await this.flush();
3472
- } catch {
3473
- }
3474
- this.shutdownSync(reason);
3475
- }
3476
- /**
3477
- * Synchronous teardown — drops the in-memory queue WITHOUT
3478
- * flushing, then clears all in-memory state. Used by
3479
- * `[Symbol.dispose]` (which has no await) and tests that need
3480
- * an unconditional sync wipe. Production code should use
3481
- * [[shutdown]] (async) instead so queued events are flushed.
3482
- *
3483
- * A queue with items at sync-shutdown logs a warning recommending
3484
- * `[Symbol.asyncDispose]` or `await server.shutdown()` — silent
3485
- * loss is incompatible with the bank-grade contract.
3486
- */
3487
- shutdownSync(reason = "shutdown") {
3488
- if (!this.didEmitShutdown) {
3489
- this.emit("sdk.shutdown", { reason });
3490
- this.didEmitShutdown = true;
3491
- }
3492
- const queuedCount = this.eventQueue.getStats().buffered;
3493
- if (queuedCount > 0 && reason !== "asyncDispose") {
3494
- console.warn(
3495
- `[crossdeck] shutdownSync() dropped ${queuedCount} queued event(s) without flushing. Use \`await server.shutdown()\` or \`await using server = ...\` with \`[Symbol.asyncDispose]\` to drain the buffer before teardown.`
3496
- );
3497
- }
3332
+ shutdown(reason = "shutdown") {
3333
+ this.emit("sdk.shutdown", { reason });
3498
3334
  this.errorTracker?.uninstall();
3499
3335
  this.flushOnExit?.uninstall();
3500
3336
  this.eventQueue.reset();
@@ -3608,28 +3444,28 @@ var CrossdeckServer = class extends EventEmitter {
3608
3444
  * // ... use server ...
3609
3445
  * // at end of block, server[Symbol.dispose]() runs automatically
3610
3446
  *
3611
- * **`Symbol.dispose` is synchronous so it CANNOT await the queue
3612
- * flush.** A queue with pending events at sync-dispose time will
3613
- * be DROPPED `shutdownSync` warns to the console when this
3614
- * happens. For the common case of "drain the queue before
3615
- * exit", switch to `await using` + `[Symbol.asyncDispose]` (or
3616
- * call `await server.shutdown()` explicitly before the variable
3617
- * goes out of scope).
3447
+ * `Symbol.dispose` is synchronous so we can't await `flush()` here
3448
+ * for that, use `await using` + `[Symbol.asyncDispose]()`. This
3449
+ * sync variant just calls `shutdown()` (handler cleanup +
3450
+ * in-memory state wipe).
3618
3451
  */
3619
3452
  [Symbol.dispose]() {
3620
- this.shutdownSync("dispose");
3453
+ this.shutdown("dispose");
3621
3454
  }
3622
3455
  /**
3623
3456
  * Async disposal hook — runs when an `await using` declaration
3624
- * exits scope. Awaits the bank-grade `shutdown()` which flushes
3625
- * the queue THEN tears down. Use this variant for any code path
3626
- * that owns queued events at exit (serverless handlers,
3627
- * background workers, end-of-request hooks).
3457
+ * exits scope. Awaits `flush()` THEN runs `shutdown()`. Use this
3458
+ * variant when the caller needs the queue drained before exit
3459
+ * (the common case for serverless handlers).
3628
3460
  *
3629
3461
  * await using server = new CrossdeckServer({ ... });
3630
3462
  */
3631
3463
  async [Symbol.asyncDispose]() {
3632
- await this.shutdown("asyncDispose");
3464
+ try {
3465
+ await this.flush();
3466
+ } catch {
3467
+ }
3468
+ this.shutdown("asyncDispose");
3633
3469
  }
3634
3470
  // ============================================================
3635
3471
  reportCapturedError(captured) {
@@ -4049,43 +3885,18 @@ var _CROSSDECK_ERROR_CODES = Object.freeze([
4049
3885
  retryable: false
4050
3886
  },
4051
3887
  // ----- Webhook verification (Node-specific) -----
4052
- // v1.4.0 Phase 7.2 — distinguishable codes. Pre-v1.4.0 the
4053
- // helper used webhook_invalid_signature for nearly every failure
4054
- // mode so a customer couldn't separate replay-attack signals
4055
- // from wrong-secret signals in alerting.
4056
3888
  {
4057
- code: "webhook_signature_mismatch",
4058
- type: "authentication_error",
4059
- description: "Webhook HMAC didn't verify against any configured secret (wrong-secret / stale rotation signal).",
4060
- resolution: "Confirm the secret matches dashboard \u2192 Webhooks. If you rotated, include both the old and new secret as an array until receivers cut over.",
4061
- retryable: false
4062
- },
4063
- {
4064
- code: "webhook_timestamp_outside_tolerance",
4065
- type: "authentication_error",
4066
- description: "Webhook timestamp drift exceeds the configured replay-tolerance window (default 5 minutes; replay-attack signal).",
4067
- resolution: "Verify NTP on the receiving host. A spike on this code warrants its own alert separate from signature_mismatch \u2014 replay attacks look like this.",
4068
- retryable: false
4069
- },
4070
- {
4071
- code: "webhook_timestamp_missing",
3889
+ code: "webhook_invalid_signature",
4072
3890
  type: "authentication_error",
4073
- description: "Webhook signature header is absent or has no `t=` timestamp segment \u2014 the timestamp gate cannot be verified.",
4074
- resolution: "Confirm the request actually came from Crossdeck (signature headers are always present on real deliveries). A missing header is either a misconfigured intermediary or a forged request.",
3891
+ description: "The webhook signature header did not verify against the supplied secret.",
3892
+ 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.",
4075
3893
  retryable: false
4076
3894
  },
4077
3895
  {
4078
- code: "webhook_payload_not_json",
3896
+ code: "webhook_replay_window_exceeded",
4079
3897
  type: "authentication_error",
4080
- description: "Webhook signature verified but the body isn't valid JSON \u2014 payload tampered post-signing or source bug.",
4081
- resolution: "Inspect the raw payload. If it's not JSON, either the request was modified in transit or the sender has a bug \u2014 file a support ticket with the raw body.",
4082
- retryable: false
4083
- },
4084
- {
4085
- code: "webhook_invalid_tolerance",
4086
- type: "configuration_error",
4087
- description: "verifyWebhookSignature() called with a non-finite / negative / above-24h-cap replayToleranceMs (would silently disable replay protection).",
4088
- resolution: "Pass a finite number between 0 and 86_400_000ms (24h). Default (5 minutes) is correct for almost every scenario. Pre-v1.4.0 accepted Infinity/NaN and silently dropped the check.",
3898
+ description: "The webhook timestamp is older than the replay-tolerance window (default 5 minutes).",
3899
+ 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.",
4089
3900
  retryable: false
4090
3901
  },
4091
3902
  {
@@ -4094,101 +3905,6 @@ var _CROSSDECK_ERROR_CODES = Object.freeze([
4094
3905
  description: "verifyWebhookSignature() was called without a signing secret.",
4095
3906
  resolution: "Pass the secret from your Crossdeck dashboard \u2192 Webhooks page. Never hardcode in source \u2014 read from an env var.",
4096
3907
  retryable: false
4097
- },
4098
- {
4099
- code: "webhook_invalid_signature",
4100
- type: "authentication_error",
4101
- description: "DEPRECATED in v1.4.0 \u2014 split into webhook_signature_mismatch / webhook_timestamp_missing / webhook_timestamp_outside_tolerance / webhook_payload_not_json for alerting clarity.",
4102
- resolution: "Migrate alert rules to the more specific v1.4.0 codes \u2014 they distinguish replay-attack signals from wrong-secret signals.",
4103
- retryable: false
4104
- },
4105
- {
4106
- code: "webhook_replay_window_exceeded",
4107
- type: "authentication_error",
4108
- description: "DEPRECATED in v1.4.0 \u2014 renamed to webhook_timestamp_outside_tolerance.",
4109
- resolution: "Update alerts to webhook_timestamp_outside_tolerance.",
4110
- retryable: false
4111
- },
4112
- // ----- Backend-emitted codes (v1.4.0 Phase 6.2 backfill) -----
4113
- // Mirror of backend/src/api/v1-errors.ts ApiErrorCode. Same set
4114
- // as the Web SDK ships — keep these synchronised so a developer
4115
- // hitting any code via either SDK gets the same remediation.
4116
- {
4117
- code: "missing_api_key",
4118
- type: "authentication_error",
4119
- description: "No Authorization header (or Crossdeck-Api-Key header) on the request.",
4120
- resolution: "Confirm the CrossdeckServer was constructed with a cd_sk_\u2026 secretKey. Re-check env vars in production deployments.",
4121
- retryable: false
4122
- },
4123
- {
4124
- code: "invalid_api_key",
4125
- type: "authentication_error",
4126
- description: "The secret key is malformed, unknown, or doesn't resolve to a project.",
4127
- resolution: "Copy the key from Crossdeck dashboard \u2192 API keys. Server SDK requires cd_sk_test_ / cd_sk_live_ \u2014 client SDK keys (cd_pub_\u2026) won't work on the Node SDK.",
4128
- retryable: false
4129
- },
4130
- {
4131
- code: "key_revoked",
4132
- type: "authentication_error",
4133
- description: "The secret key was revoked in the dashboard.",
4134
- resolution: "Mint a fresh key in dashboard \u2192 API keys \u2192 Create new. The revoked key cannot be reactivated.",
4135
- retryable: false
4136
- },
4137
- {
4138
- code: "env_mismatch",
4139
- type: "permission_error",
4140
- description: "The key's env prefix doesn't match the resolved app's configured env.",
4141
- resolution: "Use a cd_sk_live_ key with a production app, cd_sk_test_ with a sandbox app. Crossing breaks the env lock.",
4142
- retryable: false
4143
- },
4144
- {
4145
- code: "idempotency_key_in_use",
4146
- type: "invalid_request_error",
4147
- description: "An Idempotency-Key was reused for a request with a different body (Stripe-grade contract).",
4148
- resolution: "Server SDK derives keys deterministically from the body since v1.4.0; this should only fire if you passed options.idempotencyKey explicitly. Use a fresh key per logical operation.",
4149
- retryable: false
4150
- },
4151
- {
4152
- code: "rate_limited",
4153
- type: "rate_limit_error",
4154
- description: "Request rate exceeded the project's per-second cap.",
4155
- resolution: "Honour Retry-After (managed retries do this automatically). For custom paths, throttle to <100 req/s/key.",
4156
- retryable: true
4157
- },
4158
- {
4159
- code: "internal_error",
4160
- type: "internal_error",
4161
- description: "Server-side issue. Safe to retry with backoff.",
4162
- resolution: "Managed retries handle this automatically. If a code path surfaces it to your code, contact support with the requestId.",
4163
- retryable: true
4164
- },
4165
- {
4166
- code: "google_not_supported",
4167
- type: "invalid_request_error",
4168
- description: "POST /purchases/sync with rail=google is gated until the Play Developer API reconciliation worker ships.",
4169
- resolution: "Until v1.5+, Google Play purchases verify via Real-time Developer Notifications. The Android SDK auto-track path handles this transparently.",
4170
- retryable: false
4171
- },
4172
- {
4173
- code: "stripe_not_supported",
4174
- type: "invalid_request_error",
4175
- description: "POST /purchases/sync with rail=stripe is unsupported \u2014 Stripe webhooks deliver evidence server-side.",
4176
- resolution: "Wire Stripe via the standard Checkout / Customer Portal flow; Crossdeck reconciles via the platform webhook automatically.",
4177
- retryable: false
4178
- },
4179
- {
4180
- code: "missing_required_param",
4181
- type: "invalid_request_error",
4182
- description: "A required field is absent from the request body.",
4183
- resolution: "The error.message identifies the missing field. Refer to the SDK's TypeScript types for canonical shapes.",
4184
- retryable: false
4185
- },
4186
- {
4187
- code: "invalid_param_value",
4188
- type: "invalid_request_error",
4189
- description: "A field is present but the value failed validation.",
4190
- resolution: "Read error.message for the field + reason. SDK-managed call sites should never emit this \u2014 file a bug if you do.",
4191
- retryable: false
4192
3908
  }
4193
3909
  ]);
4194
3910
  function isCrossdeckErrorCode(code) {
@@ -4202,7 +3918,6 @@ function getErrorCode(code) {
4202
3918
  // src/webhooks.ts
4203
3919
  import { createHmac, timingSafeEqual } from "crypto";
4204
3920
  var DEFAULT_REPLAY_TOLERANCE_MS = 5 * 60 * 1e3;
4205
- var MAX_REPLAY_TOLERANCE_MS = 24 * 60 * 60 * 1e3;
4206
3921
  function verifyWebhookSignature(payload, signatureHeader, secret, options = {}) {
4207
3922
  const secrets = normaliseSecrets(secret);
4208
3923
  if (secrets.length === 0) {
@@ -4212,56 +3927,34 @@ function verifyWebhookSignature(payload, signatureHeader, secret, options = {})
4212
3927
  message: "verifyWebhookSignature requires a non-empty secret. Read it from process.env.CROSSDECK_WEBHOOK_SECRET \u2014 never hardcode in source."
4213
3928
  });
4214
3929
  }
4215
- const requestedTolerance = options.replayToleranceMs;
4216
- let tolerance;
4217
- if (requestedTolerance === void 0) {
4218
- tolerance = DEFAULT_REPLAY_TOLERANCE_MS;
4219
- } else if (typeof requestedTolerance !== "number" || !Number.isFinite(requestedTolerance)) {
4220
- throw new CrossdeckError({
4221
- type: "configuration_error",
4222
- code: "webhook_invalid_tolerance",
4223
- message: `replayToleranceMs must be a finite non-negative number \u2264 ${MAX_REPLAY_TOLERANCE_MS} (24h). Got: ${String(requestedTolerance)}. Pre-v1.4.0 accepted Infinity/NaN/null and silently disabled replay protection \u2014 v1.4.0 rejects loudly.`
4224
- });
4225
- } else if (requestedTolerance < 0) {
4226
- throw new CrossdeckError({
4227
- type: "configuration_error",
4228
- code: "webhook_invalid_tolerance",
4229
- message: `replayToleranceMs must be \u2265 0. Got ${requestedTolerance}.`
4230
- });
4231
- } else if (requestedTolerance > MAX_REPLAY_TOLERANCE_MS) {
4232
- throw new CrossdeckError({
4233
- type: "configuration_error",
4234
- code: "webhook_invalid_tolerance",
4235
- message: `replayToleranceMs must not exceed ${MAX_REPLAY_TOLERANCE_MS}ms (24h). Got ${requestedTolerance}ms \u2014 a window that wide defeats replay protection.`
4236
- });
4237
- } else {
4238
- tolerance = requestedTolerance;
4239
- }
4240
3930
  const header = normaliseHeader(signatureHeader);
4241
3931
  const parsed = parseSignatureHeader(header);
4242
3932
  if (!parsed) {
4243
3933
  throw new CrossdeckError({
4244
3934
  type: "authentication_error",
4245
- code: "webhook_timestamp_missing",
4246
- message: "Webhook signature header is missing, malformed, or has no `t=` timestamp segment. Expected 'Crossdeck-Signature: t=<unix>,v1=<hex>'."
3935
+ code: "webhook_invalid_signature",
3936
+ message: "Webhook signature header is missing or malformed. Expected 'Crossdeck-Signature: t=<unix>,v1=<hex>'."
4247
3937
  });
4248
3938
  }
4249
- const now = (options.now ?? Date.now)();
4250
- const timestampMs = parsed.timestampSec * 1e3;
4251
- const drift = Math.abs(now - timestampMs);
4252
- if (drift > tolerance) {
4253
- throw new CrossdeckError({
4254
- type: "authentication_error",
4255
- code: "webhook_timestamp_outside_tolerance",
4256
- message: `Webhook timestamp is ${drift}ms outside the ${tolerance}ms replay-tolerance window. Either the request is replayed or the receiving clock is skewed \u2014 verify NTP on the host.`
4257
- });
3939
+ const tolerance = options.replayToleranceMs ?? DEFAULT_REPLAY_TOLERANCE_MS;
3940
+ if (tolerance > 0) {
3941
+ const now = (options.now ?? Date.now)();
3942
+ const timestampMs = parsed.timestampSec * 1e3;
3943
+ const drift = Math.abs(now - timestampMs);
3944
+ if (drift > tolerance) {
3945
+ throw new CrossdeckError({
3946
+ type: "authentication_error",
3947
+ code: "webhook_replay_window_exceeded",
3948
+ message: `Webhook timestamp is ${drift}ms outside the ${tolerance}ms replay-tolerance window. Either the request is replayed or the receiving clock is skewed \u2014 verify NTP on the host.`
3949
+ });
3950
+ }
4258
3951
  }
4259
3952
  const signedPayload = `${parsed.timestampSec}.${payload}`;
4260
3953
  const expectedBuf = Buffer.from(parsed.signature, "hex");
4261
3954
  if (expectedBuf.length === 0) {
4262
3955
  throw new CrossdeckError({
4263
3956
  type: "authentication_error",
4264
- code: "webhook_signature_mismatch",
3957
+ code: "webhook_invalid_signature",
4265
3958
  message: "Webhook signature is not a valid hex string."
4266
3959
  });
4267
3960
  }
@@ -4272,8 +3965,8 @@ function verifyWebhookSignature(payload, signatureHeader, secret, options = {})
4272
3965
  if (!anyMatch) {
4273
3966
  throw new CrossdeckError({
4274
3967
  type: "authentication_error",
4275
- code: "webhook_signature_mismatch",
4276
- message: "Webhook signature did not verify against any configured secret. Confirm the secret matches your Crossdeck dashboard \u2192 Webhooks page (and that you're not on a stale rotation)."
3968
+ code: "webhook_invalid_signature",
3969
+ message: "Webhook signature did not verify. Confirm the secret matches your Crossdeck dashboard \u2192 Webhooks page (and that you're not on a stale rotation)."
4277
3970
  });
4278
3971
  }
4279
3972
  try {
@@ -4281,7 +3974,7 @@ function verifyWebhookSignature(payload, signatureHeader, secret, options = {})
4281
3974
  } catch {
4282
3975
  throw new CrossdeckError({
4283
3976
  type: "authentication_error",
4284
- code: "webhook_payload_not_json",
3977
+ code: "webhook_invalid_signature",
4285
3978
  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."
4286
3979
  });
4287
3980
  }
@@ -4319,471 +4012,35 @@ function normaliseSecrets(input) {
4319
4012
  return arr.filter((s) => typeof s === "string" && s.length > 0);
4320
4013
  }
4321
4014
 
4322
- // src/_contracts-bundled.ts
4323
- var BUNDLED_IN = "@cross-deck/node@1.5.0";
4324
- var SDK_VERSION2 = "1.5.0";
4325
- var BUNDLED_CONTRACTS = Object.freeze([
4326
- {
4327
- "id": "documentation-honesty",
4328
- "pillar": "webhooks",
4329
- "status": "enforced",
4330
- "claim": "Customer-facing documentation honestly tags outbound webhook delivery as ROADMAP (no signer, no worker, no scheduler in backend/src yet). The Node verifier helper exists today for fixture authoring + locking the validation contract surface BEFORE delivery ships \u2014 its jsdoc carries an explicit `[ROADMAP]` disclaimer so a developer reading the source doesn't assume Crossdeck sends webhooks today. The rail-webhooks doc no longer claims state surfaces 'through the dashboard, SDKs, and outbound webhooks' \u2014 outbound is gated to the explicit roadmap section.",
4331
- "appliesTo": [
4332
- "node",
4333
- "backend"
4334
- ],
4335
- "codeRef": [
4336
- "sdks/node/src/webhooks.ts",
4337
- "docs/rail-webhooks/index.html",
4338
- "docs/webhooks-receive/index.html"
4339
- ],
4340
- "testRef": [
4341
- {
4342
- "file": "sdks/node/src/webhooks.ts",
4343
- "name": "[ROADMAP \u2014 v1.4.0 honesty note]"
4344
- },
4345
- {
4346
- "file": "docs/rail-webhooks/index.html",
4347
- "name": "Outbound push-to-your-backend webhooks are <strong>roadmap</strong>"
4348
- },
4349
- {
4350
- "file": "docs/webhooks-receive/index.html",
4351
- "name": "This feature is on the roadmap"
4352
- }
4353
- ],
4354
- "registeredAt": "2026-05-26",
4355
- "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 7.1",
4356
- "bundledIn": "@cross-deck/node@1.5.0"
4357
- },
4358
- {
4359
- "id": "error-envelope-shape",
4360
- "pillar": "errors",
4361
- "status": "enforced",
4362
- "claim": "Every v1 REST endpoint returns errors in a Stripe-shape envelope: `{ error: { type, code, message, request_id } }` where `type` is one of authentication_error / permission_error / invalid_request_error / rate_limit_error / internal_error (the wire vocabulary in backend/src/api/v1-errors.ts ApiErrorType). HTTP status parity: invalid_request_error \u2192 400, authentication_error \u2192 401, permission_error \u2192 403, rate_limit_error \u2192 429, internal_error \u2192 500. SDK-side clients parse this shape via `crossdeckErrorFromResponse` (Web/Node/RN) / `crossdeckErrorFrom(response:)` (Swift) / `crossdeckErrorFromResponse` (Android) and surface the request_id verbatim so support traces are end-to-end joinable. Firebase callable endpoints (managed-keys / dashboard auth) use the Firebase HttpsError envelope instead \u2014 this contract applies to REST /v1/* only.",
4363
- "appliesTo": [
4364
- "web",
4365
- "node",
4366
- "react-native",
4367
- "swift",
4368
- "android",
4369
- "backend"
4370
- ],
4371
- "codeRef": [
4372
- "backend/src/api/v1-errors.ts",
4373
- "sdks/web/src/errors.ts",
4374
- "sdks/node/src/errors.ts",
4375
- "sdks/react-native/src/errors.ts",
4376
- "sdks/swift/Sources/Crossdeck/Errors.swift",
4377
- "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/Errors.kt"
4378
- ],
4379
- "testRef": [
4380
- {
4381
- "file": "sdks/swift/Tests/CrossdeckTests/ErrorsTests.swift",
4382
- "name": "test_errorEnvelope_fallsBackOnGarbageBody"
4383
- },
4384
- {
4385
- "file": "sdks/swift/Tests/CrossdeckTests/ErrorsTests.swift",
4386
- "name": "test_errorEnvelope_reads_XRequestId_fallback"
4387
- },
4388
- {
4389
- "file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/ErrorTypeWireVocabTest.kt",
4390
- "name": "backend 500 response parses to INTERNAL_ERROR"
4391
- }
4392
- ],
4393
- "registeredAt": "2026-05-26",
4394
- "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 8 (codifies existing contract)",
4395
- "bundledIn": "@cross-deck/node@1.5.0"
4396
- },
4397
- {
4398
- "id": "flush-interval-parity",
4399
- "pillar": "analytics",
4400
- "status": "enforced",
4401
- "claim": "Every Crossdeck SDK defaults its event-queue flush interval to 2000ms \u2014 the Stripe-adjacent industry norm. Pre-v1.4.0 the defaults disagreed (Web/Node 1500ms; RN/Swift/Android 5000ms), so cross-platform funnels saw events landing at different cadences. Per-instance override stays \u2014 call sites can still tune it freely.",
4402
- "appliesTo": [
4403
- "web",
4404
- "node",
4405
- "react-native",
4406
- "swift",
4407
- "android"
4408
- ],
4409
- "codeRef": [
4410
- "sdks/web/src/crossdeck.ts",
4411
- "sdks/node/src/crossdeck-server.ts",
4412
- "sdks/react-native/src/crossdeck.ts",
4413
- "sdks/swift/Sources/Crossdeck/EventQueue.swift",
4414
- "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/EventQueue.kt"
4415
- ],
4416
- "testRef": [
4417
- {
4418
- "file": "sdks/swift/Sources/Crossdeck/EventQueue.swift",
4419
- "name": "flushIntervalMs: Int = 2_000"
4420
- },
4421
- {
4422
- "file": "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/EventQueue.kt",
4423
- "name": "flushIntervalMs: Long = 2_000L"
4424
- },
4425
- {
4426
- "file": "sdks/web/src/crossdeck.ts",
4427
- "name": "options.eventFlushIntervalMs ?? 2000"
4428
- },
4429
- {
4430
- "file": "sdks/node/src/crossdeck-server.ts",
4431
- "name": "options.eventFlushIntervalMs ?? 2000"
4432
- },
4433
- {
4434
- "file": "sdks/react-native/src/crossdeck.ts",
4435
- "name": "options.eventFlushIntervalMs ?? 2000"
4436
- }
4437
- ],
4438
- "registeredAt": "2026-05-26",
4439
- "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 3.3",
4440
- "bundledIn": "@cross-deck/node@1.5.0"
4441
- },
4442
- {
4443
- "id": "idempotency-key-deterministic",
4444
- "pillar": "revenue",
4445
- "status": "enforced",
4446
- "claim": "syncPurchases() on every SDK derives a deterministic Idempotency-Key from the request body (UUID-shaped SHA-256 of `crossdeck:purchases/sync:<rail>:<jws|token>`). Same input -> same key. Backend short-circuits same-key-same-body retries by returning the cached response (status + body) with `idempotent_replay: true` flag in the body AND `Idempotent-Replayed: true` response header. Same-key-different-body returns 400 `idempotency_key_in_use`. 24-hour TTL matches Stripe. Cache only stores 2xx responses \u2014 4xx/5xx pass through so callers can fix bugs and retry. Helper returns nil/throws on missing identifier (no silent random fallback). Cross-SDK parity is CI-pinned: deriveForPurchase('apple', 'eyJ.jws.sig') MUST equal 'a66b1640-efaf-bb4d-1261-6650033bf111' on every SDK.",
4447
- "appliesTo": [
4448
- "web",
4449
- "node",
4450
- "react-native",
4451
- "swift",
4452
- "android",
4453
- "backend"
4454
- ],
4455
- "codeRef": [
4456
- "sdks/web/src/idempotency-key.ts",
4457
- "sdks/web/src/crossdeck.ts",
4458
- "sdks/react-native/src/idempotency-key.ts",
4459
- "sdks/react-native/src/crossdeck.ts",
4460
- "sdks/node/src/idempotency-key.ts",
4461
- "sdks/node/src/crossdeck-server.ts",
4462
- "sdks/swift/Sources/Crossdeck/IdempotencyKey.swift",
4463
- "sdks/swift/Sources/Crossdeck/Crossdeck.swift",
4464
- "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/IdempotencyKey.kt",
4465
- "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/Crossdeck.kt",
4466
- "backend/src/lib/idempotency-response-cache.ts",
4467
- "backend/src/api/v1-purchases.ts"
4468
- ],
4469
- "testRef": [
4470
- {
4471
- "file": "sdks/web/tests/idempotency-key.test.ts",
4472
- "name": "cross-SDK oracle \u2014 apple JWS pins canonical vector"
4473
- },
4474
- {
4475
- "file": "sdks/web/tests/idempotency-key.test.ts",
4476
- "name": "is deterministic: same body twice -> identical key"
4477
- },
4478
- {
4479
- "file": "sdks/web/tests/idempotency-key.test.ts",
4480
- "name": "same identifier under different rails -> different keys"
4481
- },
4482
- {
4483
- "file": "sdks/web/tests/idempotency-key.test.ts",
4484
- "name": "never silently falls back to a random key on missing identifier"
4485
- },
4486
- {
4487
- "file": "sdks/react-native/tests/idempotency-key.test.ts",
4488
- "name": "is deterministic"
4489
- },
4490
- {
4491
- "file": "sdks/react-native/tests/idempotency-key.test.ts",
4492
- "name": "cross-SDK oracle \u2014 apple JWS pins canonical vector"
4493
- },
4494
- {
4495
- "file": "sdks/node/tests/idempotency-key.test.ts",
4496
- "name": "is deterministic"
4497
- },
4498
- {
4499
- "file": "sdks/node/tests/idempotency-key.test.ts",
4500
- "name": "rail namespacing prevents cross-rail collisions"
4501
- },
4502
- {
4503
- "file": "sdks/node/tests/idempotency-key.test.ts",
4504
- "name": "apple JWS produces the canonical pinned UUID across all 5 SDKs"
4505
- },
4506
- {
4507
- "file": "backend/tests/unit/idempotency-response-cache.test.ts",
4508
- "name": "is deterministic for the same input"
4509
- },
4510
- {
4511
- "file": "backend/tests/unit/idempotency-response-cache.test.ts",
4512
- "name": "injects idempotent_replay: true into a JSON object body"
4513
- },
4514
- {
4515
- "file": "backend/tests/unit/idempotency-response-cache.test.ts",
4516
- "name": "matches Stripe's 24-hour idempotency window"
4517
- },
4518
- {
4519
- "file": "sdks/swift/Tests/CrossdeckTests/IdempotencyKeyTests.swift",
4520
- "name": "test_crossSdkOracle_appleJWS"
4521
- },
4522
- {
4523
- "file": "sdks/swift/Tests/CrossdeckTests/IdempotencyKeyTests.swift",
4524
- "name": "test_railNamespacing_preventsCrossRailCollisions"
4525
- },
4526
- {
4527
- "file": "sdks/swift/Tests/CrossdeckTests/IdempotencyKeyTests.swift",
4528
- "name": "test_missingIdentifier_returnsNil"
4529
- },
4530
- {
4531
- "file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/IdempotencyKeyTest.kt",
4532
- "name": "cross-SDK oracle for apple JWS"
4533
- },
4534
- {
4535
- "file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/IdempotencyKeyTest.kt",
4536
- "name": "rail namespacing prevents cross-rail collisions"
4537
- },
4538
- {
4539
- "file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/IdempotencyKeyTest.kt",
4540
- "name": "missing identifier returns null - never silent random fallback"
4541
- }
4542
- ],
4543
- "registeredAt": "2026-05-26",
4544
- "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 2.2.a + 2.2.b + 2.2.c",
4545
- "bundledIn": "@cross-deck/node@1.5.0"
4546
- },
4547
- {
4548
- "id": "node-pii-scrubber",
4549
- "pillar": "analytics",
4550
- "status": "enforced",
4551
- "claim": "Node SDK's track() applies scrubPiiFromProperties on the enqueue path \u2014 parity with Web/RN/Swift. Pre-v1.4.0 the Node SDK was the ONLY one that skipped this, shipping every track() payload UNREDACTED despite the README promising parity. CrossdeckServerOptions.scrubPii defaults to true; explicit false opts out for regulator-required audit trails with a documented blast-radius warning.",
4552
- "appliesTo": [
4553
- "node"
4554
- ],
4555
- "codeRef": [
4556
- "sdks/node/src/crossdeck-server.ts",
4557
- "sdks/node/src/types.ts",
4558
- "sdks/node/src/consent.ts"
4559
- ],
4560
- "testRef": [
4561
- {
4562
- "file": "sdks/node/tests/track-pii-scrub.test.ts",
4563
- "name": "by default redacts email-shaped values to <email>"
4564
- },
4565
- {
4566
- "file": "sdks/node/tests/track-pii-scrub.test.ts",
4567
- "name": "redacts card-number-shaped values to <card>"
4568
- },
4569
- {
4570
- "file": "sdks/node/tests/track-pii-scrub.test.ts",
4571
- "name": "walks nested maps + arrays"
4572
- },
4573
- {
4574
- "file": "sdks/node/tests/track-pii-scrub.test.ts",
4575
- "name": "scrubPii: false preserves the raw payload (opt-out)"
4576
- },
4577
- {
4578
- "file": "sdks/node/tests/track-pii-scrub.test.ts",
4579
- "name": "scrubPii: true is the default when option is omitted"
4580
- }
4581
- ],
4582
- "registeredAt": "2026-05-26",
4583
- "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 3.1",
4584
- "bundledIn": "@cross-deck/node@1.5.0"
4585
- },
4586
- {
4587
- "id": "node-shutdown-awaits-flush",
4588
- "pillar": "lifecycle",
4589
- "status": "enforced",
4590
- "claim": "Node SDK's async shutdown() awaits the internal flush() before tearing down the queue. A queue with pending events at sync-shutdown time (shutdownSync() or [Symbol.dispose]) logs a console.warn with the dropped-event count \u2014 silent loss is incompatible with the bank-grade contract. [Symbol.asyncDispose] is equivalent to await server.shutdown().",
4591
- "appliesTo": [
4592
- "node"
4593
- ],
4594
- "codeRef": [
4595
- "sdks/node/src/crossdeck-server.ts"
4596
- ],
4597
- "testRef": [
4598
- {
4599
- "file": "sdks/node/tests/shutdown-flush.test.ts",
4600
- "name": "async shutdown() flushes queued events before clearing"
4601
- },
4602
- {
4603
- "file": "sdks/node/tests/shutdown-flush.test.ts",
4604
- "name": "async shutdown() proceeds with teardown even if flush fails"
4605
- },
4606
- {
4607
- "file": "sdks/node/tests/shutdown-flush.test.ts",
4608
- "name": "sync shutdownSync() warns when the buffer has events at teardown"
4609
- },
4610
- {
4611
- "file": "sdks/node/tests/shutdown-flush.test.ts",
4612
- "name": "[Symbol.asyncDispose] equals await server.shutdown()"
4613
- }
4614
- ],
4615
- "registeredAt": "2026-05-26",
4616
- "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 5.4",
4617
- "bundledIn": "@cross-deck/node@1.5.0"
4618
- },
4619
- {
4620
- "id": "sdk-error-codes-catalogue",
4621
- "pillar": "errors",
4622
- "status": "enforced",
4623
- "claim": "Web + Node SDK error-codes catalogues include EVERY backend-emitted ApiErrorCode with a description + resolution. Pre-v1.4.0 the catalogues documented codes the SDK threw ITSELF but ZERO backend codes \u2014 a developer hitting `invalid_api_key` / `origin_not_allowed` / `bundle_id_not_allowed` / `env_mismatch` / `idempotency_key_in_use` etc. got `undefined` from getErrorCode() and had to hunt for guidance. v1.4.0 backfills the catalogue from backend/src/api/v1-errors.ts so every wire code has a canonical 'what does this mean / what should I do' answer Stripe-style.",
4624
- "appliesTo": [
4625
- "web",
4626
- "node"
4627
- ],
4628
- "codeRef": [
4629
- "sdks/web/src/error-codes.ts",
4630
- "sdks/node/src/error-codes.ts",
4631
- "backend/src/api/v1-errors.ts"
4632
- ],
4633
- "testRef": [
4634
- {
4635
- "file": "sdks/web/tests/error-codes-backfill.test.ts",
4636
- "name": "includes backend code"
4637
- },
4638
- {
4639
- "file": "sdks/web/tests/error-codes-backfill.test.ts",
4640
- "name": "invalid_api_key resolution points at the dashboard"
4641
- },
4642
- {
4643
- "file": "sdks/web/tests/error-codes-backfill.test.ts",
4644
- "name": "idempotency_key_in_use resolution mentions Stripe-grade contract"
4645
- },
4646
- {
4647
- "file": "sdks/web/tests/error-codes-backfill.test.ts",
4648
- "name": "identity-lock codes carry permission_error type"
4649
- },
4650
- {
4651
- "file": "sdks/web/tests/error-codes-backfill.test.ts",
4652
- "name": "no entry has an empty description or resolution"
4653
- }
4654
- ],
4655
- "registeredAt": "2026-05-26",
4656
- "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 6.2",
4657
- "bundledIn": "@cross-deck/node@1.5.0"
4658
- },
4659
- {
4660
- "id": "sync-purchases-funnel-parity",
4661
- "pillar": "analytics",
4662
- "status": "enforced",
4663
- "claim": "Manual syncPurchases() emits a `purchase.completed` analytics event on success across ALL SDKs (Web / Node / RN / Swift / Android). Pre-v1.4.0 only Swift/Android auto-track emitted it \u2014 Web/Node/RN manual calls + Swift/Android manual calls fired ZERO analytics. Schema mirrors the auto-track event name + rail/productId/subscriptionId so cross-platform funnels reconcile on every payment path. When the backend short-circuits via the v1.4.0 idempotency cache, the event also carries `idempotent_replay: true`.",
4664
- "appliesTo": [
4665
- "web",
4666
- "node",
4667
- "react-native",
4668
- "swift",
4669
- "android"
4670
- ],
4671
- "codeRef": [
4672
- "sdks/web/src/crossdeck.ts",
4673
- "sdks/node/src/crossdeck-server.ts",
4674
- "sdks/react-native/src/crossdeck.ts",
4675
- "sdks/swift/Sources/Crossdeck/Crossdeck.swift",
4676
- "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/Crossdeck.kt"
4677
- ],
4678
- "testRef": [
4679
- {
4680
- "file": "sdks/web/tests/sync-purchases-funnel.test.ts",
4681
- "name": "emits purchase.completed after a successful sync"
4682
- },
4683
- {
4684
- "file": "sdks/web/tests/sync-purchases-funnel.test.ts",
4685
- "name": "carries idempotent_replay=true when backend replied from cache"
4686
- }
4687
- ],
4688
- "registeredAt": "2026-05-26",
4689
- "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 3.5",
4690
- "bundledIn": "@cross-deck/node@1.5.0"
4691
- },
4692
- {
4693
- "id": "verifier-timestamp-mandatory",
4694
- "pillar": "webhooks",
4695
- "status": "enforced",
4696
- "claim": "Node verifyWebhookSignature() enforces a MANDATORY timestamp window. Pre-v1.4.0 the helper silently disabled replay protection on tolerance=0 (`if (tolerance > 0)` skipped the check) and on Infinity/NaN/null (`Math.abs(...) > Infinity = false`). v1.4.0 rejects non-finite / negative / above-24h-cap tolerances at the boundary with typed `webhook_invalid_tolerance` and always runs the drift check. Verification failures are surfaced via distinguishable codes: `webhook_signature_mismatch` (wrong-secret signal), `webhook_timestamp_outside_tolerance` (replay-attack signal \u2014 alert separately), `webhook_timestamp_missing` (header absent/malformed), `webhook_payload_not_json` (tampered post-signing), `webhook_missing_secret`, `webhook_invalid_tolerance` \u2014 replaces the pre-1.4.0 single `webhook_invalid_signature` catch-all.",
4697
- "appliesTo": [
4698
- "node"
4699
- ],
4700
- "codeRef": [
4701
- "sdks/node/src/webhooks.ts",
4702
- "sdks/node/src/error-codes.ts"
4703
- ],
4704
- "testRef": [
4705
- {
4706
- "file": "sdks/node/tests/webhooks.test.ts",
4707
- "name": "tolerance of 0 still enforces the replay window (v1.4.0 \u2014 cannot disable)"
4708
- },
4709
- {
4710
- "file": "sdks/node/tests/webhooks.test.ts",
4711
- "name": "rejects Infinity tolerance (would silently disable replay protection)"
4712
- },
4713
- {
4714
- "file": "sdks/node/tests/webhooks.test.ts",
4715
- "name": "rejects NaN tolerance"
4716
- },
4717
- {
4718
- "file": "sdks/node/tests/webhooks.test.ts",
4719
- "name": "rejects negative tolerance"
4720
- },
4721
- {
4722
- "file": "sdks/node/tests/webhooks.test.ts",
4723
- "name": "rejects tolerance above the 24h cap"
4724
- },
4725
- {
4726
- "file": "sdks/node/tests/webhooks.test.ts",
4727
- "name": "rejects non-number tolerance (null / string)"
4728
- },
4729
- {
4730
- "file": "sdks/node/tests/webhooks.test.ts",
4731
- "name": "accepts tolerance exactly at the 24h cap"
4732
- },
4733
- {
4734
- "file": "sdks/node/tests/webhooks.test.ts",
4735
- "name": "malformed header (no t= or no v1=) throws webhook_timestamp_missing"
4736
- },
4737
- {
4738
- "file": "sdks/node/tests/webhooks.test.ts",
4739
- "name": "valid signature but non-JSON payload throws webhook_payload_not_json"
4740
- }
4741
- ],
4742
- "registeredAt": "2026-05-26",
4743
- "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 7.2",
4744
- "bundledIn": "@cross-deck/node@1.5.0"
4015
+ // src/consent.ts
4016
+ var EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
4017
+ var CARD_PATTERN = /\b\d(?:[ -]?\d){12,18}\b/g;
4018
+ var REPLACEMENT_EMAIL = "<email>";
4019
+ var REPLACEMENT_CARD = "<card>";
4020
+ function scrubPii(value) {
4021
+ if (!value) return value;
4022
+ return value.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL).replace(CARD_PATTERN, REPLACEMENT_CARD);
4023
+ }
4024
+ function scrubPiiFromProperties(properties) {
4025
+ const out = {};
4026
+ for (const k of Object.keys(properties)) {
4027
+ out[k] = scrubValue(properties[k]);
4745
4028
  }
4746
- ]);
4747
-
4748
- // src/contracts.ts
4749
- var CrossdeckContracts = {
4750
- all() {
4751
- return BUNDLED_CONTRACTS.filter((c) => c.status === "enforced");
4752
- },
4753
- allIncludingHistorical() {
4754
- return BUNDLED_CONTRACTS;
4755
- },
4756
- byId(id) {
4757
- return BUNDLED_CONTRACTS.find((c) => c.id === id);
4758
- },
4759
- byPillar(pillar) {
4760
- return BUNDLED_CONTRACTS.filter(
4761
- (c) => c.pillar === pillar && c.status === "enforced"
4762
- );
4763
- },
4764
- withStatus(status) {
4765
- return BUNDLED_CONTRACTS.filter((c) => c.status === status);
4766
- },
4767
- sdkVersion: SDK_VERSION2,
4768
- bundledIn: BUNDLED_IN,
4769
- /**
4770
- * Resolve a failing test back to the contract it exercises.
4771
- * Used by test-framework hooks to find the contract id of a
4772
- * failed contract test so `reportContractFailure(...)` can stamp
4773
- * the right `contract_id` on the emitted event.
4774
- */
4775
- findByTestName(name) {
4776
- return BUNDLED_CONTRACTS.find(
4777
- (c) => c.testRef.some((ref) => ref.name === name)
4778
- );
4029
+ return out;
4030
+ }
4031
+ function scrubValue(v) {
4032
+ if (typeof v === "string") return scrubPii(v);
4033
+ if (Array.isArray(v)) return v.map(scrubValue);
4034
+ if (v && typeof v === "object" && v.constructor === Object) {
4035
+ return scrubPiiFromProperties(v);
4779
4036
  }
4780
- };
4037
+ return v;
4038
+ }
4781
4039
  export {
4782
4040
  CROSSDECK_API_VERSION,
4783
4041
  CROSSDECK_ERROR_CODES,
4784
4042
  CrossdeckAuthenticationError,
4785
4043
  CrossdeckConfigurationError,
4786
- CrossdeckContracts,
4787
4044
  CrossdeckError,
4788
4045
  CrossdeckInternalError,
4789
4046
  CrossdeckNetworkError,