@cross-deck/web 1.4.2 → 1.5.1
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 +74 -0
- package/README.md +94 -0
- package/dist/contracts 2.json +378 -0
- package/dist/contracts.json +378 -0
- package/dist/crossdeck.umd.min 2.js +3 -0
- package/dist/crossdeck.umd.min.js +2 -2
- package/dist/crossdeck.umd.min.js 2.map +1 -0
- package/dist/crossdeck.umd.min.js.map +1 -1
- package/dist/error-codes 2.json +196 -0
- package/dist/error-codes.json +1 -1
- package/dist/index 2.cjs +4778 -0
- package/dist/index 2.mjs +4742 -0
- package/dist/index.cjs +457 -0
- package/dist/index.cjs 2.map +1 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d 2.mts +938 -0
- package/dist/index.d 2.ts +938 -0
- package/dist/index.d.mts +174 -1
- package/dist/index.d.ts +174 -1
- package/dist/index.mjs +456 -0
- package/dist/index.mjs 2.map +1 -0
- package/dist/index.mjs.map +1 -1
- package/dist/react 2.cjs +4220 -0
- package/dist/react 2.mjs +4193 -0
- package/dist/react.cjs +31 -0
- package/dist/react.cjs 2.map +1 -0
- package/dist/react.cjs.map +1 -1
- package/dist/react.d 2.mts +91 -0
- package/dist/react.d 2.ts +91 -0
- package/dist/react.mjs +31 -0
- package/dist/react.mjs 2.map +1 -0
- package/dist/react.mjs.map +1 -1
- package/dist/types-Bu3jbmdq.d 2.mts +314 -0
- package/dist/types-Bu3jbmdq.d 2.ts +314 -0
- package/dist/vue 2.cjs +4185 -0
- package/dist/vue 2.mjs +4159 -0
- package/dist/vue.cjs +31 -0
- package/dist/vue.cjs 2.map +1 -0
- package/dist/vue.cjs.map +1 -1
- package/dist/vue.d 2.mts +37 -0
- package/dist/vue.d 2.ts +37 -0
- package/dist/vue.mjs +31 -0
- package/dist/vue.mjs 2.map +1 -0
- package/dist/vue.mjs.map +1 -1
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,80 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@cross-deck/web` will be documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
4
4
|
|
|
5
|
+
## [1.5.1] — 2026-05-27
|
|
6
|
+
|
|
7
|
+
`crossdeck.contract_failed` is now single-fire to a dedicated
|
|
8
|
+
reliability endpoint instead of the customer's `track()` pipeline.
|
|
9
|
+
Independent-controller flow per Privacy Policy §6; schema-locked by
|
|
10
|
+
`contracts/diagnostics/contract-failed-payload-schema-lock.json`.
|
|
11
|
+
`ContractFailureInput.extra` removed (schema-lock forbids unbounded
|
|
12
|
+
fields); `ContractFailureInput.deviceClass` added.
|
|
13
|
+
|
|
14
|
+
**Runtime contract verifier layer.** The SDK now self-tests its
|
|
15
|
+
own structural contracts at runtime — per-user cache isolation,
|
|
16
|
+
idempotency-key determinism, error-envelope shape, flush-interval
|
|
17
|
+
parity, super-property merge precedence. Verifiers run on every
|
|
18
|
+
relevant SDK operation; PASS results stream to the developer's
|
|
19
|
+
console when `logVerifierResults: true`; FAIL results fire
|
|
20
|
+
`reportContractFailure(...)` to the reliability channel.
|
|
21
|
+
|
|
22
|
+
Three new `CrossdeckOptions` flags:
|
|
23
|
+
- `verifyContractsAtBoot` — default dev=true, prod=false
|
|
24
|
+
- `logVerifierResults` — default dev=true, prod=false (cosmetic only)
|
|
25
|
+
- `disableContractAssertions` — sovereignty kill-switch, default false
|
|
26
|
+
|
|
27
|
+
Bundle-size budget bumped 45 → 55 KB gzipped (core) and 26 → 32 KB
|
|
28
|
+
(UMD) to accommodate the ~6 KB verifier framework + verifier
|
|
29
|
+
implementations. The platform-hardening signal — every install
|
|
30
|
+
in the field tests its own structural contracts as it operates and
|
|
31
|
+
reports failures to Crossdeck's reliability workspace in real time —
|
|
32
|
+
is the trade-off.
|
|
33
|
+
|
|
34
|
+
## [1.5.0] — 2026-05-26
|
|
35
|
+
|
|
36
|
+
Minor — `CrossdeckContracts` + `reportContractFailure(...)` ship as a
|
|
37
|
+
new public surface on every SDK simultaneously. Additive only; no
|
|
38
|
+
behavioural change to existing APIs.
|
|
39
|
+
|
|
40
|
+
**Added:**
|
|
41
|
+
|
|
42
|
+
- **`CrossdeckContracts` namespace** — typed, tree-shakeable access to
|
|
43
|
+
the bank-grade contract registry the SDK was already shipping in
|
|
44
|
+
`dist/contracts.json`. Methods: `all()` (enforced only),
|
|
45
|
+
`allIncludingHistorical()`, `byId(id)`, `byPillar(pillar)`,
|
|
46
|
+
`withStatus(status)`, `findByTestName(name)`. Properties:
|
|
47
|
+
`sdkVersion`, `bundledIn` (e.g. `"@cross-deck/web@1.5.0"`).
|
|
48
|
+
- **`Contract` type + `ContractPillar` / `ContractStatus` /
|
|
49
|
+
`ContractAppliesTo` unions + `ContractTestRef` interface** exported
|
|
50
|
+
from the top-level entry. Treated as binary-stable — fields may be
|
|
51
|
+
added in any minor release but never removed/repurposed except in a
|
|
52
|
+
major bump.
|
|
53
|
+
- **`Crossdeck.reportContractFailure(input)` method** — fires a
|
|
54
|
+
typed `crossdeck.contract_failed` custom event through the
|
|
55
|
+
standard `track()` pipeline when a contract test asserts and
|
|
56
|
+
fails (in CI, dogfood, or a customer integration test). Wire
|
|
57
|
+
properties: `contract_id`, `sdk_version` (auto-stamped),
|
|
58
|
+
`sdk_platform` (auto-stamped to `"web"`), `failure_reason`,
|
|
59
|
+
`run_context` (`ci` | `dogfood` | `customer-app`), `run_id`, and
|
|
60
|
+
optional `test_file` / `test_name` from `input.testRef`.
|
|
61
|
+
- **Bundle size**: core ESM/CJS/react/vue budgets raised to 45 KB
|
|
62
|
+
gzipped (from 41 KB), UMD min to 26 KB (from 23 KB) to accommodate
|
|
63
|
+
the inlined contracts dataset (~3 KB gzipped) + the query helpers
|
|
64
|
+
+ the new public types. Still well below every single-pillar
|
|
65
|
+
competitor's ceiling (Mixpanel 55, Sentry 30 errors-only, PostHog
|
|
66
|
+
40 analytics-only) for a one-bundle three-pillar SDK that now
|
|
67
|
+
also ships its own verification dataset.
|
|
68
|
+
|
|
69
|
+
**Changed:**
|
|
70
|
+
|
|
71
|
+
- Contract registry source files migrated from snake_case to camelCase
|
|
72
|
+
keys (`appliesTo`, `codeRef`, `testRef`, `registeredAt`,
|
|
73
|
+
`firstRegisteredIn`). The bundled `contracts.json` sidecar shipped
|
|
74
|
+
with this release uses the new keys. `bundledIn` is added at build
|
|
75
|
+
time, never present in source. See [`contracts/README.md`](https://github.com/VistaApps-za/crossdeck/blob/main/contracts/README.md)
|
|
76
|
+
for the schema rationale and `firstRegisteredIn` (immutable) vs
|
|
77
|
+
`bundledIn` (build-stamped) split.
|
|
78
|
+
|
|
5
79
|
## [1.4.2] — 2026-05-26
|
|
6
80
|
|
|
7
81
|
Patch — second npm publish pipeline fix. v1.4.1 fixed the Node 24
|
package/README.md
CHANGED
|
@@ -254,6 +254,100 @@ Diagnostic snapshot — useful for development consoles and bug reports:
|
|
|
254
254
|
}
|
|
255
255
|
```
|
|
256
256
|
|
|
257
|
+
## Bank-grade contracts
|
|
258
|
+
|
|
259
|
+
The SDK ships its own contracts registry — every behavioural guarantee the SDK makes (per-user cache isolation, deterministic Idempotency-Key, queue durability, etc.) lives in `contracts/**/*.json` at the monorepo root and is **bundled into every release**. The customer's lockfile pins SDK code + contracts atomically — drift between what the SDK does and what it claims is structurally impossible. See [`contracts/README.md`](https://github.com/VistaApps-za/crossdeck/blob/main/contracts/README.md) for the full architecture.
|
|
260
|
+
|
|
261
|
+
### `CrossdeckContracts` — typed access to the bundled registry
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
import { CrossdeckContracts } from "@cross-deck/web";
|
|
265
|
+
|
|
266
|
+
CrossdeckContracts.all(); // enforced contracts only
|
|
267
|
+
CrossdeckContracts.allIncludingHistorical(); // + proposed + retired
|
|
268
|
+
CrossdeckContracts.byId("per-user-cache-isolation");
|
|
269
|
+
CrossdeckContracts.byPillar("entitlements");
|
|
270
|
+
CrossdeckContracts.withStatus("proposed");
|
|
271
|
+
CrossdeckContracts.findByTestName("identify(B) makes A's entitlements unreachable from in-memory");
|
|
272
|
+
CrossdeckContracts.sdkVersion; // "1.5.0"
|
|
273
|
+
CrossdeckContracts.bundledIn; // "@cross-deck/web@1.5.0"
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
The `Contract` type is exported alongside; the binary-stability promise (which fields are guaranteed across patch/minor releases) is documented inline on `src/contracts.ts` and in [`contracts/README.md`](https://github.com/VistaApps-za/crossdeck/blob/main/contracts/README.md).
|
|
277
|
+
|
|
278
|
+
### `Crossdeck.reportContractFailure(input)` — surface contract test failures
|
|
279
|
+
|
|
280
|
+
When a contract test asserts and fails — in your CI, a dogfood run, or a customer integration test — fire a typed `crossdeck.contract_failed` event over the **Crossdeck reliability channel**. This is one-way operational telemetry to the Crossdeck operations team (Privacy Policy §6, "Flow B"); it never enters your `track()` pipeline, never shows in your dashboard, never bills against your event quota. The wire shape is schema-locked at [`contracts/diagnostics/contract-failed-payload-schema-lock.json`](https://github.com/VistaApps-za/crossdeck/blob/main/contracts/diagnostics/contract-failed-payload-schema-lock.json):
|
|
281
|
+
|
|
282
|
+
```ts
|
|
283
|
+
Crossdeck.reportContractFailure({
|
|
284
|
+
contractId: "per-user-cache-isolation",
|
|
285
|
+
failureReason: "expected isolation across user switch, got cross-read",
|
|
286
|
+
runContext: process.env.CI ? "ci" : "dogfood",
|
|
287
|
+
runId: process.env.GITHUB_RUN_ID ?? crypto.randomUUID(),
|
|
288
|
+
testRef: {
|
|
289
|
+
file: "tests/entitlement-cache-isolation.test.ts",
|
|
290
|
+
name: "identify(B) makes A's entitlements unreachable from in-memory",
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
No new endpoint, no special ingest path — the event lands in the same pipeline every other `track()` call does. It surfaces immediately in the dashboard's live event feed, the breakdown chart (group by `contract_id`, `sdk_platform`), and any alert rule with `event = crossdeck.contract_failed`.
|
|
296
|
+
|
|
297
|
+
Properties stamped on the wire:
|
|
298
|
+
|
|
299
|
+
| Property | Source |
|
|
300
|
+
|----------|--------|
|
|
301
|
+
| `contract_id` | caller |
|
|
302
|
+
| `sdk_version`, `sdk_platform` | auto-stamped by the SDK |
|
|
303
|
+
| `failure_reason`, `run_context`, `run_id` | caller |
|
|
304
|
+
| `test_file`, `test_name` | set when `testRef` is provided |
|
|
305
|
+
| `device_class` | optional, set by caller (categorical bucket) |
|
|
306
|
+
| `verification_phase` | auto-stamped when the runtime verifier layer (below) is the source — `boot` or `hot_path` |
|
|
307
|
+
|
|
308
|
+
The wire shape is locked by [`contracts/diagnostics/contract-failed-payload-schema-lock.json`](https://github.com/VistaApps-za/crossdeck/blob/main/contracts/diagnostics/contract-failed-payload-schema-lock.json); per-SDK assertion tests gate it on every release.
|
|
309
|
+
|
|
310
|
+
For per-test-framework hooks (Vitest `afterEach`, etc.) see [`contracts/README.md` § Reporting contract failures](https://github.com/VistaApps-za/crossdeck/blob/main/contracts/README.md#reporting-contract-failures-back-to-crossdeck).
|
|
311
|
+
|
|
312
|
+
### Runtime self-verification (v1.5.1+)
|
|
313
|
+
|
|
314
|
+
The Web SDK actively tests its own structural contracts at runtime — not just in Crossdeck's CI. Every `init()` runs a boot self-test; every relevant SDK operation (`identify()`, `track()`, `syncPurchases()`, error parse) is observed by a hot-path verifier that asserts the contract claim held. PASS results stream to your devtools console; FAIL results stream silently to Crossdeck's reliability workspace via a dedicated one-way channel that never touches your appId, your dashboard, or your event quota.
|
|
315
|
+
|
|
316
|
+
```ts
|
|
317
|
+
Crossdeck.init({
|
|
318
|
+
appId: "app_web_xxx",
|
|
319
|
+
publicKey: "cd_pub_test_xxx",
|
|
320
|
+
environment: "sandbox",
|
|
321
|
+
// Defaults shown — both are true in dev (NODE_ENV !== "production"),
|
|
322
|
+
// false in production. Set explicitly to override.
|
|
323
|
+
verifyContractsAtBoot: true, // run the boot self-test on init
|
|
324
|
+
logVerifierResults: true, // print PASS lines to console
|
|
325
|
+
// Sovereignty kill-switch — disables the entire layer end-to-end:
|
|
326
|
+
// no verifiers run, no console output, no reliability-channel
|
|
327
|
+
// writes. Default false.
|
|
328
|
+
disableContractAssertions: false,
|
|
329
|
+
});
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
What you'll see in your devtools console (with `logVerifierResults: true`):
|
|
333
|
+
|
|
334
|
+
```
|
|
335
|
+
[crossdeck] Contract self-verification — running 5 tests
|
|
336
|
+
✓ per-user-cache-isolation — slot rotated A:7c44…ee20 → B:a3f2…01b9
|
|
337
|
+
✓ idempotency-key-deterministic — apple JWS → a66b1640-…
|
|
338
|
+
✓ error-envelope-shape — { type, code, message, request_id } parsed
|
|
339
|
+
✓ flush-interval-parity — eventFlushIntervalMs = 2000ms
|
|
340
|
+
✓ super-property-merge-precedence — caller > super > device
|
|
341
|
+
[crossdeck] Self-verification passed — 5 passed, 0 failed (8ms)
|
|
342
|
+
|
|
343
|
+
[crossdeck.identify] ✓ per-user-cache-isolation — slot rotated _anon → a3f2…01b9
|
|
344
|
+
[crossdeck.track] ✓ super-property-merge-precedence — caller(2) > super(1) > device
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
**Operator's-view configuration.** The same `verifyContractsAtBoot` + `logVerifierResults` flags are exposed as a per-app remote config in the dashboard at [/dashboard/apps/](https://cross-deck.com/dashboard/apps/). Flip a toggle there and the next SDK boot picks it up — no code change, no redeploy. Code wins on precedence (`code option > dashboard remote config > DEBUG/RELEASE default`), so engineers retain ultimate control. `disableContractAssertions` is intentionally code-only — see [docs/contracts/ § sovereignty](https://cross-deck.com/docs/contracts/#runtime-sovereignty).
|
|
348
|
+
|
|
349
|
+
Full architecture + per-verifier walkthrough at [docs/contracts/ § Runtime self-verification](https://cross-deck.com/docs/contracts/#runtime).
|
|
350
|
+
|
|
257
351
|
## Errors
|
|
258
352
|
|
|
259
353
|
Every async method can throw `CrossdeckError`. Synchronous methods throw on configuration mistakes (calling before `init()`, invalid key prefix, env mismatch).
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"generatedAt": "2026-05-26T12:50:53.045Z",
|
|
4
|
+
"sdk": "@cross-deck/web",
|
|
5
|
+
"sdkVersion": "1.5.0",
|
|
6
|
+
"bundledIn": "@cross-deck/web@1.5.0",
|
|
7
|
+
"count": 7,
|
|
8
|
+
"contracts": [
|
|
9
|
+
{
|
|
10
|
+
"id": "error-envelope-shape",
|
|
11
|
+
"pillar": "errors",
|
|
12
|
+
"status": "enforced",
|
|
13
|
+
"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.",
|
|
14
|
+
"appliesTo": [
|
|
15
|
+
"web",
|
|
16
|
+
"node",
|
|
17
|
+
"react-native",
|
|
18
|
+
"swift",
|
|
19
|
+
"android",
|
|
20
|
+
"backend"
|
|
21
|
+
],
|
|
22
|
+
"codeRef": [
|
|
23
|
+
"backend/src/api/v1-errors.ts",
|
|
24
|
+
"sdks/web/src/errors.ts",
|
|
25
|
+
"sdks/node/src/errors.ts",
|
|
26
|
+
"sdks/react-native/src/errors.ts",
|
|
27
|
+
"sdks/swift/Sources/Crossdeck/Errors.swift",
|
|
28
|
+
"sdks/android/crossdeck/src/main/kotlin/com/crossdeck/Errors.kt"
|
|
29
|
+
],
|
|
30
|
+
"testRef": [
|
|
31
|
+
{
|
|
32
|
+
"file": "sdks/swift/Tests/CrossdeckTests/ErrorsTests.swift",
|
|
33
|
+
"name": "test_errorEnvelope_fallsBackOnGarbageBody"
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"file": "sdks/swift/Tests/CrossdeckTests/ErrorsTests.swift",
|
|
37
|
+
"name": "test_errorEnvelope_reads_XRequestId_fallback"
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/ErrorTypeWireVocabTest.kt",
|
|
41
|
+
"name": "backend 500 response parses to INTERNAL_ERROR"
|
|
42
|
+
}
|
|
43
|
+
],
|
|
44
|
+
"registeredAt": "2026-05-26",
|
|
45
|
+
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 — phase 8 (codifies existing contract)",
|
|
46
|
+
"bundledIn": "@cross-deck/web@1.5.0"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"id": "flush-interval-parity",
|
|
50
|
+
"pillar": "analytics",
|
|
51
|
+
"status": "enforced",
|
|
52
|
+
"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.",
|
|
53
|
+
"appliesTo": [
|
|
54
|
+
"web",
|
|
55
|
+
"node",
|
|
56
|
+
"react-native",
|
|
57
|
+
"swift",
|
|
58
|
+
"android"
|
|
59
|
+
],
|
|
60
|
+
"codeRef": [
|
|
61
|
+
"sdks/web/src/crossdeck.ts",
|
|
62
|
+
"sdks/node/src/crossdeck-server.ts",
|
|
63
|
+
"sdks/react-native/src/crossdeck.ts",
|
|
64
|
+
"sdks/swift/Sources/Crossdeck/EventQueue.swift",
|
|
65
|
+
"sdks/android/crossdeck/src/main/kotlin/com/crossdeck/EventQueue.kt"
|
|
66
|
+
],
|
|
67
|
+
"testRef": [
|
|
68
|
+
{
|
|
69
|
+
"file": "sdks/swift/Sources/Crossdeck/EventQueue.swift",
|
|
70
|
+
"name": "flushIntervalMs: Int = 2_000"
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"file": "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/EventQueue.kt",
|
|
74
|
+
"name": "flushIntervalMs: Long = 2_000L"
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"file": "sdks/web/src/crossdeck.ts",
|
|
78
|
+
"name": "options.eventFlushIntervalMs ?? 2000"
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"file": "sdks/node/src/crossdeck-server.ts",
|
|
82
|
+
"name": "options.eventFlushIntervalMs ?? 2000"
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"file": "sdks/react-native/src/crossdeck.ts",
|
|
86
|
+
"name": "options.eventFlushIntervalMs ?? 2000"
|
|
87
|
+
}
|
|
88
|
+
],
|
|
89
|
+
"registeredAt": "2026-05-26",
|
|
90
|
+
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 — phase 3.3",
|
|
91
|
+
"bundledIn": "@cross-deck/web@1.5.0"
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
"id": "idempotency-key-deterministic",
|
|
95
|
+
"pillar": "revenue",
|
|
96
|
+
"status": "enforced",
|
|
97
|
+
"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.",
|
|
98
|
+
"appliesTo": [
|
|
99
|
+
"web",
|
|
100
|
+
"node",
|
|
101
|
+
"react-native",
|
|
102
|
+
"swift",
|
|
103
|
+
"android",
|
|
104
|
+
"backend"
|
|
105
|
+
],
|
|
106
|
+
"codeRef": [
|
|
107
|
+
"sdks/web/src/idempotency-key.ts",
|
|
108
|
+
"sdks/web/src/crossdeck.ts",
|
|
109
|
+
"sdks/react-native/src/idempotency-key.ts",
|
|
110
|
+
"sdks/react-native/src/crossdeck.ts",
|
|
111
|
+
"sdks/node/src/idempotency-key.ts",
|
|
112
|
+
"sdks/node/src/crossdeck-server.ts",
|
|
113
|
+
"sdks/swift/Sources/Crossdeck/IdempotencyKey.swift",
|
|
114
|
+
"sdks/swift/Sources/Crossdeck/Crossdeck.swift",
|
|
115
|
+
"sdks/android/crossdeck/src/main/kotlin/com/crossdeck/IdempotencyKey.kt",
|
|
116
|
+
"sdks/android/crossdeck/src/main/kotlin/com/crossdeck/Crossdeck.kt",
|
|
117
|
+
"backend/src/lib/idempotency-response-cache.ts",
|
|
118
|
+
"backend/src/api/v1-purchases.ts"
|
|
119
|
+
],
|
|
120
|
+
"testRef": [
|
|
121
|
+
{
|
|
122
|
+
"file": "sdks/web/tests/idempotency-key.test.ts",
|
|
123
|
+
"name": "cross-SDK oracle — apple JWS pins canonical vector"
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
"file": "sdks/web/tests/idempotency-key.test.ts",
|
|
127
|
+
"name": "is deterministic: same body twice -> identical key"
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
"file": "sdks/web/tests/idempotency-key.test.ts",
|
|
131
|
+
"name": "same identifier under different rails -> different keys"
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
"file": "sdks/web/tests/idempotency-key.test.ts",
|
|
135
|
+
"name": "never silently falls back to a random key on missing identifier"
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
"file": "sdks/react-native/tests/idempotency-key.test.ts",
|
|
139
|
+
"name": "is deterministic"
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
"file": "sdks/react-native/tests/idempotency-key.test.ts",
|
|
143
|
+
"name": "cross-SDK oracle — apple JWS pins canonical vector"
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
"file": "sdks/node/tests/idempotency-key.test.ts",
|
|
147
|
+
"name": "is deterministic"
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
"file": "sdks/node/tests/idempotency-key.test.ts",
|
|
151
|
+
"name": "rail namespacing prevents cross-rail collisions"
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
"file": "sdks/node/tests/idempotency-key.test.ts",
|
|
155
|
+
"name": "apple JWS produces the canonical pinned UUID across all 5 SDKs"
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
"file": "backend/tests/unit/idempotency-response-cache.test.ts",
|
|
159
|
+
"name": "is deterministic for the same input"
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
"file": "backend/tests/unit/idempotency-response-cache.test.ts",
|
|
163
|
+
"name": "injects idempotent_replay: true into a JSON object body"
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
"file": "backend/tests/unit/idempotency-response-cache.test.ts",
|
|
167
|
+
"name": "matches Stripe's 24-hour idempotency window"
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
"file": "sdks/swift/Tests/CrossdeckTests/IdempotencyKeyTests.swift",
|
|
171
|
+
"name": "test_crossSdkOracle_appleJWS"
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
"file": "sdks/swift/Tests/CrossdeckTests/IdempotencyKeyTests.swift",
|
|
175
|
+
"name": "test_railNamespacing_preventsCrossRailCollisions"
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
"file": "sdks/swift/Tests/CrossdeckTests/IdempotencyKeyTests.swift",
|
|
179
|
+
"name": "test_missingIdentifier_returnsNil"
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
"file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/IdempotencyKeyTest.kt",
|
|
183
|
+
"name": "cross-SDK oracle for apple JWS"
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
"file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/IdempotencyKeyTest.kt",
|
|
187
|
+
"name": "rail namespacing prevents cross-rail collisions"
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
"file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/IdempotencyKeyTest.kt",
|
|
191
|
+
"name": "missing identifier returns null - never silent random fallback"
|
|
192
|
+
}
|
|
193
|
+
],
|
|
194
|
+
"registeredAt": "2026-05-26",
|
|
195
|
+
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 — phase 2.2.a + 2.2.b + 2.2.c",
|
|
196
|
+
"bundledIn": "@cross-deck/web@1.5.0"
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
"id": "init-reentry-drains-prior-queue",
|
|
200
|
+
"pillar": "lifecycle",
|
|
201
|
+
"status": "enforced",
|
|
202
|
+
"claim": "Web + RN init() re-entry drains the prior EventQueue's pending setTimeout BEFORE replacing this.state. Pre-v1.4.0 the teardown handled autoTracker/webVitals/errors/unloadFlush but NOT events, so the prior queue's timer would fire AFTER the state swap — sending old-init events against new-init http + identity references (cross-identity leak during HMR / config swap / multi-tenant SDK shells). The teardown CANNOT call persistent.clear() — the durable queue belongs to the SDK lifetime, not the init() lifetime, and a survived crash mid-flush re-hydrates on the next init.",
|
|
203
|
+
"appliesTo": [
|
|
204
|
+
"web",
|
|
205
|
+
"react-native"
|
|
206
|
+
],
|
|
207
|
+
"codeRef": [
|
|
208
|
+
"sdks/web/src/crossdeck.ts",
|
|
209
|
+
"sdks/react-native/src/crossdeck.ts"
|
|
210
|
+
],
|
|
211
|
+
"testRef": [
|
|
212
|
+
{
|
|
213
|
+
"file": "sdks/web/tests/init-reentry.test.ts",
|
|
214
|
+
"name": "re-init drains the prior queue's pending timer before swapping state"
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
"file": "sdks/web/tests/init-reentry.test.ts",
|
|
218
|
+
"name": "re-init does NOT wipe the durable event store"
|
|
219
|
+
}
|
|
220
|
+
],
|
|
221
|
+
"registeredAt": "2026-05-26",
|
|
222
|
+
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 — phase 5.5",
|
|
223
|
+
"bundledIn": "@cross-deck/web@1.5.0"
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
"id": "per-user-cache-isolation",
|
|
227
|
+
"pillar": "entitlements",
|
|
228
|
+
"status": "enforced",
|
|
229
|
+
"claim": "Every identify(userId) switches the entitlement cache to a physically separate per-user storage slot — `crossdeck:entitlements:<sha256(userId)>` — and unconditionally wipes the in-memory snapshot. A user-switch on a shared device CANNOT cross-read a prior user's cached entitlements, even if the in-memory clear is somehow skipped, because the storage keys are physically separate. reset() wipes every per-user slot via the persisted index.",
|
|
230
|
+
"appliesTo": [
|
|
231
|
+
"web",
|
|
232
|
+
"react-native",
|
|
233
|
+
"swift",
|
|
234
|
+
"android"
|
|
235
|
+
],
|
|
236
|
+
"codeRef": [
|
|
237
|
+
"sdks/web/src/entitlement-cache.ts",
|
|
238
|
+
"sdks/web/src/hash.ts",
|
|
239
|
+
"sdks/web/src/crossdeck.ts",
|
|
240
|
+
"sdks/react-native/src/entitlement-cache.ts",
|
|
241
|
+
"sdks/react-native/src/hash.ts",
|
|
242
|
+
"sdks/react-native/src/crossdeck.ts",
|
|
243
|
+
"sdks/swift/Sources/Crossdeck/EntitlementCache.swift",
|
|
244
|
+
"sdks/swift/Sources/Crossdeck/IdempotencyKey.swift",
|
|
245
|
+
"sdks/swift/Sources/Crossdeck/Crossdeck.swift",
|
|
246
|
+
"sdks/android/crossdeck/src/main/kotlin/com/crossdeck/EntitlementCache.kt",
|
|
247
|
+
"sdks/android/crossdeck/src/main/kotlin/com/crossdeck/IdempotencyKey.kt",
|
|
248
|
+
"sdks/android/crossdeck/src/main/kotlin/com/crossdeck/Crossdeck.kt"
|
|
249
|
+
],
|
|
250
|
+
"testRef": [
|
|
251
|
+
{
|
|
252
|
+
"file": "sdks/web/tests/entitlement-cache-isolation.test.ts",
|
|
253
|
+
"name": "identify(B) makes A's entitlements unreachable from in-memory"
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
"file": "sdks/web/tests/entitlement-cache-isolation.test.ts",
|
|
257
|
+
"name": "clearAll() removes every per-user storage key plus the index"
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
"file": "sdks/web/tests/entitlement-cache-isolation.test.ts",
|
|
261
|
+
"name": "a second cache instance reading A's storage suffix CANNOT see B's data"
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
"file": "sdks/react-native/tests/entitlement-cache-isolation.test.ts",
|
|
265
|
+
"name": "identify(B) makes A's entitlements unreachable from in-memory"
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
"file": "sdks/react-native/tests/entitlement-cache-isolation.test.ts",
|
|
269
|
+
"name": "removes every per-user storage key plus the index"
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
"file": "sdks/swift/Tests/CrossdeckTests/EntitlementCacheIsolationTests.swift",
|
|
273
|
+
"name": "test_identifyB_makesAEntitlementsUnreachable"
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
"file": "sdks/swift/Tests/CrossdeckTests/EntitlementCacheIsolationTests.swift",
|
|
277
|
+
"name": "test_identifiedWritesLandUnderPerUserSha256Key"
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
"file": "sdks/swift/Tests/CrossdeckTests/EntitlementCacheIsolationTests.swift",
|
|
281
|
+
"name": "test_clearAll_removesEveryPerUserStorageKeyPlusIndex"
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
"file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/EntitlementCacheIsolationTest.kt",
|
|
285
|
+
"name": "identified writes land under per-user sha256 key"
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
"file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/EntitlementCacheIsolationTest.kt",
|
|
289
|
+
"name": "identify B makes A entitlements unreachable from in-memory"
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
"file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/EntitlementCacheIsolationTest.kt",
|
|
293
|
+
"name": "clearAll removes every per-user storage key plus the index"
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
"file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/EntitlementCacheIsolationTest.kt",
|
|
297
|
+
"name": "a fresh cache bound to A's key CANNOT read B's blob"
|
|
298
|
+
}
|
|
299
|
+
],
|
|
300
|
+
"registeredAt": "2026-05-26",
|
|
301
|
+
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 — phase 1.3 (web/RN) + dogfood-gap fix (swift + android)",
|
|
302
|
+
"bundledIn": "@cross-deck/web@1.5.0"
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
"id": "sdk-error-codes-catalogue",
|
|
306
|
+
"pillar": "errors",
|
|
307
|
+
"status": "enforced",
|
|
308
|
+
"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.",
|
|
309
|
+
"appliesTo": [
|
|
310
|
+
"web",
|
|
311
|
+
"node"
|
|
312
|
+
],
|
|
313
|
+
"codeRef": [
|
|
314
|
+
"sdks/web/src/error-codes.ts",
|
|
315
|
+
"sdks/node/src/error-codes.ts",
|
|
316
|
+
"backend/src/api/v1-errors.ts"
|
|
317
|
+
],
|
|
318
|
+
"testRef": [
|
|
319
|
+
{
|
|
320
|
+
"file": "sdks/web/tests/error-codes-backfill.test.ts",
|
|
321
|
+
"name": "includes backend code"
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
"file": "sdks/web/tests/error-codes-backfill.test.ts",
|
|
325
|
+
"name": "invalid_api_key resolution points at the dashboard"
|
|
326
|
+
},
|
|
327
|
+
{
|
|
328
|
+
"file": "sdks/web/tests/error-codes-backfill.test.ts",
|
|
329
|
+
"name": "idempotency_key_in_use resolution mentions Stripe-grade contract"
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
"file": "sdks/web/tests/error-codes-backfill.test.ts",
|
|
333
|
+
"name": "identity-lock codes carry permission_error type"
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
"file": "sdks/web/tests/error-codes-backfill.test.ts",
|
|
337
|
+
"name": "no entry has an empty description or resolution"
|
|
338
|
+
}
|
|
339
|
+
],
|
|
340
|
+
"registeredAt": "2026-05-26",
|
|
341
|
+
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 — phase 6.2",
|
|
342
|
+
"bundledIn": "@cross-deck/web@1.5.0"
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
"id": "sync-purchases-funnel-parity",
|
|
346
|
+
"pillar": "analytics",
|
|
347
|
+
"status": "enforced",
|
|
348
|
+
"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`.",
|
|
349
|
+
"appliesTo": [
|
|
350
|
+
"web",
|
|
351
|
+
"node",
|
|
352
|
+
"react-native",
|
|
353
|
+
"swift",
|
|
354
|
+
"android"
|
|
355
|
+
],
|
|
356
|
+
"codeRef": [
|
|
357
|
+
"sdks/web/src/crossdeck.ts",
|
|
358
|
+
"sdks/node/src/crossdeck-server.ts",
|
|
359
|
+
"sdks/react-native/src/crossdeck.ts",
|
|
360
|
+
"sdks/swift/Sources/Crossdeck/Crossdeck.swift",
|
|
361
|
+
"sdks/android/crossdeck/src/main/kotlin/com/crossdeck/Crossdeck.kt"
|
|
362
|
+
],
|
|
363
|
+
"testRef": [
|
|
364
|
+
{
|
|
365
|
+
"file": "sdks/web/tests/sync-purchases-funnel.test.ts",
|
|
366
|
+
"name": "emits purchase.completed after a successful sync"
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
"file": "sdks/web/tests/sync-purchases-funnel.test.ts",
|
|
370
|
+
"name": "carries idempotent_replay=true when backend replied from cache"
|
|
371
|
+
}
|
|
372
|
+
],
|
|
373
|
+
"registeredAt": "2026-05-26",
|
|
374
|
+
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 — phase 3.5",
|
|
375
|
+
"bundledIn": "@cross-deck/web@1.5.0"
|
|
376
|
+
}
|
|
377
|
+
]
|
|
378
|
+
}
|