@cross-deck/node 1.3.1 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +48 -0
- package/README.md +62 -0
- package/dist/auto-events/index.d.mts +1 -1
- package/dist/auto-events/index.d.ts +1 -1
- package/dist/contracts.json +430 -0
- package/dist/{crossdeck-server-DhnHvUhh.d.mts → crossdeck-server-oAaKBnUU.d.mts} +183 -12
- package/dist/{crossdeck-server-DhnHvUhh.d.ts → crossdeck-server-oAaKBnUU.d.ts} +183 -12
- package/dist/index.cjs +817 -73
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +150 -21
- package/dist/index.d.ts +150 -21
- package/dist/index.mjs +812 -69
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -3
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,54 @@ All notable changes to `@cross-deck/node` will be documented here. The
|
|
|
4
4
|
format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
|
5
5
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [1.4.2] — 2026-05-26
|
|
8
|
+
|
|
9
|
+
Patch — fix `tests/shutdown-flush.test.ts` compile error under
|
|
10
|
+
strict tsc. The five `s.track("name", { props })` calls used the
|
|
11
|
+
web/RN positional-args shape; Node SDK's track takes a single
|
|
12
|
+
`ServerEvent` object. Switched to `s.track({ name, properties })`.
|
|
13
|
+
Plus a non-null assertion on `sent[0].length` for
|
|
14
|
+
`noUncheckedIndexedAccess`. v1.4.1 was tagged on the public
|
|
15
|
+
crossdeck-node repo but its publish workflow aborted on these
|
|
16
|
+
errors. v1.4.2 is the first 1.4.x line to land on the npm
|
|
17
|
+
registry. **No SDK code changes vs v1.4.0 / v1.4.1**.
|
|
18
|
+
|
|
19
|
+
## [1.4.1] — 2026-05-26
|
|
20
|
+
|
|
21
|
+
Patch — add automated npm publish workflow to the public
|
|
22
|
+
`crossdeck-node` repo so future `vX.Y.Z` tag pushes auto-publish
|
|
23
|
+
to npm via OIDC Trusted Publishing (matches the existing
|
|
24
|
+
`crossdeck-web` pattern). Also strips `test:e2e` from
|
|
25
|
+
`prepublishOnly` — the publish workflow runs lint + unit tests +
|
|
26
|
+
build which covers the release gate. No SDK code changes vs
|
|
27
|
+
v1.4.0.
|
|
28
|
+
|
|
29
|
+
**Operator note:** npmjs.com Trusted Publisher rule must be
|
|
30
|
+
configured for `crossdeck-node` (owner: VistaApps-za,
|
|
31
|
+
workflow: publish.yml) before the OIDC publish succeeds. First
|
|
32
|
+
publish after this lands will fail with an auth error if the
|
|
33
|
+
rule is missing — that's the prompt to configure it.
|
|
34
|
+
|
|
35
|
+
## [1.4.0] — 2026-05-26
|
|
36
|
+
|
|
37
|
+
**Bank-grade reconciliation release.** 6-pillar KPMG-style audit closed across SDK + backend. Every behavioural guarantee registered in the monorepo's `contracts/` directory with a CI-enforced audit job.
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
|
|
41
|
+
- **PII scrubber applied on `track()` enqueue path** — parity with Web/RN/Swift. Pre-1.4.0 Node was the ONLY SDK that skipped this, shipping payloads UNREDACTED. New `scrubPii?: boolean` option (default true); explicit false opt-out preserves raw payloads for regulator-required audit trails.
|
|
42
|
+
- **Deterministic `Idempotency-Key` on `syncPurchases()`** — same JWS/purchaseToken → same key. New `options.idempotencyKey` override for outer orchestrators.
|
|
43
|
+
- **`PurchaseResult.idempotent_replay?: boolean`** — true when the backend replayed a cached response.
|
|
44
|
+
- **`purchase.completed` event on every successful `syncPurchases()`** — funnel parity with Swift/Android auto-track.
|
|
45
|
+
- **Distinguishable webhook verifier error codes** — pre-1.4.0 collapsed everything into `webhook_invalid_signature`. New: `webhook_signature_mismatch` (wrong-secret signal), `webhook_timestamp_outside_tolerance` (replay-attack signal — alert separately), `webhook_timestamp_missing`, `webhook_payload_not_json`, `webhook_invalid_tolerance`. Legacy codes deprecated with migration notes.
|
|
46
|
+
- **Webhook verifier rejects footgun tolerances** — `Infinity` / `NaN` / negative / above-24h-cap now throw `webhook_invalid_tolerance` instead of silently disabling replay protection.
|
|
47
|
+
- **15 backend-emitted error codes** added to the `crossdeck-error-codes.json` catalogue with Stripe-style remediation guidance.
|
|
48
|
+
|
|
49
|
+
### Changed (breaking)
|
|
50
|
+
|
|
51
|
+
- **`shutdown()` signature changed from `(reason) => void` to `(reason) => Promise<void>`.** Awaits `flush()` before tearing down the queue. Pre-1.4.0 it called `eventQueue.reset()` synchronously — every event between the last flush and shutdown was silently dropped. New `shutdownSync()` for callers that genuinely cannot await (signal handlers); it logs `console.warn` with the dropped-event count if the buffer is non-empty.
|
|
52
|
+
- **Default event-queue flush interval is now 2000ms** (was 1500ms) — cross-SDK parity.
|
|
53
|
+
- **`[Symbol.dispose]` now warns when dropping queued events.** Use `await using` + `[Symbol.asyncDispose]` (or `await server.shutdown()`) for proper drainage.
|
|
54
|
+
|
|
7
55
|
## [1.3.1] — 2026-05-24
|
|
8
56
|
|
|
9
57
|
Patch fix for the 1.3.0 dist-load contract. Mirrors the
|
package/README.md
CHANGED
|
@@ -397,6 +397,14 @@ new CrossdeckServer({
|
|
|
397
397
|
|
|
398
398
|
POST methods (`track`/`ingest`/`syncPurchases`/`grantEntitlement`/`revokeEntitlement`) DO NOT auto-retry at the HTTP layer. Retries happen via the event queue with per-batch `Idempotency-Key` reuse — the server can dedupe replays.
|
|
399
399
|
|
|
400
|
+
**v1.4.0 — `syncPurchases` deterministic key.** The Idempotency-Key
|
|
401
|
+
on `syncPurchases` is derived from the request body (UUID-shaped
|
|
402
|
+
SHA-256 of `crossdeck:purchases/sync:<rail>:<jws|token>`). Two retries
|
|
403
|
+
of the same Apple transaction land on the same key, so the backend
|
|
404
|
+
short-circuits with `idempotent_replay: true` instead of
|
|
405
|
+
double-processing. Override via `options.idempotencyKey` only when
|
|
406
|
+
an outer orchestrator needs a different idempotency window.
|
|
407
|
+
|
|
400
408
|
### AbortSignal — caller-controlled cancellation
|
|
401
409
|
|
|
402
410
|
Every async method accepts a final `RequestOptions?` with `{ signal, timeoutMs }`:
|
|
@@ -414,6 +422,60 @@ try {
|
|
|
414
422
|
}
|
|
415
423
|
```
|
|
416
424
|
|
|
425
|
+
### PII scrubber (v1.4.0 — parity with Web/RN/Swift)
|
|
426
|
+
|
|
427
|
+
Every `track()` payload runs through `scrubPiiFromProperties`
|
|
428
|
+
before enqueue — email-shaped and card-number-shaped substrings
|
|
429
|
+
are rewritten to `<email>` / `<card>` sentinels recursively
|
|
430
|
+
across nested objects + arrays. **Default: on.** Pre-v1.4.0 the
|
|
431
|
+
Node SDK was the only one that skipped this, shipping payloads
|
|
432
|
+
UNREDACTED despite the README promising parity.
|
|
433
|
+
|
|
434
|
+
Opt out only for regulator-required audit trails where the raw
|
|
435
|
+
value must be preserved:
|
|
436
|
+
|
|
437
|
+
```ts
|
|
438
|
+
new CrossdeckServer({ secretKey, scrubPii: false });
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
**Blast radius:** every `track()` payload — event names with
|
|
442
|
+
embedded emails, trait values, group memberships, error context
|
|
443
|
+
blobs — ships verbatim to Crossdeck and downstream warehouses /
|
|
444
|
+
analytics exports. Document the decision at the call site.
|
|
445
|
+
|
|
446
|
+
### Shutdown — flush before exit (v1.4.0 contract)
|
|
447
|
+
|
|
448
|
+
The server holds a buffered event queue. A clean teardown MUST
|
|
449
|
+
flush the buffer before dropping it, otherwise events queued
|
|
450
|
+
between the last flush and shutdown are silently lost.
|
|
451
|
+
|
|
452
|
+
**Three teardown paths, three contracts:**
|
|
453
|
+
|
|
454
|
+
| Method | Flushes? | Use when |
|
|
455
|
+
| ------ | -------- | -------- |
|
|
456
|
+
| `await server.shutdown()` | YES — awaits internal `flush()` then tears down | Default. Use this in graceful-shutdown handlers. |
|
|
457
|
+
| `await using server = ...` + `[Symbol.asyncDispose]` | YES — equivalent to `await server.shutdown()` | TC39 explicit-resource-management blocks. |
|
|
458
|
+
| `server.shutdownSync()` / `using` + `[Symbol.dispose]` | NO — drops the buffer | ONLY when the runtime cannot await (signal handlers, process.exit fallthrough). |
|
|
459
|
+
|
|
460
|
+
```ts
|
|
461
|
+
// Graceful shutdown (recommended)
|
|
462
|
+
process.on("SIGTERM", async () => {
|
|
463
|
+
await server.shutdown();
|
|
464
|
+
process.exit(0);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
// Explicit-resource-management (Node 20+ / TS 5.2+)
|
|
468
|
+
{
|
|
469
|
+
await using server = new CrossdeckServer({ secretKey });
|
|
470
|
+
// ... use server ...
|
|
471
|
+
} // [Symbol.asyncDispose] fires here, awaits flush
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
`shutdownSync()` (and the sync `[Symbol.dispose]` that wraps it)
|
|
475
|
+
logs a `console.warn` with the dropped-event count whenever the
|
|
476
|
+
buffer is non-empty at sync-teardown time — silent loss is
|
|
477
|
+
incompatible with the bank-grade contract.
|
|
478
|
+
|
|
417
479
|
### EventEmitter — internal events
|
|
418
480
|
|
|
419
481
|
`CrossdeckServer extends EventEmitter`. Subscribe to internal lifecycle events with typed listeners:
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"generatedAt": "2026-05-26T13:04:47.333Z",
|
|
4
|
+
"sdk": "@cross-deck/node",
|
|
5
|
+
"sdkVersion": "1.5.0",
|
|
6
|
+
"bundledIn": "@cross-deck/node@1.5.0",
|
|
7
|
+
"count": 9,
|
|
8
|
+
"contracts": [
|
|
9
|
+
{
|
|
10
|
+
"id": "documentation-honesty",
|
|
11
|
+
"pillar": "webhooks",
|
|
12
|
+
"status": "enforced",
|
|
13
|
+
"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 — 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' — outbound is gated to the explicit roadmap section.",
|
|
14
|
+
"appliesTo": [
|
|
15
|
+
"node",
|
|
16
|
+
"backend"
|
|
17
|
+
],
|
|
18
|
+
"codeRef": [
|
|
19
|
+
"sdks/node/src/webhooks.ts",
|
|
20
|
+
"docs/rail-webhooks/index.html",
|
|
21
|
+
"docs/webhooks-receive/index.html"
|
|
22
|
+
],
|
|
23
|
+
"testRef": [
|
|
24
|
+
{
|
|
25
|
+
"file": "sdks/node/src/webhooks.ts",
|
|
26
|
+
"name": "[ROADMAP — v1.4.0 honesty note]"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"file": "docs/rail-webhooks/index.html",
|
|
30
|
+
"name": "Outbound push-to-your-backend webhooks are <strong>roadmap</strong>"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"file": "docs/webhooks-receive/index.html",
|
|
34
|
+
"name": "This feature is on the roadmap"
|
|
35
|
+
}
|
|
36
|
+
],
|
|
37
|
+
"registeredAt": "2026-05-26",
|
|
38
|
+
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 — phase 7.1",
|
|
39
|
+
"bundledIn": "@cross-deck/node@1.5.0"
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"id": "error-envelope-shape",
|
|
43
|
+
"pillar": "errors",
|
|
44
|
+
"status": "enforced",
|
|
45
|
+
"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 → 400, authentication_error → 401, permission_error → 403, rate_limit_error → 429, internal_error → 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 — this contract applies to REST /v1/* only.",
|
|
46
|
+
"appliesTo": [
|
|
47
|
+
"web",
|
|
48
|
+
"node",
|
|
49
|
+
"react-native",
|
|
50
|
+
"swift",
|
|
51
|
+
"android",
|
|
52
|
+
"backend"
|
|
53
|
+
],
|
|
54
|
+
"codeRef": [
|
|
55
|
+
"backend/src/api/v1-errors.ts",
|
|
56
|
+
"sdks/web/src/errors.ts",
|
|
57
|
+
"sdks/node/src/errors.ts",
|
|
58
|
+
"sdks/react-native/src/errors.ts",
|
|
59
|
+
"sdks/swift/Sources/Crossdeck/Errors.swift",
|
|
60
|
+
"sdks/android/crossdeck/src/main/kotlin/com/crossdeck/Errors.kt"
|
|
61
|
+
],
|
|
62
|
+
"testRef": [
|
|
63
|
+
{
|
|
64
|
+
"file": "sdks/swift/Tests/CrossdeckTests/ErrorsTests.swift",
|
|
65
|
+
"name": "test_errorEnvelope_fallsBackOnGarbageBody"
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"file": "sdks/swift/Tests/CrossdeckTests/ErrorsTests.swift",
|
|
69
|
+
"name": "test_errorEnvelope_reads_XRequestId_fallback"
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/ErrorTypeWireVocabTest.kt",
|
|
73
|
+
"name": "backend 500 response parses to INTERNAL_ERROR"
|
|
74
|
+
}
|
|
75
|
+
],
|
|
76
|
+
"registeredAt": "2026-05-26",
|
|
77
|
+
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 — phase 8 (codifies existing contract)",
|
|
78
|
+
"bundledIn": "@cross-deck/node@1.5.0"
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"id": "flush-interval-parity",
|
|
82
|
+
"pillar": "analytics",
|
|
83
|
+
"status": "enforced",
|
|
84
|
+
"claim": "Every Crossdeck SDK defaults its event-queue flush interval to 2000ms — 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 — call sites can still tune it freely.",
|
|
85
|
+
"appliesTo": [
|
|
86
|
+
"web",
|
|
87
|
+
"node",
|
|
88
|
+
"react-native",
|
|
89
|
+
"swift",
|
|
90
|
+
"android"
|
|
91
|
+
],
|
|
92
|
+
"codeRef": [
|
|
93
|
+
"sdks/web/src/crossdeck.ts",
|
|
94
|
+
"sdks/node/src/crossdeck-server.ts",
|
|
95
|
+
"sdks/react-native/src/crossdeck.ts",
|
|
96
|
+
"sdks/swift/Sources/Crossdeck/EventQueue.swift",
|
|
97
|
+
"sdks/android/crossdeck/src/main/kotlin/com/crossdeck/EventQueue.kt"
|
|
98
|
+
],
|
|
99
|
+
"testRef": [
|
|
100
|
+
{
|
|
101
|
+
"file": "sdks/swift/Sources/Crossdeck/EventQueue.swift",
|
|
102
|
+
"name": "flushIntervalMs: Int = 2_000"
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
"file": "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/EventQueue.kt",
|
|
106
|
+
"name": "flushIntervalMs: Long = 2_000L"
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
"file": "sdks/web/src/crossdeck.ts",
|
|
110
|
+
"name": "options.eventFlushIntervalMs ?? 2000"
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
"file": "sdks/node/src/crossdeck-server.ts",
|
|
114
|
+
"name": "options.eventFlushIntervalMs ?? 2000"
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
"file": "sdks/react-native/src/crossdeck.ts",
|
|
118
|
+
"name": "options.eventFlushIntervalMs ?? 2000"
|
|
119
|
+
}
|
|
120
|
+
],
|
|
121
|
+
"registeredAt": "2026-05-26",
|
|
122
|
+
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 — phase 3.3",
|
|
123
|
+
"bundledIn": "@cross-deck/node@1.5.0"
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
"id": "idempotency-key-deterministic",
|
|
127
|
+
"pillar": "revenue",
|
|
128
|
+
"status": "enforced",
|
|
129
|
+
"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 — 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.",
|
|
130
|
+
"appliesTo": [
|
|
131
|
+
"web",
|
|
132
|
+
"node",
|
|
133
|
+
"react-native",
|
|
134
|
+
"swift",
|
|
135
|
+
"android",
|
|
136
|
+
"backend"
|
|
137
|
+
],
|
|
138
|
+
"codeRef": [
|
|
139
|
+
"sdks/web/src/idempotency-key.ts",
|
|
140
|
+
"sdks/web/src/crossdeck.ts",
|
|
141
|
+
"sdks/react-native/src/idempotency-key.ts",
|
|
142
|
+
"sdks/react-native/src/crossdeck.ts",
|
|
143
|
+
"sdks/node/src/idempotency-key.ts",
|
|
144
|
+
"sdks/node/src/crossdeck-server.ts",
|
|
145
|
+
"sdks/swift/Sources/Crossdeck/IdempotencyKey.swift",
|
|
146
|
+
"sdks/swift/Sources/Crossdeck/Crossdeck.swift",
|
|
147
|
+
"sdks/android/crossdeck/src/main/kotlin/com/crossdeck/IdempotencyKey.kt",
|
|
148
|
+
"sdks/android/crossdeck/src/main/kotlin/com/crossdeck/Crossdeck.kt",
|
|
149
|
+
"backend/src/lib/idempotency-response-cache.ts",
|
|
150
|
+
"backend/src/api/v1-purchases.ts"
|
|
151
|
+
],
|
|
152
|
+
"testRef": [
|
|
153
|
+
{
|
|
154
|
+
"file": "sdks/web/tests/idempotency-key.test.ts",
|
|
155
|
+
"name": "cross-SDK oracle — apple JWS pins canonical vector"
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
"file": "sdks/web/tests/idempotency-key.test.ts",
|
|
159
|
+
"name": "is deterministic: same body twice -> identical key"
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
"file": "sdks/web/tests/idempotency-key.test.ts",
|
|
163
|
+
"name": "same identifier under different rails -> different keys"
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
"file": "sdks/web/tests/idempotency-key.test.ts",
|
|
167
|
+
"name": "never silently falls back to a random key on missing identifier"
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
"file": "sdks/react-native/tests/idempotency-key.test.ts",
|
|
171
|
+
"name": "is deterministic"
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
"file": "sdks/react-native/tests/idempotency-key.test.ts",
|
|
175
|
+
"name": "cross-SDK oracle — apple JWS pins canonical vector"
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
"file": "sdks/node/tests/idempotency-key.test.ts",
|
|
179
|
+
"name": "is deterministic"
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
"file": "sdks/node/tests/idempotency-key.test.ts",
|
|
183
|
+
"name": "rail namespacing prevents cross-rail collisions"
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
"file": "sdks/node/tests/idempotency-key.test.ts",
|
|
187
|
+
"name": "apple JWS produces the canonical pinned UUID across all 5 SDKs"
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
"file": "backend/tests/unit/idempotency-response-cache.test.ts",
|
|
191
|
+
"name": "is deterministic for the same input"
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
"file": "backend/tests/unit/idempotency-response-cache.test.ts",
|
|
195
|
+
"name": "injects idempotent_replay: true into a JSON object body"
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
"file": "backend/tests/unit/idempotency-response-cache.test.ts",
|
|
199
|
+
"name": "matches Stripe's 24-hour idempotency window"
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
"file": "sdks/swift/Tests/CrossdeckTests/IdempotencyKeyTests.swift",
|
|
203
|
+
"name": "test_crossSdkOracle_appleJWS"
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
"file": "sdks/swift/Tests/CrossdeckTests/IdempotencyKeyTests.swift",
|
|
207
|
+
"name": "test_railNamespacing_preventsCrossRailCollisions"
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
"file": "sdks/swift/Tests/CrossdeckTests/IdempotencyKeyTests.swift",
|
|
211
|
+
"name": "test_missingIdentifier_returnsNil"
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
"file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/IdempotencyKeyTest.kt",
|
|
215
|
+
"name": "cross-SDK oracle for apple JWS"
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
"file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/IdempotencyKeyTest.kt",
|
|
219
|
+
"name": "rail namespacing prevents cross-rail collisions"
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
"file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/IdempotencyKeyTest.kt",
|
|
223
|
+
"name": "missing identifier returns null - never silent random fallback"
|
|
224
|
+
}
|
|
225
|
+
],
|
|
226
|
+
"registeredAt": "2026-05-26",
|
|
227
|
+
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 — phase 2.2.a + 2.2.b + 2.2.c",
|
|
228
|
+
"bundledIn": "@cross-deck/node@1.5.0"
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
"id": "node-pii-scrubber",
|
|
232
|
+
"pillar": "analytics",
|
|
233
|
+
"status": "enforced",
|
|
234
|
+
"claim": "Node SDK's track() applies scrubPiiFromProperties on the enqueue path — 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.",
|
|
235
|
+
"appliesTo": [
|
|
236
|
+
"node"
|
|
237
|
+
],
|
|
238
|
+
"codeRef": [
|
|
239
|
+
"sdks/node/src/crossdeck-server.ts",
|
|
240
|
+
"sdks/node/src/types.ts",
|
|
241
|
+
"sdks/node/src/consent.ts"
|
|
242
|
+
],
|
|
243
|
+
"testRef": [
|
|
244
|
+
{
|
|
245
|
+
"file": "sdks/node/tests/track-pii-scrub.test.ts",
|
|
246
|
+
"name": "by default redacts email-shaped values to <email>"
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
"file": "sdks/node/tests/track-pii-scrub.test.ts",
|
|
250
|
+
"name": "redacts card-number-shaped values to <card>"
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
"file": "sdks/node/tests/track-pii-scrub.test.ts",
|
|
254
|
+
"name": "walks nested maps + arrays"
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
"file": "sdks/node/tests/track-pii-scrub.test.ts",
|
|
258
|
+
"name": "scrubPii: false preserves the raw payload (opt-out)"
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
"file": "sdks/node/tests/track-pii-scrub.test.ts",
|
|
262
|
+
"name": "scrubPii: true is the default when option is omitted"
|
|
263
|
+
}
|
|
264
|
+
],
|
|
265
|
+
"registeredAt": "2026-05-26",
|
|
266
|
+
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 — phase 3.1",
|
|
267
|
+
"bundledIn": "@cross-deck/node@1.5.0"
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
"id": "node-shutdown-awaits-flush",
|
|
271
|
+
"pillar": "lifecycle",
|
|
272
|
+
"status": "enforced",
|
|
273
|
+
"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 — silent loss is incompatible with the bank-grade contract. [Symbol.asyncDispose] is equivalent to await server.shutdown().",
|
|
274
|
+
"appliesTo": [
|
|
275
|
+
"node"
|
|
276
|
+
],
|
|
277
|
+
"codeRef": [
|
|
278
|
+
"sdks/node/src/crossdeck-server.ts"
|
|
279
|
+
],
|
|
280
|
+
"testRef": [
|
|
281
|
+
{
|
|
282
|
+
"file": "sdks/node/tests/shutdown-flush.test.ts",
|
|
283
|
+
"name": "async shutdown() flushes queued events before clearing"
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
"file": "sdks/node/tests/shutdown-flush.test.ts",
|
|
287
|
+
"name": "async shutdown() proceeds with teardown even if flush fails"
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
"file": "sdks/node/tests/shutdown-flush.test.ts",
|
|
291
|
+
"name": "sync shutdownSync() warns when the buffer has events at teardown"
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
"file": "sdks/node/tests/shutdown-flush.test.ts",
|
|
295
|
+
"name": "[Symbol.asyncDispose] equals await server.shutdown()"
|
|
296
|
+
}
|
|
297
|
+
],
|
|
298
|
+
"registeredAt": "2026-05-26",
|
|
299
|
+
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 — phase 5.4",
|
|
300
|
+
"bundledIn": "@cross-deck/node@1.5.0"
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
"id": "sdk-error-codes-catalogue",
|
|
304
|
+
"pillar": "errors",
|
|
305
|
+
"status": "enforced",
|
|
306
|
+
"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 — 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.",
|
|
307
|
+
"appliesTo": [
|
|
308
|
+
"web",
|
|
309
|
+
"node"
|
|
310
|
+
],
|
|
311
|
+
"codeRef": [
|
|
312
|
+
"sdks/web/src/error-codes.ts",
|
|
313
|
+
"sdks/node/src/error-codes.ts",
|
|
314
|
+
"backend/src/api/v1-errors.ts"
|
|
315
|
+
],
|
|
316
|
+
"testRef": [
|
|
317
|
+
{
|
|
318
|
+
"file": "sdks/web/tests/error-codes-backfill.test.ts",
|
|
319
|
+
"name": "includes backend code"
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
"file": "sdks/web/tests/error-codes-backfill.test.ts",
|
|
323
|
+
"name": "invalid_api_key resolution points at the dashboard"
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
"file": "sdks/web/tests/error-codes-backfill.test.ts",
|
|
327
|
+
"name": "idempotency_key_in_use resolution mentions Stripe-grade contract"
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
"file": "sdks/web/tests/error-codes-backfill.test.ts",
|
|
331
|
+
"name": "identity-lock codes carry permission_error type"
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
"file": "sdks/web/tests/error-codes-backfill.test.ts",
|
|
335
|
+
"name": "no entry has an empty description or resolution"
|
|
336
|
+
}
|
|
337
|
+
],
|
|
338
|
+
"registeredAt": "2026-05-26",
|
|
339
|
+
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 — phase 6.2",
|
|
340
|
+
"bundledIn": "@cross-deck/node@1.5.0"
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
"id": "sync-purchases-funnel-parity",
|
|
344
|
+
"pillar": "analytics",
|
|
345
|
+
"status": "enforced",
|
|
346
|
+
"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 — 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`.",
|
|
347
|
+
"appliesTo": [
|
|
348
|
+
"web",
|
|
349
|
+
"node",
|
|
350
|
+
"react-native",
|
|
351
|
+
"swift",
|
|
352
|
+
"android"
|
|
353
|
+
],
|
|
354
|
+
"codeRef": [
|
|
355
|
+
"sdks/web/src/crossdeck.ts",
|
|
356
|
+
"sdks/node/src/crossdeck-server.ts",
|
|
357
|
+
"sdks/react-native/src/crossdeck.ts",
|
|
358
|
+
"sdks/swift/Sources/Crossdeck/Crossdeck.swift",
|
|
359
|
+
"sdks/android/crossdeck/src/main/kotlin/com/crossdeck/Crossdeck.kt"
|
|
360
|
+
],
|
|
361
|
+
"testRef": [
|
|
362
|
+
{
|
|
363
|
+
"file": "sdks/web/tests/sync-purchases-funnel.test.ts",
|
|
364
|
+
"name": "emits purchase.completed after a successful sync"
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
"file": "sdks/web/tests/sync-purchases-funnel.test.ts",
|
|
368
|
+
"name": "carries idempotent_replay=true when backend replied from cache"
|
|
369
|
+
}
|
|
370
|
+
],
|
|
371
|
+
"registeredAt": "2026-05-26",
|
|
372
|
+
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 — phase 3.5",
|
|
373
|
+
"bundledIn": "@cross-deck/node@1.5.0"
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
"id": "verifier-timestamp-mandatory",
|
|
377
|
+
"pillar": "webhooks",
|
|
378
|
+
"status": "enforced",
|
|
379
|
+
"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 — alert separately), `webhook_timestamp_missing` (header absent/malformed), `webhook_payload_not_json` (tampered post-signing), `webhook_missing_secret`, `webhook_invalid_tolerance` — replaces the pre-1.4.0 single `webhook_invalid_signature` catch-all.",
|
|
380
|
+
"appliesTo": [
|
|
381
|
+
"node"
|
|
382
|
+
],
|
|
383
|
+
"codeRef": [
|
|
384
|
+
"sdks/node/src/webhooks.ts",
|
|
385
|
+
"sdks/node/src/error-codes.ts"
|
|
386
|
+
],
|
|
387
|
+
"testRef": [
|
|
388
|
+
{
|
|
389
|
+
"file": "sdks/node/tests/webhooks.test.ts",
|
|
390
|
+
"name": "tolerance of 0 still enforces the replay window (v1.4.0 — cannot disable)"
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
"file": "sdks/node/tests/webhooks.test.ts",
|
|
394
|
+
"name": "rejects Infinity tolerance (would silently disable replay protection)"
|
|
395
|
+
},
|
|
396
|
+
{
|
|
397
|
+
"file": "sdks/node/tests/webhooks.test.ts",
|
|
398
|
+
"name": "rejects NaN tolerance"
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
"file": "sdks/node/tests/webhooks.test.ts",
|
|
402
|
+
"name": "rejects negative tolerance"
|
|
403
|
+
},
|
|
404
|
+
{
|
|
405
|
+
"file": "sdks/node/tests/webhooks.test.ts",
|
|
406
|
+
"name": "rejects tolerance above the 24h cap"
|
|
407
|
+
},
|
|
408
|
+
{
|
|
409
|
+
"file": "sdks/node/tests/webhooks.test.ts",
|
|
410
|
+
"name": "rejects non-number tolerance (null / string)"
|
|
411
|
+
},
|
|
412
|
+
{
|
|
413
|
+
"file": "sdks/node/tests/webhooks.test.ts",
|
|
414
|
+
"name": "accepts tolerance exactly at the 24h cap"
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
"file": "sdks/node/tests/webhooks.test.ts",
|
|
418
|
+
"name": "malformed header (no t= or no v1=) throws webhook_timestamp_missing"
|
|
419
|
+
},
|
|
420
|
+
{
|
|
421
|
+
"file": "sdks/node/tests/webhooks.test.ts",
|
|
422
|
+
"name": "valid signature but non-JSON payload throws webhook_payload_not_json"
|
|
423
|
+
}
|
|
424
|
+
],
|
|
425
|
+
"registeredAt": "2026-05-26",
|
|
426
|
+
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 — phase 7.2",
|
|
427
|
+
"bundledIn": "@cross-deck/node@1.5.0"
|
|
428
|
+
}
|
|
429
|
+
]
|
|
430
|
+
}
|