@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/CHANGELOG.md +53 -0
- package/README.md +58 -0
- package/dist/auto-events/index.d.mts +1 -1
- package/dist/auto-events/index.d.ts +1 -1
- package/dist/{crossdeck-server-oAaKBnUU.d.mts → crossdeck-server-DhnHvUhh.d.mts} +12 -183
- package/dist/{crossdeck-server-oAaKBnUU.d.ts → crossdeck-server-DhnHvUhh.d.ts} +12 -183
- package/dist/index.cjs +73 -817
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +21 -150
- package/dist/index.d.ts +21 -150
- package/dist/index.mjs +69 -812
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/dist/contracts.json +0 -430
package/dist/index.cjs
CHANGED
|
@@ -24,7 +24,6 @@ __export(index_exports, {
|
|
|
24
24
|
CROSSDECK_ERROR_CODES: () => CROSSDECK_ERROR_CODES,
|
|
25
25
|
CrossdeckAuthenticationError: () => CrossdeckAuthenticationError,
|
|
26
26
|
CrossdeckConfigurationError: () => CrossdeckConfigurationError,
|
|
27
|
-
CrossdeckContracts: () => CrossdeckContracts,
|
|
28
27
|
CrossdeckError: () => CrossdeckError,
|
|
29
28
|
CrossdeckInternalError: () => CrossdeckInternalError,
|
|
30
29
|
CrossdeckNetworkError: () => CrossdeckNetworkError,
|
|
@@ -365,7 +364,7 @@ function byteLength(s) {
|
|
|
365
364
|
}
|
|
366
365
|
|
|
367
366
|
// src/_version.ts
|
|
368
|
-
var SDK_VERSION = "1.
|
|
367
|
+
var SDK_VERSION = "1.3.1";
|
|
369
368
|
var SDK_NAME = "@cross-deck/node";
|
|
370
369
|
|
|
371
370
|
// src/http.ts
|
|
@@ -2397,63 +2396,6 @@ var EntitlementCache = class {
|
|
|
2397
2396
|
}
|
|
2398
2397
|
};
|
|
2399
2398
|
|
|
2400
|
-
// src/consent.ts
|
|
2401
|
-
var EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
|
|
2402
|
-
var CARD_PATTERN = /\b\d(?:[ -]?\d){12,18}\b/g;
|
|
2403
|
-
var REPLACEMENT_EMAIL = "<email>";
|
|
2404
|
-
var REPLACEMENT_CARD = "<card>";
|
|
2405
|
-
function scrubPii(value) {
|
|
2406
|
-
if (!value) return value;
|
|
2407
|
-
return value.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL).replace(CARD_PATTERN, REPLACEMENT_CARD);
|
|
2408
|
-
}
|
|
2409
|
-
function scrubPiiFromProperties(properties) {
|
|
2410
|
-
const out = {};
|
|
2411
|
-
for (const k of Object.keys(properties)) {
|
|
2412
|
-
out[k] = scrubValue(properties[k]);
|
|
2413
|
-
}
|
|
2414
|
-
return out;
|
|
2415
|
-
}
|
|
2416
|
-
function scrubValue(v) {
|
|
2417
|
-
if (typeof v === "string") return scrubPii(v);
|
|
2418
|
-
if (Array.isArray(v)) return v.map(scrubValue);
|
|
2419
|
-
if (v && typeof v === "object" && v.constructor === Object) {
|
|
2420
|
-
return scrubPiiFromProperties(v);
|
|
2421
|
-
}
|
|
2422
|
-
return v;
|
|
2423
|
-
}
|
|
2424
|
-
|
|
2425
|
-
// src/idempotency-key.ts
|
|
2426
|
-
var import_node_crypto = require("crypto");
|
|
2427
|
-
function formatAsUuid(hex) {
|
|
2428
|
-
return [
|
|
2429
|
-
hex.slice(0, 8),
|
|
2430
|
-
hex.slice(8, 12),
|
|
2431
|
-
hex.slice(12, 16),
|
|
2432
|
-
hex.slice(16, 20),
|
|
2433
|
-
hex.slice(20, 32)
|
|
2434
|
-
].join("-");
|
|
2435
|
-
}
|
|
2436
|
-
function sha256Hex(input) {
|
|
2437
|
-
return (0, import_node_crypto.createHash)("sha256").update(input, "utf8").digest("hex");
|
|
2438
|
-
}
|
|
2439
|
-
function deriveIdempotencyKeyForPurchase(body) {
|
|
2440
|
-
let identifier;
|
|
2441
|
-
if (body.rail === "apple") {
|
|
2442
|
-
identifier = body.signedTransactionInfo ?? "";
|
|
2443
|
-
} else if (body.rail === "google") {
|
|
2444
|
-
identifier = body.purchaseToken ?? "";
|
|
2445
|
-
} else {
|
|
2446
|
-
identifier = "";
|
|
2447
|
-
}
|
|
2448
|
-
if (!identifier) {
|
|
2449
|
-
throw new Error(
|
|
2450
|
-
`deriveIdempotencyKeyForPurchase: no stable identifier in body (rail=${body.rail}). Apple needs signedTransactionInfo; Google needs purchaseToken.`
|
|
2451
|
-
);
|
|
2452
|
-
}
|
|
2453
|
-
const namespaced = `crossdeck:purchases/sync:${body.rail}:${identifier}`;
|
|
2454
|
-
return formatAsUuid(sha256Hex(namespaced));
|
|
2455
|
-
}
|
|
2456
|
-
|
|
2457
2399
|
// src/debug.ts
|
|
2458
2400
|
var SENSITIVE_KEY_PATTERNS = [
|
|
2459
2401
|
/^email$/i,
|
|
@@ -2513,10 +2455,6 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
2513
2455
|
baseUrl;
|
|
2514
2456
|
appId;
|
|
2515
2457
|
env;
|
|
2516
|
-
/** PII scrubber toggle. Default true — parity with Web/RN/Swift.
|
|
2517
|
-
* Pre-v1.4.0 the Node SDK shipped track() payloads UNREDACTED,
|
|
2518
|
-
* a privacy contract drift versus the README. */
|
|
2519
|
-
scrubPii;
|
|
2520
2458
|
secretKeyPrefix;
|
|
2521
2459
|
/**
|
|
2522
2460
|
* Process-stable pseudo-anonymous ID. Used as the default identity
|
|
@@ -2562,15 +2500,6 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
2562
2500
|
errorContext = {};
|
|
2563
2501
|
errorTags = {};
|
|
2564
2502
|
errorBeforeSend = null;
|
|
2565
|
-
/**
|
|
2566
|
-
* Dedup gate for `sdk.shutdown`. Both `shutdown()` (async) and
|
|
2567
|
-
* `shutdownSync()` need to emit so direct callers of EITHER see
|
|
2568
|
-
* the event (the async path's listener guarantees pre-launch
|
|
2569
|
-
* tests, the sync path covers `Symbol.dispose` + tests that call
|
|
2570
|
-
* `shutdownSync()` directly). Without this flag, `shutdown()`'s
|
|
2571
|
-
* tail call into `shutdownSync()` would emit twice.
|
|
2572
|
-
*/
|
|
2573
|
-
didEmitShutdown = false;
|
|
2574
2503
|
constructor(options) {
|
|
2575
2504
|
super();
|
|
2576
2505
|
if (!options.secretKey || !options.secretKey.startsWith("cd_sk_")) {
|
|
@@ -2585,7 +2514,6 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
2585
2514
|
this.baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
|
|
2586
2515
|
this.env = inferEnvFromKey(options.secretKey);
|
|
2587
2516
|
this.secretKeyPrefix = maskSecretKey(options.secretKey);
|
|
2588
|
-
this.scrubPii = options.scrubPii !== false;
|
|
2589
2517
|
this.http = new HttpClient({
|
|
2590
2518
|
secretKey: options.secretKey,
|
|
2591
2519
|
baseUrl: this.baseUrl,
|
|
@@ -2625,9 +2553,7 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
2625
2553
|
this.eventQueue = new EventQueue({
|
|
2626
2554
|
http: this.http,
|
|
2627
2555
|
batchSize: options.eventFlushBatchSize ?? 20,
|
|
2628
|
-
|
|
2629
|
-
// across every SDK. Per-instance override stays.
|
|
2630
|
-
intervalMs: options.eventFlushIntervalMs ?? 2e3,
|
|
2556
|
+
intervalMs: options.eventFlushIntervalMs ?? 1500,
|
|
2631
2557
|
envelope: () => ({
|
|
2632
2558
|
appId: this.appId,
|
|
2633
2559
|
// Ship env on every batch so the backend can cross-check
|
|
@@ -3006,38 +2932,6 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
3006
2932
|
* `uncaughtException` has no per-request context; without the
|
|
3007
2933
|
* auto-fill, the event would be rejected at queue enqueue.
|
|
3008
2934
|
*/
|
|
3009
|
-
/**
|
|
3010
|
-
* Emit `crossdeck.contract_failed` with the canonical property
|
|
3011
|
-
* shape. Same wire shape every Crossdeck SDK uses for contract
|
|
3012
|
-
* verification telemetry — see `contracts/README.md` for the
|
|
3013
|
-
* full pattern. No new endpoint, no special path; goes through
|
|
3014
|
-
* the standard server-side `track()` pipeline.
|
|
3015
|
-
*/
|
|
3016
|
-
reportContractFailure(input) {
|
|
3017
|
-
const props = {
|
|
3018
|
-
contract_id: input.contractId,
|
|
3019
|
-
sdk_version: SDK_VERSION,
|
|
3020
|
-
sdk_platform: "node",
|
|
3021
|
-
failure_reason: input.failureReason,
|
|
3022
|
-
run_context: input.runContext,
|
|
3023
|
-
run_id: input.runId
|
|
3024
|
-
};
|
|
3025
|
-
if (input.testRef) {
|
|
3026
|
-
props.test_file = input.testRef.file;
|
|
3027
|
-
props.test_name = input.testRef.name;
|
|
3028
|
-
}
|
|
3029
|
-
if (input.extra) {
|
|
3030
|
-
for (const [k, v] of Object.entries(input.extra)) {
|
|
3031
|
-
if (props[k] === void 0) props[k] = v;
|
|
3032
|
-
}
|
|
3033
|
-
}
|
|
3034
|
-
this.track({
|
|
3035
|
-
name: "crossdeck.contract_failed",
|
|
3036
|
-
properties: props
|
|
3037
|
-
// No identity hint — these events are about the SDK / dogfood
|
|
3038
|
-
// run itself, not a specific end-user.
|
|
3039
|
-
});
|
|
3040
|
-
}
|
|
3041
2935
|
track(event) {
|
|
3042
2936
|
if (!event.name) {
|
|
3043
2937
|
throw new CrossdeckError({
|
|
@@ -3046,8 +2940,7 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
3046
2940
|
message: "track(event) requires a non-empty event.name."
|
|
3047
2941
|
});
|
|
3048
2942
|
}
|
|
3049
|
-
const
|
|
3050
|
-
const sanitized = this.scrubPii ? scrubPiiFromProperties(validated) : validated;
|
|
2943
|
+
const sanitized = sanitizePropertyBag(event.properties, "event properties") ?? {};
|
|
3051
2944
|
if (this.debug.enabled) {
|
|
3052
2945
|
const flagged = findSensitivePropertyKeys(sanitized);
|
|
3053
2946
|
if (flagged.length > 0) {
|
|
@@ -3183,25 +3076,11 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
3183
3076
|
});
|
|
3184
3077
|
}
|
|
3185
3078
|
const rail = input.rail ?? "apple";
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
const result = await this.http.request("POST", "/purchases/sync", {
|
|
3189
|
-
body,
|
|
3190
|
-
idempotencyKey,
|
|
3079
|
+
return this.http.request("POST", "/purchases/sync", {
|
|
3080
|
+
body: { ...input, rail },
|
|
3191
3081
|
signal: options?.signal,
|
|
3192
3082
|
timeoutMs: options?.timeoutMs
|
|
3193
3083
|
});
|
|
3194
|
-
try {
|
|
3195
|
-
const sourceProductId = result.entitlements[0]?.source.productId;
|
|
3196
|
-
const sourceSubscriptionId = result.entitlements[0]?.source.subscriptionId;
|
|
3197
|
-
const props = { rail };
|
|
3198
|
-
if (sourceProductId) props.productId = sourceProductId;
|
|
3199
|
-
if (sourceSubscriptionId) props.subscriptionId = sourceSubscriptionId;
|
|
3200
|
-
if (result.idempotent_replay) props.idempotent_replay = true;
|
|
3201
|
-
this.track({ name: "purchase.completed", properties: props });
|
|
3202
|
-
} catch {
|
|
3203
|
-
}
|
|
3204
|
-
return result;
|
|
3205
3084
|
}
|
|
3206
3085
|
// ============================================================
|
|
3207
3086
|
// Manual entitlement controls + audit — direct HTTP
|
|
@@ -3493,56 +3372,12 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
3493
3372
|
};
|
|
3494
3373
|
}
|
|
3495
3374
|
/**
|
|
3496
|
-
* Tear down handlers and clear in-memory state.
|
|
3497
|
-
*
|
|
3498
|
-
*
|
|
3499
|
-
* before dropping the queue, so callers don't silently lose
|
|
3500
|
-
* every queued event on a clean shutdown. The pre-v1.4.0
|
|
3501
|
-
* behaviour (sync `eventQueue.reset()` with no flush) was the
|
|
3502
|
-
* default for both `shutdown()` and `[Symbol.dispose]`; only
|
|
3503
|
-
* `await using` + `[Symbol.asyncDispose]` flushed correctly.
|
|
3504
|
-
*
|
|
3505
|
-
* Production servers should still prefer `await server.flush()`
|
|
3506
|
-
* (visible) followed by `server.shutdown()` so the flush
|
|
3507
|
-
* outcome is observable — `shutdown()`'s internal flush swallows
|
|
3508
|
-
* errors as a best-effort drain.
|
|
3509
|
-
*
|
|
3510
|
-
* Use [[shutdownSync]] only when the runtime cannot await
|
|
3511
|
-
* (e.g. inside `Symbol.dispose` — see below).
|
|
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.
|
|
3512
3378
|
*/
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
this.emit("sdk.shutdown", { reason });
|
|
3516
|
-
this.didEmitShutdown = true;
|
|
3517
|
-
}
|
|
3518
|
-
try {
|
|
3519
|
-
await this.flush();
|
|
3520
|
-
} catch {
|
|
3521
|
-
}
|
|
3522
|
-
this.shutdownSync(reason);
|
|
3523
|
-
}
|
|
3524
|
-
/**
|
|
3525
|
-
* Synchronous teardown — drops the in-memory queue WITHOUT
|
|
3526
|
-
* flushing, then clears all in-memory state. Used by
|
|
3527
|
-
* `[Symbol.dispose]` (which has no await) and tests that need
|
|
3528
|
-
* an unconditional sync wipe. Production code should use
|
|
3529
|
-
* [[shutdown]] (async) instead so queued events are flushed.
|
|
3530
|
-
*
|
|
3531
|
-
* A queue with items at sync-shutdown logs a warning recommending
|
|
3532
|
-
* `[Symbol.asyncDispose]` or `await server.shutdown()` — silent
|
|
3533
|
-
* loss is incompatible with the bank-grade contract.
|
|
3534
|
-
*/
|
|
3535
|
-
shutdownSync(reason = "shutdown") {
|
|
3536
|
-
if (!this.didEmitShutdown) {
|
|
3537
|
-
this.emit("sdk.shutdown", { reason });
|
|
3538
|
-
this.didEmitShutdown = true;
|
|
3539
|
-
}
|
|
3540
|
-
const queuedCount = this.eventQueue.getStats().buffered;
|
|
3541
|
-
if (queuedCount > 0 && reason !== "asyncDispose") {
|
|
3542
|
-
console.warn(
|
|
3543
|
-
`[crossdeck] shutdownSync() dropped ${queuedCount} queued event(s) without flushing. Use \`await server.shutdown()\` or \`await using server = ...\` with \`[Symbol.asyncDispose]\` to drain the buffer before teardown.`
|
|
3544
|
-
);
|
|
3545
|
-
}
|
|
3379
|
+
shutdown(reason = "shutdown") {
|
|
3380
|
+
this.emit("sdk.shutdown", { reason });
|
|
3546
3381
|
this.errorTracker?.uninstall();
|
|
3547
3382
|
this.flushOnExit?.uninstall();
|
|
3548
3383
|
this.eventQueue.reset();
|
|
@@ -3656,28 +3491,28 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
|
|
|
3656
3491
|
* // ... use server ...
|
|
3657
3492
|
* // at end of block, server[Symbol.dispose]() runs automatically
|
|
3658
3493
|
*
|
|
3659
|
-
*
|
|
3660
|
-
*
|
|
3661
|
-
*
|
|
3662
|
-
*
|
|
3663
|
-
* exit", switch to `await using` + `[Symbol.asyncDispose]` (or
|
|
3664
|
-
* call `await server.shutdown()` explicitly before the variable
|
|
3665
|
-
* goes out of scope).
|
|
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).
|
|
3666
3498
|
*/
|
|
3667
3499
|
[Symbol.dispose]() {
|
|
3668
|
-
this.
|
|
3500
|
+
this.shutdown("dispose");
|
|
3669
3501
|
}
|
|
3670
3502
|
/**
|
|
3671
3503
|
* Async disposal hook — runs when an `await using` declaration
|
|
3672
|
-
* exits scope. Awaits
|
|
3673
|
-
*
|
|
3674
|
-
*
|
|
3675
|
-
* background workers, end-of-request hooks).
|
|
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).
|
|
3676
3507
|
*
|
|
3677
3508
|
* await using server = new CrossdeckServer({ ... });
|
|
3678
3509
|
*/
|
|
3679
3510
|
async [Symbol.asyncDispose]() {
|
|
3680
|
-
|
|
3511
|
+
try {
|
|
3512
|
+
await this.flush();
|
|
3513
|
+
} catch {
|
|
3514
|
+
}
|
|
3515
|
+
this.shutdown("asyncDispose");
|
|
3681
3516
|
}
|
|
3682
3517
|
// ============================================================
|
|
3683
3518
|
reportCapturedError(captured) {
|
|
@@ -4097,43 +3932,18 @@ var _CROSSDECK_ERROR_CODES = Object.freeze([
|
|
|
4097
3932
|
retryable: false
|
|
4098
3933
|
},
|
|
4099
3934
|
// ----- Webhook verification (Node-specific) -----
|
|
4100
|
-
// v1.4.0 Phase 7.2 — distinguishable codes. Pre-v1.4.0 the
|
|
4101
|
-
// helper used webhook_invalid_signature for nearly every failure
|
|
4102
|
-
// mode so a customer couldn't separate replay-attack signals
|
|
4103
|
-
// from wrong-secret signals in alerting.
|
|
4104
3935
|
{
|
|
4105
|
-
code: "
|
|
4106
|
-
type: "authentication_error",
|
|
4107
|
-
description: "Webhook HMAC didn't verify against any configured secret (wrong-secret / stale rotation signal).",
|
|
4108
|
-
resolution: "Confirm the secret matches dashboard \u2192 Webhooks. If you rotated, include both the old and new secret as an array until receivers cut over.",
|
|
4109
|
-
retryable: false
|
|
4110
|
-
},
|
|
4111
|
-
{
|
|
4112
|
-
code: "webhook_timestamp_outside_tolerance",
|
|
4113
|
-
type: "authentication_error",
|
|
4114
|
-
description: "Webhook timestamp drift exceeds the configured replay-tolerance window (default 5 minutes; replay-attack signal).",
|
|
4115
|
-
resolution: "Verify NTP on the receiving host. A spike on this code warrants its own alert separate from signature_mismatch \u2014 replay attacks look like this.",
|
|
4116
|
-
retryable: false
|
|
4117
|
-
},
|
|
4118
|
-
{
|
|
4119
|
-
code: "webhook_timestamp_missing",
|
|
3936
|
+
code: "webhook_invalid_signature",
|
|
4120
3937
|
type: "authentication_error",
|
|
4121
|
-
description: "
|
|
4122
|
-
resolution: "Confirm the
|
|
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.",
|
|
4123
3940
|
retryable: false
|
|
4124
3941
|
},
|
|
4125
3942
|
{
|
|
4126
|
-
code: "
|
|
3943
|
+
code: "webhook_replay_window_exceeded",
|
|
4127
3944
|
type: "authentication_error",
|
|
4128
|
-
description: "
|
|
4129
|
-
resolution: "
|
|
4130
|
-
retryable: false
|
|
4131
|
-
},
|
|
4132
|
-
{
|
|
4133
|
-
code: "webhook_invalid_tolerance",
|
|
4134
|
-
type: "configuration_error",
|
|
4135
|
-
description: "verifyWebhookSignature() called with a non-finite / negative / above-24h-cap replayToleranceMs (would silently disable replay protection).",
|
|
4136
|
-
resolution: "Pass a finite number between 0 and 86_400_000ms (24h). Default (5 minutes) is correct for almost every scenario. Pre-v1.4.0 accepted Infinity/NaN and silently dropped the check.",
|
|
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.",
|
|
4137
3947
|
retryable: false
|
|
4138
3948
|
},
|
|
4139
3949
|
{
|
|
@@ -4142,101 +3952,6 @@ var _CROSSDECK_ERROR_CODES = Object.freeze([
|
|
|
4142
3952
|
description: "verifyWebhookSignature() was called without a signing secret.",
|
|
4143
3953
|
resolution: "Pass the secret from your Crossdeck dashboard \u2192 Webhooks page. Never hardcode in source \u2014 read from an env var.",
|
|
4144
3954
|
retryable: false
|
|
4145
|
-
},
|
|
4146
|
-
{
|
|
4147
|
-
code: "webhook_invalid_signature",
|
|
4148
|
-
type: "authentication_error",
|
|
4149
|
-
description: "DEPRECATED in v1.4.0 \u2014 split into webhook_signature_mismatch / webhook_timestamp_missing / webhook_timestamp_outside_tolerance / webhook_payload_not_json for alerting clarity.",
|
|
4150
|
-
resolution: "Migrate alert rules to the more specific v1.4.0 codes \u2014 they distinguish replay-attack signals from wrong-secret signals.",
|
|
4151
|
-
retryable: false
|
|
4152
|
-
},
|
|
4153
|
-
{
|
|
4154
|
-
code: "webhook_replay_window_exceeded",
|
|
4155
|
-
type: "authentication_error",
|
|
4156
|
-
description: "DEPRECATED in v1.4.0 \u2014 renamed to webhook_timestamp_outside_tolerance.",
|
|
4157
|
-
resolution: "Update alerts to webhook_timestamp_outside_tolerance.",
|
|
4158
|
-
retryable: false
|
|
4159
|
-
},
|
|
4160
|
-
// ----- Backend-emitted codes (v1.4.0 Phase 6.2 backfill) -----
|
|
4161
|
-
// Mirror of backend/src/api/v1-errors.ts ApiErrorCode. Same set
|
|
4162
|
-
// as the Web SDK ships — keep these synchronised so a developer
|
|
4163
|
-
// hitting any code via either SDK gets the same remediation.
|
|
4164
|
-
{
|
|
4165
|
-
code: "missing_api_key",
|
|
4166
|
-
type: "authentication_error",
|
|
4167
|
-
description: "No Authorization header (or Crossdeck-Api-Key header) on the request.",
|
|
4168
|
-
resolution: "Confirm the CrossdeckServer was constructed with a cd_sk_\u2026 secretKey. Re-check env vars in production deployments.",
|
|
4169
|
-
retryable: false
|
|
4170
|
-
},
|
|
4171
|
-
{
|
|
4172
|
-
code: "invalid_api_key",
|
|
4173
|
-
type: "authentication_error",
|
|
4174
|
-
description: "The secret key is malformed, unknown, or doesn't resolve to a project.",
|
|
4175
|
-
resolution: "Copy the key from Crossdeck dashboard \u2192 API keys. Server SDK requires cd_sk_test_ / cd_sk_live_ \u2014 client SDK keys (cd_pub_\u2026) won't work on the Node SDK.",
|
|
4176
|
-
retryable: false
|
|
4177
|
-
},
|
|
4178
|
-
{
|
|
4179
|
-
code: "key_revoked",
|
|
4180
|
-
type: "authentication_error",
|
|
4181
|
-
description: "The secret key was revoked in the dashboard.",
|
|
4182
|
-
resolution: "Mint a fresh key in dashboard \u2192 API keys \u2192 Create new. The revoked key cannot be reactivated.",
|
|
4183
|
-
retryable: false
|
|
4184
|
-
},
|
|
4185
|
-
{
|
|
4186
|
-
code: "env_mismatch",
|
|
4187
|
-
type: "permission_error",
|
|
4188
|
-
description: "The key's env prefix doesn't match the resolved app's configured env.",
|
|
4189
|
-
resolution: "Use a cd_sk_live_ key with a production app, cd_sk_test_ with a sandbox app. Crossing breaks the env lock.",
|
|
4190
|
-
retryable: false
|
|
4191
|
-
},
|
|
4192
|
-
{
|
|
4193
|
-
code: "idempotency_key_in_use",
|
|
4194
|
-
type: "invalid_request_error",
|
|
4195
|
-
description: "An Idempotency-Key was reused for a request with a different body (Stripe-grade contract).",
|
|
4196
|
-
resolution: "Server SDK derives keys deterministically from the body since v1.4.0; this should only fire if you passed options.idempotencyKey explicitly. Use a fresh key per logical operation.",
|
|
4197
|
-
retryable: false
|
|
4198
|
-
},
|
|
4199
|
-
{
|
|
4200
|
-
code: "rate_limited",
|
|
4201
|
-
type: "rate_limit_error",
|
|
4202
|
-
description: "Request rate exceeded the project's per-second cap.",
|
|
4203
|
-
resolution: "Honour Retry-After (managed retries do this automatically). For custom paths, throttle to <100 req/s/key.",
|
|
4204
|
-
retryable: true
|
|
4205
|
-
},
|
|
4206
|
-
{
|
|
4207
|
-
code: "internal_error",
|
|
4208
|
-
type: "internal_error",
|
|
4209
|
-
description: "Server-side issue. Safe to retry with backoff.",
|
|
4210
|
-
resolution: "Managed retries handle this automatically. If a code path surfaces it to your code, contact support with the requestId.",
|
|
4211
|
-
retryable: true
|
|
4212
|
-
},
|
|
4213
|
-
{
|
|
4214
|
-
code: "google_not_supported",
|
|
4215
|
-
type: "invalid_request_error",
|
|
4216
|
-
description: "POST /purchases/sync with rail=google is gated until the Play Developer API reconciliation worker ships.",
|
|
4217
|
-
resolution: "Until v1.5+, Google Play purchases verify via Real-time Developer Notifications. The Android SDK auto-track path handles this transparently.",
|
|
4218
|
-
retryable: false
|
|
4219
|
-
},
|
|
4220
|
-
{
|
|
4221
|
-
code: "stripe_not_supported",
|
|
4222
|
-
type: "invalid_request_error",
|
|
4223
|
-
description: "POST /purchases/sync with rail=stripe is unsupported \u2014 Stripe webhooks deliver evidence server-side.",
|
|
4224
|
-
resolution: "Wire Stripe via the standard Checkout / Customer Portal flow; Crossdeck reconciles via the platform webhook automatically.",
|
|
4225
|
-
retryable: false
|
|
4226
|
-
},
|
|
4227
|
-
{
|
|
4228
|
-
code: "missing_required_param",
|
|
4229
|
-
type: "invalid_request_error",
|
|
4230
|
-
description: "A required field is absent from the request body.",
|
|
4231
|
-
resolution: "The error.message identifies the missing field. Refer to the SDK's TypeScript types for canonical shapes.",
|
|
4232
|
-
retryable: false
|
|
4233
|
-
},
|
|
4234
|
-
{
|
|
4235
|
-
code: "invalid_param_value",
|
|
4236
|
-
type: "invalid_request_error",
|
|
4237
|
-
description: "A field is present but the value failed validation.",
|
|
4238
|
-
resolution: "Read error.message for the field + reason. SDK-managed call sites should never emit this \u2014 file a bug if you do.",
|
|
4239
|
-
retryable: false
|
|
4240
3955
|
}
|
|
4241
3956
|
]);
|
|
4242
3957
|
function isCrossdeckErrorCode(code) {
|
|
@@ -4248,9 +3963,8 @@ function getErrorCode(code) {
|
|
|
4248
3963
|
}
|
|
4249
3964
|
|
|
4250
3965
|
// src/webhooks.ts
|
|
4251
|
-
var
|
|
3966
|
+
var import_node_crypto = require("crypto");
|
|
4252
3967
|
var DEFAULT_REPLAY_TOLERANCE_MS = 5 * 60 * 1e3;
|
|
4253
|
-
var MAX_REPLAY_TOLERANCE_MS = 24 * 60 * 60 * 1e3;
|
|
4254
3968
|
function verifyWebhookSignature(payload, signatureHeader, secret, options = {}) {
|
|
4255
3969
|
const secrets = normaliseSecrets(secret);
|
|
4256
3970
|
if (secrets.length === 0) {
|
|
@@ -4260,68 +3974,46 @@ function verifyWebhookSignature(payload, signatureHeader, secret, options = {})
|
|
|
4260
3974
|
message: "verifyWebhookSignature requires a non-empty secret. Read it from process.env.CROSSDECK_WEBHOOK_SECRET \u2014 never hardcode in source."
|
|
4261
3975
|
});
|
|
4262
3976
|
}
|
|
4263
|
-
const requestedTolerance = options.replayToleranceMs;
|
|
4264
|
-
let tolerance;
|
|
4265
|
-
if (requestedTolerance === void 0) {
|
|
4266
|
-
tolerance = DEFAULT_REPLAY_TOLERANCE_MS;
|
|
4267
|
-
} else if (typeof requestedTolerance !== "number" || !Number.isFinite(requestedTolerance)) {
|
|
4268
|
-
throw new CrossdeckError({
|
|
4269
|
-
type: "configuration_error",
|
|
4270
|
-
code: "webhook_invalid_tolerance",
|
|
4271
|
-
message: `replayToleranceMs must be a finite non-negative number \u2264 ${MAX_REPLAY_TOLERANCE_MS} (24h). Got: ${String(requestedTolerance)}. Pre-v1.4.0 accepted Infinity/NaN/null and silently disabled replay protection \u2014 v1.4.0 rejects loudly.`
|
|
4272
|
-
});
|
|
4273
|
-
} else if (requestedTolerance < 0) {
|
|
4274
|
-
throw new CrossdeckError({
|
|
4275
|
-
type: "configuration_error",
|
|
4276
|
-
code: "webhook_invalid_tolerance",
|
|
4277
|
-
message: `replayToleranceMs must be \u2265 0. Got ${requestedTolerance}.`
|
|
4278
|
-
});
|
|
4279
|
-
} else if (requestedTolerance > MAX_REPLAY_TOLERANCE_MS) {
|
|
4280
|
-
throw new CrossdeckError({
|
|
4281
|
-
type: "configuration_error",
|
|
4282
|
-
code: "webhook_invalid_tolerance",
|
|
4283
|
-
message: `replayToleranceMs must not exceed ${MAX_REPLAY_TOLERANCE_MS}ms (24h). Got ${requestedTolerance}ms \u2014 a window that wide defeats replay protection.`
|
|
4284
|
-
});
|
|
4285
|
-
} else {
|
|
4286
|
-
tolerance = requestedTolerance;
|
|
4287
|
-
}
|
|
4288
3977
|
const header = normaliseHeader(signatureHeader);
|
|
4289
3978
|
const parsed = parseSignatureHeader(header);
|
|
4290
3979
|
if (!parsed) {
|
|
4291
3980
|
throw new CrossdeckError({
|
|
4292
3981
|
type: "authentication_error",
|
|
4293
|
-
code: "
|
|
4294
|
-
message: "Webhook signature header is missing
|
|
3982
|
+
code: "webhook_invalid_signature",
|
|
3983
|
+
message: "Webhook signature header is missing or malformed. Expected 'Crossdeck-Signature: t=<unix>,v1=<hex>'."
|
|
4295
3984
|
});
|
|
4296
3985
|
}
|
|
4297
|
-
const
|
|
4298
|
-
|
|
4299
|
-
|
|
4300
|
-
|
|
4301
|
-
|
|
4302
|
-
|
|
4303
|
-
|
|
4304
|
-
|
|
4305
|
-
|
|
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
|
+
}
|
|
4306
3998
|
}
|
|
4307
3999
|
const signedPayload = `${parsed.timestampSec}.${payload}`;
|
|
4308
4000
|
const expectedBuf = Buffer.from(parsed.signature, "hex");
|
|
4309
4001
|
if (expectedBuf.length === 0) {
|
|
4310
4002
|
throw new CrossdeckError({
|
|
4311
4003
|
type: "authentication_error",
|
|
4312
|
-
code: "
|
|
4004
|
+
code: "webhook_invalid_signature",
|
|
4313
4005
|
message: "Webhook signature is not a valid hex string."
|
|
4314
4006
|
});
|
|
4315
4007
|
}
|
|
4316
4008
|
const anyMatch = secrets.some((s) => {
|
|
4317
|
-
const computed = (0,
|
|
4318
|
-
return computed.length === expectedBuf.length && (0,
|
|
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);
|
|
4319
4011
|
});
|
|
4320
4012
|
if (!anyMatch) {
|
|
4321
4013
|
throw new CrossdeckError({
|
|
4322
4014
|
type: "authentication_error",
|
|
4323
|
-
code: "
|
|
4324
|
-
message: "Webhook signature did not verify
|
|
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)."
|
|
4325
4017
|
});
|
|
4326
4018
|
}
|
|
4327
4019
|
try {
|
|
@@ -4329,13 +4021,13 @@ function verifyWebhookSignature(payload, signatureHeader, secret, options = {})
|
|
|
4329
4021
|
} catch {
|
|
4330
4022
|
throw new CrossdeckError({
|
|
4331
4023
|
type: "authentication_error",
|
|
4332
|
-
code: "
|
|
4024
|
+
code: "webhook_invalid_signature",
|
|
4333
4025
|
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."
|
|
4334
4026
|
});
|
|
4335
4027
|
}
|
|
4336
4028
|
}
|
|
4337
4029
|
function signWebhookPayload(payload, secret, timestampSec) {
|
|
4338
|
-
return (0,
|
|
4030
|
+
return (0, import_node_crypto.createHmac)("sha256", secret).update(`${timestampSec}.${payload}`).digest("hex");
|
|
4339
4031
|
}
|
|
4340
4032
|
function parseSignatureHeader(header) {
|
|
4341
4033
|
if (!header) return null;
|
|
@@ -4367,472 +4059,36 @@ function normaliseSecrets(input) {
|
|
|
4367
4059
|
return arr.filter((s) => typeof s === "string" && s.length > 0);
|
|
4368
4060
|
}
|
|
4369
4061
|
|
|
4370
|
-
// src/
|
|
4371
|
-
var
|
|
4372
|
-
var
|
|
4373
|
-
var
|
|
4374
|
-
|
|
4375
|
-
|
|
4376
|
-
|
|
4377
|
-
|
|
4378
|
-
|
|
4379
|
-
|
|
4380
|
-
|
|
4381
|
-
|
|
4382
|
-
]
|
|
4383
|
-
"codeRef": [
|
|
4384
|
-
"sdks/node/src/webhooks.ts",
|
|
4385
|
-
"docs/rail-webhooks/index.html",
|
|
4386
|
-
"docs/webhooks-receive/index.html"
|
|
4387
|
-
],
|
|
4388
|
-
"testRef": [
|
|
4389
|
-
{
|
|
4390
|
-
"file": "sdks/node/src/webhooks.ts",
|
|
4391
|
-
"name": "[ROADMAP \u2014 v1.4.0 honesty note]"
|
|
4392
|
-
},
|
|
4393
|
-
{
|
|
4394
|
-
"file": "docs/rail-webhooks/index.html",
|
|
4395
|
-
"name": "Outbound push-to-your-backend webhooks are <strong>roadmap</strong>"
|
|
4396
|
-
},
|
|
4397
|
-
{
|
|
4398
|
-
"file": "docs/webhooks-receive/index.html",
|
|
4399
|
-
"name": "This feature is on the roadmap"
|
|
4400
|
-
}
|
|
4401
|
-
],
|
|
4402
|
-
"registeredAt": "2026-05-26",
|
|
4403
|
-
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 7.1",
|
|
4404
|
-
"bundledIn": "@cross-deck/node@1.5.0"
|
|
4405
|
-
},
|
|
4406
|
-
{
|
|
4407
|
-
"id": "error-envelope-shape",
|
|
4408
|
-
"pillar": "errors",
|
|
4409
|
-
"status": "enforced",
|
|
4410
|
-
"claim": "Every v1 REST endpoint returns errors in a Stripe-shape envelope: `{ error: { type, code, message, request_id } }` where `type` is one of authentication_error / permission_error / invalid_request_error / rate_limit_error / internal_error (the wire vocabulary in backend/src/api/v1-errors.ts ApiErrorType). HTTP status parity: invalid_request_error \u2192 400, authentication_error \u2192 401, permission_error \u2192 403, rate_limit_error \u2192 429, internal_error \u2192 500. SDK-side clients parse this shape via `crossdeckErrorFromResponse` (Web/Node/RN) / `crossdeckErrorFrom(response:)` (Swift) / `crossdeckErrorFromResponse` (Android) and surface the request_id verbatim so support traces are end-to-end joinable. Firebase callable endpoints (managed-keys / dashboard auth) use the Firebase HttpsError envelope instead \u2014 this contract applies to REST /v1/* only.",
|
|
4411
|
-
"appliesTo": [
|
|
4412
|
-
"web",
|
|
4413
|
-
"node",
|
|
4414
|
-
"react-native",
|
|
4415
|
-
"swift",
|
|
4416
|
-
"android",
|
|
4417
|
-
"backend"
|
|
4418
|
-
],
|
|
4419
|
-
"codeRef": [
|
|
4420
|
-
"backend/src/api/v1-errors.ts",
|
|
4421
|
-
"sdks/web/src/errors.ts",
|
|
4422
|
-
"sdks/node/src/errors.ts",
|
|
4423
|
-
"sdks/react-native/src/errors.ts",
|
|
4424
|
-
"sdks/swift/Sources/Crossdeck/Errors.swift",
|
|
4425
|
-
"sdks/android/crossdeck/src/main/kotlin/com/crossdeck/Errors.kt"
|
|
4426
|
-
],
|
|
4427
|
-
"testRef": [
|
|
4428
|
-
{
|
|
4429
|
-
"file": "sdks/swift/Tests/CrossdeckTests/ErrorsTests.swift",
|
|
4430
|
-
"name": "test_errorEnvelope_fallsBackOnGarbageBody"
|
|
4431
|
-
},
|
|
4432
|
-
{
|
|
4433
|
-
"file": "sdks/swift/Tests/CrossdeckTests/ErrorsTests.swift",
|
|
4434
|
-
"name": "test_errorEnvelope_reads_XRequestId_fallback"
|
|
4435
|
-
},
|
|
4436
|
-
{
|
|
4437
|
-
"file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/ErrorTypeWireVocabTest.kt",
|
|
4438
|
-
"name": "backend 500 response parses to INTERNAL_ERROR"
|
|
4439
|
-
}
|
|
4440
|
-
],
|
|
4441
|
-
"registeredAt": "2026-05-26",
|
|
4442
|
-
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 8 (codifies existing contract)",
|
|
4443
|
-
"bundledIn": "@cross-deck/node@1.5.0"
|
|
4444
|
-
},
|
|
4445
|
-
{
|
|
4446
|
-
"id": "flush-interval-parity",
|
|
4447
|
-
"pillar": "analytics",
|
|
4448
|
-
"status": "enforced",
|
|
4449
|
-
"claim": "Every Crossdeck SDK defaults its event-queue flush interval to 2000ms \u2014 the Stripe-adjacent industry norm. Pre-v1.4.0 the defaults disagreed (Web/Node 1500ms; RN/Swift/Android 5000ms), so cross-platform funnels saw events landing at different cadences. Per-instance override stays \u2014 call sites can still tune it freely.",
|
|
4450
|
-
"appliesTo": [
|
|
4451
|
-
"web",
|
|
4452
|
-
"node",
|
|
4453
|
-
"react-native",
|
|
4454
|
-
"swift",
|
|
4455
|
-
"android"
|
|
4456
|
-
],
|
|
4457
|
-
"codeRef": [
|
|
4458
|
-
"sdks/web/src/crossdeck.ts",
|
|
4459
|
-
"sdks/node/src/crossdeck-server.ts",
|
|
4460
|
-
"sdks/react-native/src/crossdeck.ts",
|
|
4461
|
-
"sdks/swift/Sources/Crossdeck/EventQueue.swift",
|
|
4462
|
-
"sdks/android/crossdeck/src/main/kotlin/com/crossdeck/EventQueue.kt"
|
|
4463
|
-
],
|
|
4464
|
-
"testRef": [
|
|
4465
|
-
{
|
|
4466
|
-
"file": "sdks/swift/Sources/Crossdeck/EventQueue.swift",
|
|
4467
|
-
"name": "flushIntervalMs: Int = 2_000"
|
|
4468
|
-
},
|
|
4469
|
-
{
|
|
4470
|
-
"file": "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/EventQueue.kt",
|
|
4471
|
-
"name": "flushIntervalMs: Long = 2_000L"
|
|
4472
|
-
},
|
|
4473
|
-
{
|
|
4474
|
-
"file": "sdks/web/src/crossdeck.ts",
|
|
4475
|
-
"name": "options.eventFlushIntervalMs ?? 2000"
|
|
4476
|
-
},
|
|
4477
|
-
{
|
|
4478
|
-
"file": "sdks/node/src/crossdeck-server.ts",
|
|
4479
|
-
"name": "options.eventFlushIntervalMs ?? 2000"
|
|
4480
|
-
},
|
|
4481
|
-
{
|
|
4482
|
-
"file": "sdks/react-native/src/crossdeck.ts",
|
|
4483
|
-
"name": "options.eventFlushIntervalMs ?? 2000"
|
|
4484
|
-
}
|
|
4485
|
-
],
|
|
4486
|
-
"registeredAt": "2026-05-26",
|
|
4487
|
-
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 3.3",
|
|
4488
|
-
"bundledIn": "@cross-deck/node@1.5.0"
|
|
4489
|
-
},
|
|
4490
|
-
{
|
|
4491
|
-
"id": "idempotency-key-deterministic",
|
|
4492
|
-
"pillar": "revenue",
|
|
4493
|
-
"status": "enforced",
|
|
4494
|
-
"claim": "syncPurchases() on every SDK derives a deterministic Idempotency-Key from the request body (UUID-shaped SHA-256 of `crossdeck:purchases/sync:<rail>:<jws|token>`). Same input -> same key. Backend short-circuits same-key-same-body retries by returning the cached response (status + body) with `idempotent_replay: true` flag in the body AND `Idempotent-Replayed: true` response header. Same-key-different-body returns 400 `idempotency_key_in_use`. 24-hour TTL matches Stripe. Cache only stores 2xx responses \u2014 4xx/5xx pass through so callers can fix bugs and retry. Helper returns nil/throws on missing identifier (no silent random fallback). Cross-SDK parity is CI-pinned: deriveForPurchase('apple', 'eyJ.jws.sig') MUST equal 'a66b1640-efaf-bb4d-1261-6650033bf111' on every SDK.",
|
|
4495
|
-
"appliesTo": [
|
|
4496
|
-
"web",
|
|
4497
|
-
"node",
|
|
4498
|
-
"react-native",
|
|
4499
|
-
"swift",
|
|
4500
|
-
"android",
|
|
4501
|
-
"backend"
|
|
4502
|
-
],
|
|
4503
|
-
"codeRef": [
|
|
4504
|
-
"sdks/web/src/idempotency-key.ts",
|
|
4505
|
-
"sdks/web/src/crossdeck.ts",
|
|
4506
|
-
"sdks/react-native/src/idempotency-key.ts",
|
|
4507
|
-
"sdks/react-native/src/crossdeck.ts",
|
|
4508
|
-
"sdks/node/src/idempotency-key.ts",
|
|
4509
|
-
"sdks/node/src/crossdeck-server.ts",
|
|
4510
|
-
"sdks/swift/Sources/Crossdeck/IdempotencyKey.swift",
|
|
4511
|
-
"sdks/swift/Sources/Crossdeck/Crossdeck.swift",
|
|
4512
|
-
"sdks/android/crossdeck/src/main/kotlin/com/crossdeck/IdempotencyKey.kt",
|
|
4513
|
-
"sdks/android/crossdeck/src/main/kotlin/com/crossdeck/Crossdeck.kt",
|
|
4514
|
-
"backend/src/lib/idempotency-response-cache.ts",
|
|
4515
|
-
"backend/src/api/v1-purchases.ts"
|
|
4516
|
-
],
|
|
4517
|
-
"testRef": [
|
|
4518
|
-
{
|
|
4519
|
-
"file": "sdks/web/tests/idempotency-key.test.ts",
|
|
4520
|
-
"name": "cross-SDK oracle \u2014 apple JWS pins canonical vector"
|
|
4521
|
-
},
|
|
4522
|
-
{
|
|
4523
|
-
"file": "sdks/web/tests/idempotency-key.test.ts",
|
|
4524
|
-
"name": "is deterministic: same body twice -> identical key"
|
|
4525
|
-
},
|
|
4526
|
-
{
|
|
4527
|
-
"file": "sdks/web/tests/idempotency-key.test.ts",
|
|
4528
|
-
"name": "same identifier under different rails -> different keys"
|
|
4529
|
-
},
|
|
4530
|
-
{
|
|
4531
|
-
"file": "sdks/web/tests/idempotency-key.test.ts",
|
|
4532
|
-
"name": "never silently falls back to a random key on missing identifier"
|
|
4533
|
-
},
|
|
4534
|
-
{
|
|
4535
|
-
"file": "sdks/react-native/tests/idempotency-key.test.ts",
|
|
4536
|
-
"name": "is deterministic"
|
|
4537
|
-
},
|
|
4538
|
-
{
|
|
4539
|
-
"file": "sdks/react-native/tests/idempotency-key.test.ts",
|
|
4540
|
-
"name": "cross-SDK oracle \u2014 apple JWS pins canonical vector"
|
|
4541
|
-
},
|
|
4542
|
-
{
|
|
4543
|
-
"file": "sdks/node/tests/idempotency-key.test.ts",
|
|
4544
|
-
"name": "is deterministic"
|
|
4545
|
-
},
|
|
4546
|
-
{
|
|
4547
|
-
"file": "sdks/node/tests/idempotency-key.test.ts",
|
|
4548
|
-
"name": "rail namespacing prevents cross-rail collisions"
|
|
4549
|
-
},
|
|
4550
|
-
{
|
|
4551
|
-
"file": "sdks/node/tests/idempotency-key.test.ts",
|
|
4552
|
-
"name": "apple JWS produces the canonical pinned UUID across all 5 SDKs"
|
|
4553
|
-
},
|
|
4554
|
-
{
|
|
4555
|
-
"file": "backend/tests/unit/idempotency-response-cache.test.ts",
|
|
4556
|
-
"name": "is deterministic for the same input"
|
|
4557
|
-
},
|
|
4558
|
-
{
|
|
4559
|
-
"file": "backend/tests/unit/idempotency-response-cache.test.ts",
|
|
4560
|
-
"name": "injects idempotent_replay: true into a JSON object body"
|
|
4561
|
-
},
|
|
4562
|
-
{
|
|
4563
|
-
"file": "backend/tests/unit/idempotency-response-cache.test.ts",
|
|
4564
|
-
"name": "matches Stripe's 24-hour idempotency window"
|
|
4565
|
-
},
|
|
4566
|
-
{
|
|
4567
|
-
"file": "sdks/swift/Tests/CrossdeckTests/IdempotencyKeyTests.swift",
|
|
4568
|
-
"name": "test_crossSdkOracle_appleJWS"
|
|
4569
|
-
},
|
|
4570
|
-
{
|
|
4571
|
-
"file": "sdks/swift/Tests/CrossdeckTests/IdempotencyKeyTests.swift",
|
|
4572
|
-
"name": "test_railNamespacing_preventsCrossRailCollisions"
|
|
4573
|
-
},
|
|
4574
|
-
{
|
|
4575
|
-
"file": "sdks/swift/Tests/CrossdeckTests/IdempotencyKeyTests.swift",
|
|
4576
|
-
"name": "test_missingIdentifier_returnsNil"
|
|
4577
|
-
},
|
|
4578
|
-
{
|
|
4579
|
-
"file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/IdempotencyKeyTest.kt",
|
|
4580
|
-
"name": "cross-SDK oracle for apple JWS"
|
|
4581
|
-
},
|
|
4582
|
-
{
|
|
4583
|
-
"file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/IdempotencyKeyTest.kt",
|
|
4584
|
-
"name": "rail namespacing prevents cross-rail collisions"
|
|
4585
|
-
},
|
|
4586
|
-
{
|
|
4587
|
-
"file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/IdempotencyKeyTest.kt",
|
|
4588
|
-
"name": "missing identifier returns null - never silent random fallback"
|
|
4589
|
-
}
|
|
4590
|
-
],
|
|
4591
|
-
"registeredAt": "2026-05-26",
|
|
4592
|
-
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 2.2.a + 2.2.b + 2.2.c",
|
|
4593
|
-
"bundledIn": "@cross-deck/node@1.5.0"
|
|
4594
|
-
},
|
|
4595
|
-
{
|
|
4596
|
-
"id": "node-pii-scrubber",
|
|
4597
|
-
"pillar": "analytics",
|
|
4598
|
-
"status": "enforced",
|
|
4599
|
-
"claim": "Node SDK's track() applies scrubPiiFromProperties on the enqueue path \u2014 parity with Web/RN/Swift. Pre-v1.4.0 the Node SDK was the ONLY one that skipped this, shipping every track() payload UNREDACTED despite the README promising parity. CrossdeckServerOptions.scrubPii defaults to true; explicit false opts out for regulator-required audit trails with a documented blast-radius warning.",
|
|
4600
|
-
"appliesTo": [
|
|
4601
|
-
"node"
|
|
4602
|
-
],
|
|
4603
|
-
"codeRef": [
|
|
4604
|
-
"sdks/node/src/crossdeck-server.ts",
|
|
4605
|
-
"sdks/node/src/types.ts",
|
|
4606
|
-
"sdks/node/src/consent.ts"
|
|
4607
|
-
],
|
|
4608
|
-
"testRef": [
|
|
4609
|
-
{
|
|
4610
|
-
"file": "sdks/node/tests/track-pii-scrub.test.ts",
|
|
4611
|
-
"name": "by default redacts email-shaped values to <email>"
|
|
4612
|
-
},
|
|
4613
|
-
{
|
|
4614
|
-
"file": "sdks/node/tests/track-pii-scrub.test.ts",
|
|
4615
|
-
"name": "redacts card-number-shaped values to <card>"
|
|
4616
|
-
},
|
|
4617
|
-
{
|
|
4618
|
-
"file": "sdks/node/tests/track-pii-scrub.test.ts",
|
|
4619
|
-
"name": "walks nested maps + arrays"
|
|
4620
|
-
},
|
|
4621
|
-
{
|
|
4622
|
-
"file": "sdks/node/tests/track-pii-scrub.test.ts",
|
|
4623
|
-
"name": "scrubPii: false preserves the raw payload (opt-out)"
|
|
4624
|
-
},
|
|
4625
|
-
{
|
|
4626
|
-
"file": "sdks/node/tests/track-pii-scrub.test.ts",
|
|
4627
|
-
"name": "scrubPii: true is the default when option is omitted"
|
|
4628
|
-
}
|
|
4629
|
-
],
|
|
4630
|
-
"registeredAt": "2026-05-26",
|
|
4631
|
-
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 3.1",
|
|
4632
|
-
"bundledIn": "@cross-deck/node@1.5.0"
|
|
4633
|
-
},
|
|
4634
|
-
{
|
|
4635
|
-
"id": "node-shutdown-awaits-flush",
|
|
4636
|
-
"pillar": "lifecycle",
|
|
4637
|
-
"status": "enforced",
|
|
4638
|
-
"claim": "Node SDK's async shutdown() awaits the internal flush() before tearing down the queue. A queue with pending events at sync-shutdown time (shutdownSync() or [Symbol.dispose]) logs a console.warn with the dropped-event count \u2014 silent loss is incompatible with the bank-grade contract. [Symbol.asyncDispose] is equivalent to await server.shutdown().",
|
|
4639
|
-
"appliesTo": [
|
|
4640
|
-
"node"
|
|
4641
|
-
],
|
|
4642
|
-
"codeRef": [
|
|
4643
|
-
"sdks/node/src/crossdeck-server.ts"
|
|
4644
|
-
],
|
|
4645
|
-
"testRef": [
|
|
4646
|
-
{
|
|
4647
|
-
"file": "sdks/node/tests/shutdown-flush.test.ts",
|
|
4648
|
-
"name": "async shutdown() flushes queued events before clearing"
|
|
4649
|
-
},
|
|
4650
|
-
{
|
|
4651
|
-
"file": "sdks/node/tests/shutdown-flush.test.ts",
|
|
4652
|
-
"name": "async shutdown() proceeds with teardown even if flush fails"
|
|
4653
|
-
},
|
|
4654
|
-
{
|
|
4655
|
-
"file": "sdks/node/tests/shutdown-flush.test.ts",
|
|
4656
|
-
"name": "sync shutdownSync() warns when the buffer has events at teardown"
|
|
4657
|
-
},
|
|
4658
|
-
{
|
|
4659
|
-
"file": "sdks/node/tests/shutdown-flush.test.ts",
|
|
4660
|
-
"name": "[Symbol.asyncDispose] equals await server.shutdown()"
|
|
4661
|
-
}
|
|
4662
|
-
],
|
|
4663
|
-
"registeredAt": "2026-05-26",
|
|
4664
|
-
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 5.4",
|
|
4665
|
-
"bundledIn": "@cross-deck/node@1.5.0"
|
|
4666
|
-
},
|
|
4667
|
-
{
|
|
4668
|
-
"id": "sdk-error-codes-catalogue",
|
|
4669
|
-
"pillar": "errors",
|
|
4670
|
-
"status": "enforced",
|
|
4671
|
-
"claim": "Web + Node SDK error-codes catalogues include EVERY backend-emitted ApiErrorCode with a description + resolution. Pre-v1.4.0 the catalogues documented codes the SDK threw ITSELF but ZERO backend codes \u2014 a developer hitting `invalid_api_key` / `origin_not_allowed` / `bundle_id_not_allowed` / `env_mismatch` / `idempotency_key_in_use` etc. got `undefined` from getErrorCode() and had to hunt for guidance. v1.4.0 backfills the catalogue from backend/src/api/v1-errors.ts so every wire code has a canonical 'what does this mean / what should I do' answer Stripe-style.",
|
|
4672
|
-
"appliesTo": [
|
|
4673
|
-
"web",
|
|
4674
|
-
"node"
|
|
4675
|
-
],
|
|
4676
|
-
"codeRef": [
|
|
4677
|
-
"sdks/web/src/error-codes.ts",
|
|
4678
|
-
"sdks/node/src/error-codes.ts",
|
|
4679
|
-
"backend/src/api/v1-errors.ts"
|
|
4680
|
-
],
|
|
4681
|
-
"testRef": [
|
|
4682
|
-
{
|
|
4683
|
-
"file": "sdks/web/tests/error-codes-backfill.test.ts",
|
|
4684
|
-
"name": "includes backend code"
|
|
4685
|
-
},
|
|
4686
|
-
{
|
|
4687
|
-
"file": "sdks/web/tests/error-codes-backfill.test.ts",
|
|
4688
|
-
"name": "invalid_api_key resolution points at the dashboard"
|
|
4689
|
-
},
|
|
4690
|
-
{
|
|
4691
|
-
"file": "sdks/web/tests/error-codes-backfill.test.ts",
|
|
4692
|
-
"name": "idempotency_key_in_use resolution mentions Stripe-grade contract"
|
|
4693
|
-
},
|
|
4694
|
-
{
|
|
4695
|
-
"file": "sdks/web/tests/error-codes-backfill.test.ts",
|
|
4696
|
-
"name": "identity-lock codes carry permission_error type"
|
|
4697
|
-
},
|
|
4698
|
-
{
|
|
4699
|
-
"file": "sdks/web/tests/error-codes-backfill.test.ts",
|
|
4700
|
-
"name": "no entry has an empty description or resolution"
|
|
4701
|
-
}
|
|
4702
|
-
],
|
|
4703
|
-
"registeredAt": "2026-05-26",
|
|
4704
|
-
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 6.2",
|
|
4705
|
-
"bundledIn": "@cross-deck/node@1.5.0"
|
|
4706
|
-
},
|
|
4707
|
-
{
|
|
4708
|
-
"id": "sync-purchases-funnel-parity",
|
|
4709
|
-
"pillar": "analytics",
|
|
4710
|
-
"status": "enforced",
|
|
4711
|
-
"claim": "Manual syncPurchases() emits a `purchase.completed` analytics event on success across ALL SDKs (Web / Node / RN / Swift / Android). Pre-v1.4.0 only Swift/Android auto-track emitted it \u2014 Web/Node/RN manual calls + Swift/Android manual calls fired ZERO analytics. Schema mirrors the auto-track event name + rail/productId/subscriptionId so cross-platform funnels reconcile on every payment path. When the backend short-circuits via the v1.4.0 idempotency cache, the event also carries `idempotent_replay: true`.",
|
|
4712
|
-
"appliesTo": [
|
|
4713
|
-
"web",
|
|
4714
|
-
"node",
|
|
4715
|
-
"react-native",
|
|
4716
|
-
"swift",
|
|
4717
|
-
"android"
|
|
4718
|
-
],
|
|
4719
|
-
"codeRef": [
|
|
4720
|
-
"sdks/web/src/crossdeck.ts",
|
|
4721
|
-
"sdks/node/src/crossdeck-server.ts",
|
|
4722
|
-
"sdks/react-native/src/crossdeck.ts",
|
|
4723
|
-
"sdks/swift/Sources/Crossdeck/Crossdeck.swift",
|
|
4724
|
-
"sdks/android/crossdeck/src/main/kotlin/com/crossdeck/Crossdeck.kt"
|
|
4725
|
-
],
|
|
4726
|
-
"testRef": [
|
|
4727
|
-
{
|
|
4728
|
-
"file": "sdks/web/tests/sync-purchases-funnel.test.ts",
|
|
4729
|
-
"name": "emits purchase.completed after a successful sync"
|
|
4730
|
-
},
|
|
4731
|
-
{
|
|
4732
|
-
"file": "sdks/web/tests/sync-purchases-funnel.test.ts",
|
|
4733
|
-
"name": "carries idempotent_replay=true when backend replied from cache"
|
|
4734
|
-
}
|
|
4735
|
-
],
|
|
4736
|
-
"registeredAt": "2026-05-26",
|
|
4737
|
-
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 3.5",
|
|
4738
|
-
"bundledIn": "@cross-deck/node@1.5.0"
|
|
4739
|
-
},
|
|
4740
|
-
{
|
|
4741
|
-
"id": "verifier-timestamp-mandatory",
|
|
4742
|
-
"pillar": "webhooks",
|
|
4743
|
-
"status": "enforced",
|
|
4744
|
-
"claim": "Node verifyWebhookSignature() enforces a MANDATORY timestamp window. Pre-v1.4.0 the helper silently disabled replay protection on tolerance=0 (`if (tolerance > 0)` skipped the check) and on Infinity/NaN/null (`Math.abs(...) > Infinity = false`). v1.4.0 rejects non-finite / negative / above-24h-cap tolerances at the boundary with typed `webhook_invalid_tolerance` and always runs the drift check. Verification failures are surfaced via distinguishable codes: `webhook_signature_mismatch` (wrong-secret signal), `webhook_timestamp_outside_tolerance` (replay-attack signal \u2014 alert separately), `webhook_timestamp_missing` (header absent/malformed), `webhook_payload_not_json` (tampered post-signing), `webhook_missing_secret`, `webhook_invalid_tolerance` \u2014 replaces the pre-1.4.0 single `webhook_invalid_signature` catch-all.",
|
|
4745
|
-
"appliesTo": [
|
|
4746
|
-
"node"
|
|
4747
|
-
],
|
|
4748
|
-
"codeRef": [
|
|
4749
|
-
"sdks/node/src/webhooks.ts",
|
|
4750
|
-
"sdks/node/src/error-codes.ts"
|
|
4751
|
-
],
|
|
4752
|
-
"testRef": [
|
|
4753
|
-
{
|
|
4754
|
-
"file": "sdks/node/tests/webhooks.test.ts",
|
|
4755
|
-
"name": "tolerance of 0 still enforces the replay window (v1.4.0 \u2014 cannot disable)"
|
|
4756
|
-
},
|
|
4757
|
-
{
|
|
4758
|
-
"file": "sdks/node/tests/webhooks.test.ts",
|
|
4759
|
-
"name": "rejects Infinity tolerance (would silently disable replay protection)"
|
|
4760
|
-
},
|
|
4761
|
-
{
|
|
4762
|
-
"file": "sdks/node/tests/webhooks.test.ts",
|
|
4763
|
-
"name": "rejects NaN tolerance"
|
|
4764
|
-
},
|
|
4765
|
-
{
|
|
4766
|
-
"file": "sdks/node/tests/webhooks.test.ts",
|
|
4767
|
-
"name": "rejects negative tolerance"
|
|
4768
|
-
},
|
|
4769
|
-
{
|
|
4770
|
-
"file": "sdks/node/tests/webhooks.test.ts",
|
|
4771
|
-
"name": "rejects tolerance above the 24h cap"
|
|
4772
|
-
},
|
|
4773
|
-
{
|
|
4774
|
-
"file": "sdks/node/tests/webhooks.test.ts",
|
|
4775
|
-
"name": "rejects non-number tolerance (null / string)"
|
|
4776
|
-
},
|
|
4777
|
-
{
|
|
4778
|
-
"file": "sdks/node/tests/webhooks.test.ts",
|
|
4779
|
-
"name": "accepts tolerance exactly at the 24h cap"
|
|
4780
|
-
},
|
|
4781
|
-
{
|
|
4782
|
-
"file": "sdks/node/tests/webhooks.test.ts",
|
|
4783
|
-
"name": "malformed header (no t= or no v1=) throws webhook_timestamp_missing"
|
|
4784
|
-
},
|
|
4785
|
-
{
|
|
4786
|
-
"file": "sdks/node/tests/webhooks.test.ts",
|
|
4787
|
-
"name": "valid signature but non-JSON payload throws webhook_payload_not_json"
|
|
4788
|
-
}
|
|
4789
|
-
],
|
|
4790
|
-
"registeredAt": "2026-05-26",
|
|
4791
|
-
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 7.2",
|
|
4792
|
-
"bundledIn": "@cross-deck/node@1.5.0"
|
|
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]);
|
|
4793
4075
|
}
|
|
4794
|
-
|
|
4795
|
-
|
|
4796
|
-
|
|
4797
|
-
|
|
4798
|
-
|
|
4799
|
-
|
|
4800
|
-
|
|
4801
|
-
allIncludingHistorical() {
|
|
4802
|
-
return BUNDLED_CONTRACTS;
|
|
4803
|
-
},
|
|
4804
|
-
byId(id) {
|
|
4805
|
-
return BUNDLED_CONTRACTS.find((c) => c.id === id);
|
|
4806
|
-
},
|
|
4807
|
-
byPillar(pillar) {
|
|
4808
|
-
return BUNDLED_CONTRACTS.filter(
|
|
4809
|
-
(c) => c.pillar === pillar && c.status === "enforced"
|
|
4810
|
-
);
|
|
4811
|
-
},
|
|
4812
|
-
withStatus(status) {
|
|
4813
|
-
return BUNDLED_CONTRACTS.filter((c) => c.status === status);
|
|
4814
|
-
},
|
|
4815
|
-
sdkVersion: SDK_VERSION2,
|
|
4816
|
-
bundledIn: BUNDLED_IN,
|
|
4817
|
-
/**
|
|
4818
|
-
* Resolve a failing test back to the contract it exercises.
|
|
4819
|
-
* Used by test-framework hooks to find the contract id of a
|
|
4820
|
-
* failed contract test so `reportContractFailure(...)` can stamp
|
|
4821
|
-
* the right `contract_id` on the emitted event.
|
|
4822
|
-
*/
|
|
4823
|
-
findByTestName(name) {
|
|
4824
|
-
return BUNDLED_CONTRACTS.find(
|
|
4825
|
-
(c) => c.testRef.some((ref) => ref.name === name)
|
|
4826
|
-
);
|
|
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);
|
|
4827
4083
|
}
|
|
4828
|
-
|
|
4084
|
+
return v;
|
|
4085
|
+
}
|
|
4829
4086
|
// Annotate the CommonJS export names for ESM import in node:
|
|
4830
4087
|
0 && (module.exports = {
|
|
4831
4088
|
CROSSDECK_API_VERSION,
|
|
4832
4089
|
CROSSDECK_ERROR_CODES,
|
|
4833
4090
|
CrossdeckAuthenticationError,
|
|
4834
4091
|
CrossdeckConfigurationError,
|
|
4835
|
-
CrossdeckContracts,
|
|
4836
4092
|
CrossdeckError,
|
|
4837
4093
|
CrossdeckInternalError,
|
|
4838
4094
|
CrossdeckNetworkError,
|