@cross-deck/react-native 1.0.0 → 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/dist/index.mjs CHANGED
@@ -1,3 +1,10 @@
1
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
+ }) : x)(function(x) {
4
+ if (typeof require !== "undefined") return require.apply(this, arguments);
5
+ throw Error('Dynamic require of "' + x + '" is not supported');
6
+ });
7
+
1
8
  // src/errors.ts
2
9
  var CrossdeckError = class _CrossdeckError extends Error {
3
10
  constructor(payload) {
@@ -64,7 +71,7 @@ function typeMapForStatus(status) {
64
71
  }
65
72
 
66
73
  // src/_version.ts
67
- var SDK_VERSION = "1.0.0";
74
+ var SDK_VERSION = "1.5.1";
68
75
  var SDK_NAME = "@cross-deck/react-native";
69
76
 
70
77
  // src/http.ts
@@ -90,6 +97,12 @@ var HttpClient = class {
90
97
  "Crossdeck-Sdk-Version": `${SDK_NAME}@${this.config.sdkVersion}`,
91
98
  Accept: "application/json"
92
99
  };
100
+ if (this.config.bundleId) {
101
+ headers["X-Crossdeck-Bundle-Id"] = this.config.bundleId;
102
+ }
103
+ if (this.config.packageName) {
104
+ headers["X-Crossdeck-Package-Name"] = this.config.packageName;
105
+ }
93
106
  if (options.idempotencyKey) {
94
107
  headers["Idempotency-Key"] = options.idempotencyKey;
95
108
  }
@@ -298,34 +311,230 @@ function randomChars(count) {
298
311
  return out.join("");
299
312
  }
300
313
 
314
+ // src/hash.ts
315
+ var K = new Uint32Array([
316
+ 1116352408,
317
+ 1899447441,
318
+ 3049323471,
319
+ 3921009573,
320
+ 961987163,
321
+ 1508970993,
322
+ 2453635748,
323
+ 2870763221,
324
+ 3624381080,
325
+ 310598401,
326
+ 607225278,
327
+ 1426881987,
328
+ 1925078388,
329
+ 2162078206,
330
+ 2614888103,
331
+ 3248222580,
332
+ 3835390401,
333
+ 4022224774,
334
+ 264347078,
335
+ 604807628,
336
+ 770255983,
337
+ 1249150122,
338
+ 1555081692,
339
+ 1996064986,
340
+ 2554220882,
341
+ 2821834349,
342
+ 2952996808,
343
+ 3210313671,
344
+ 3336571891,
345
+ 3584528711,
346
+ 113926993,
347
+ 338241895,
348
+ 666307205,
349
+ 773529912,
350
+ 1294757372,
351
+ 1396182291,
352
+ 1695183700,
353
+ 1986661051,
354
+ 2177026350,
355
+ 2456956037,
356
+ 2730485921,
357
+ 2820302411,
358
+ 3259730800,
359
+ 3345764771,
360
+ 3516065817,
361
+ 3600352804,
362
+ 4094571909,
363
+ 275423344,
364
+ 430227734,
365
+ 506948616,
366
+ 659060556,
367
+ 883997877,
368
+ 958139571,
369
+ 1322822218,
370
+ 1537002063,
371
+ 1747873779,
372
+ 1955562222,
373
+ 2024104815,
374
+ 2227730452,
375
+ 2361852424,
376
+ 2428436474,
377
+ 2756734187,
378
+ 3204031479,
379
+ 3329325298
380
+ ]);
381
+ function utf8Bytes(input) {
382
+ if (typeof TextEncoder !== "undefined") {
383
+ return new TextEncoder().encode(input);
384
+ }
385
+ const out = [];
386
+ for (let i = 0; i < input.length; i++) {
387
+ let codePoint = input.charCodeAt(i);
388
+ if (codePoint >= 55296 && codePoint <= 56319 && i + 1 < input.length) {
389
+ const next = input.charCodeAt(i + 1);
390
+ if (next >= 56320 && next <= 57343) {
391
+ codePoint = 65536 + (codePoint - 55296 << 10) + (next - 56320);
392
+ i++;
393
+ }
394
+ }
395
+ if (codePoint < 128) {
396
+ out.push(codePoint);
397
+ } else if (codePoint < 2048) {
398
+ out.push(192 | codePoint >> 6);
399
+ out.push(128 | codePoint & 63);
400
+ } else if (codePoint < 65536) {
401
+ out.push(224 | codePoint >> 12);
402
+ out.push(128 | codePoint >> 6 & 63);
403
+ out.push(128 | codePoint & 63);
404
+ } else {
405
+ out.push(240 | codePoint >> 18);
406
+ out.push(128 | codePoint >> 12 & 63);
407
+ out.push(128 | codePoint >> 6 & 63);
408
+ out.push(128 | codePoint & 63);
409
+ }
410
+ }
411
+ return new Uint8Array(out);
412
+ }
413
+ function sha256Hex(input) {
414
+ const bytes = utf8Bytes(input);
415
+ const bitLength = bytes.length * 8;
416
+ const blockCount = Math.floor((bytes.length + 9 + 63) / 64);
417
+ const padded = new Uint8Array(blockCount * 64);
418
+ padded.set(bytes);
419
+ padded[bytes.length] = 128;
420
+ const high = Math.floor(bitLength / 4294967296);
421
+ const low = bitLength >>> 0;
422
+ const lenOffset = padded.length - 8;
423
+ padded[lenOffset + 0] = high >>> 24 & 255;
424
+ padded[lenOffset + 1] = high >>> 16 & 255;
425
+ padded[lenOffset + 2] = high >>> 8 & 255;
426
+ padded[lenOffset + 3] = high & 255;
427
+ padded[lenOffset + 4] = low >>> 24 & 255;
428
+ padded[lenOffset + 5] = low >>> 16 & 255;
429
+ padded[lenOffset + 6] = low >>> 8 & 255;
430
+ padded[lenOffset + 7] = low & 255;
431
+ const H = new Uint32Array([
432
+ 1779033703,
433
+ 3144134277,
434
+ 1013904242,
435
+ 2773480762,
436
+ 1359893119,
437
+ 2600822924,
438
+ 528734635,
439
+ 1541459225
440
+ ]);
441
+ const W = new Uint32Array(64);
442
+ for (let block = 0; block < blockCount; block++) {
443
+ const offset = block * 64;
444
+ for (let t = 0; t < 16; t++) {
445
+ W[t] = (padded[offset + t * 4] << 24 | padded[offset + t * 4 + 1] << 16 | padded[offset + t * 4 + 2] << 8 | padded[offset + t * 4 + 3]) >>> 0;
446
+ }
447
+ for (let t = 16; t < 64; t++) {
448
+ const w15 = W[t - 15];
449
+ const w2 = W[t - 2];
450
+ const s0 = (w15 >>> 7 | w15 << 25) ^ (w15 >>> 18 | w15 << 14) ^ w15 >>> 3;
451
+ const s1 = (w2 >>> 17 | w2 << 15) ^ (w2 >>> 19 | w2 << 13) ^ w2 >>> 10;
452
+ W[t] = W[t - 16] + s0 + W[t - 7] + s1 >>> 0;
453
+ }
454
+ let a = H[0], b = H[1], c = H[2], d = H[3];
455
+ let e = H[4], f = H[5], g = H[6], h = H[7];
456
+ for (let t = 0; t < 64; t++) {
457
+ const S1 = (e >>> 6 | e << 26) ^ (e >>> 11 | e << 21) ^ (e >>> 25 | e << 7);
458
+ const ch = e & f ^ ~e & g;
459
+ const temp1 = h + S1 + ch + K[t] + W[t] >>> 0;
460
+ const S0 = (a >>> 2 | a << 30) ^ (a >>> 13 | a << 19) ^ (a >>> 22 | a << 10);
461
+ const maj = a & b ^ a & c ^ b & c;
462
+ const temp2 = S0 + maj >>> 0;
463
+ h = g;
464
+ g = f;
465
+ f = e;
466
+ e = d + temp1 >>> 0;
467
+ d = c;
468
+ c = b;
469
+ b = a;
470
+ a = temp1 + temp2 >>> 0;
471
+ }
472
+ H[0] = H[0] + a >>> 0;
473
+ H[1] = H[1] + b >>> 0;
474
+ H[2] = H[2] + c >>> 0;
475
+ H[3] = H[3] + d >>> 0;
476
+ H[4] = H[4] + e >>> 0;
477
+ H[5] = H[5] + f >>> 0;
478
+ H[6] = H[6] + g >>> 0;
479
+ H[7] = H[7] + h >>> 0;
480
+ }
481
+ let hex = "";
482
+ for (let i = 0; i < 8; i++) {
483
+ hex += H[i].toString(16).padStart(8, "0");
484
+ }
485
+ return hex;
486
+ }
487
+
301
488
  // src/entitlement-cache.ts
302
489
  var DEFAULT_STALE_AFTER_MS = 24 * 60 * 60 * 1e3;
303
- var EntitlementCache = class {
490
+ var ANON_SUFFIX = "_anon";
491
+ var INDEX_SUFFIX = "_index";
492
+ var EntitlementCache = class _EntitlementCache {
304
493
  /**
305
- * @param storage Device storage adapter.
306
- * @param storageKey Full key the persisted blob lives under.
307
- * @param staleAfterMs Age past which last-known-good is flagged stale
308
- * even without a failed refresh. Default 24h.
494
+ * @param storage Device storage adapter.
495
+ * @param storageKeyPrefix Prefix used to derive per-user storage keys
496
+ * (`<prefix>:<sha256(userId)>`). Default
497
+ * `crossdeck:entitlements`. The trailing
498
+ * user suffix is filled at identify() /
499
+ * reset() time — see [[setUserKey]].
500
+ * @param staleAfterMs Age past which last-known-good is flagged stale
501
+ * even without a failed refresh. Default 24h.
309
502
  */
310
- constructor(storage, storageKey = "crossdeck:entitlements", staleAfterMs = DEFAULT_STALE_AFTER_MS) {
503
+ constructor(storage, storageKeyPrefix = "crossdeck:entitlements", staleAfterMs = DEFAULT_STALE_AFTER_MS) {
311
504
  this.all = [];
312
505
  this.lastUpdated = 0;
313
506
  this.lastRefreshFailedAt = 0;
314
507
  this.listeners = /* @__PURE__ */ new Set();
315
508
  this.listenerErrorCount = 0;
316
- this.hydrated = false;
509
+ this.hydratedSuffixes = /* @__PURE__ */ new Set();
510
+ this.currentSuffix = ANON_SUFFIX;
317
511
  this.storage = storage;
318
- this.storageKey = storageKey;
512
+ this.storageKeyPrefix = storageKeyPrefix;
319
513
  this.staleAfterMs = staleAfterMs;
320
514
  }
515
+ /** The full storage key the current-user blob is persisted under. */
516
+ get storageKey() {
517
+ return `${this.storageKeyPrefix}:${this.currentSuffix}`;
518
+ }
519
+ /** Key of the index blob — a JSON array of every suffix we've
520
+ * written. Used by clearAll() to scope a logout-wipe. */
521
+ get indexKey() {
522
+ return `${this.storageKeyPrefix}:${INDEX_SUFFIX}`;
523
+ }
524
+ /** Derive a stable suffix for a developerUserId via SHA-256. */
525
+ static suffixForUserId(userId) {
526
+ if (userId == null || userId === "") return ANON_SUFFIX;
527
+ return sha256Hex(userId);
528
+ }
321
529
  /**
322
- * Load last-known-good from device storage. Run once during
323
- * `Crossdeck.init()` so `isEntitled()` answers correctly from the
324
- * first call. Any corrupt / unparseable blob degrades silently to
325
- * an empty cache boot must never throw.
530
+ * Load last-known-good from device storage for the CURRENT
531
+ * suffix. Run during `Crossdeck.init()` (anonymous slot) and
532
+ * after every [[setUserKey]] switch. Idempotent per suffix
533
+ * a repeat call for the same suffix is a no-op.
326
534
  */
327
535
  async hydrate() {
328
- if (this.hydrated) return;
536
+ const suffix = this.currentSuffix;
537
+ if (this.hydratedSuffixes.has(suffix)) return;
329
538
  try {
330
539
  const raw = await this.storage.getItem(this.storageKey);
331
540
  if (raw) {
@@ -337,7 +546,33 @@ var EntitlementCache = class {
337
546
  }
338
547
  } catch {
339
548
  }
340
- this.hydrated = true;
549
+ this.hydratedSuffixes.add(suffix);
550
+ }
551
+ /**
552
+ * Switch the cache to a different user's storage slot. Bank-grade
553
+ * three-layer isolation (v1.4.0 Phase 1.3):
554
+ * (a) Physical key separation — `<prefix>:<sha256(userId)>` so
555
+ * a user-switch can't physically read prior user's data
556
+ * even if the in-memory clear was skipped.
557
+ * (b) Unconditional in-memory clear — invoked whenever the
558
+ * active suffix changes, even on same-id re-identify.
559
+ * (c) Re-hydrate from the new slot — a returning user observes
560
+ * their last-known-good cache from storage immediately.
561
+ *
562
+ * Caller (identify() / reset()) MUST `await` this BEFORE the
563
+ * next `setFromList()` so the write lands under the right key.
564
+ */
565
+ async setUserKey(userId) {
566
+ const nextSuffix = _EntitlementCache.suffixForUserId(userId);
567
+ this.all = [];
568
+ this.lastUpdated = 0;
569
+ this.lastRefreshFailedAt = 0;
570
+ if (nextSuffix !== this.currentSuffix) {
571
+ this.currentSuffix = nextSuffix;
572
+ this.hydratedSuffixes.delete(nextSuffix);
573
+ }
574
+ await this.hydrate();
575
+ this.notify();
341
576
  }
342
577
  /**
343
578
  * Sync read — true iff the entitlement is currently granting
@@ -407,19 +642,51 @@ var EntitlementCache = class {
407
642
  this.lastUpdated = Date.now();
408
643
  this.lastRefreshFailedAt = 0;
409
644
  this.persist();
645
+ void this.recordSuffixInIndex(this.currentSuffix);
410
646
  this.notify();
411
647
  }
412
648
  /**
413
- * Wipe used on `reset()` (logout) and on an identity switch.
414
- * Clears BOTH memory and durable storage so a prior user's
415
- * entitlements can never leak to the next person on this device.
649
+ * Wipe the CURRENT user's slot. Used internally when a single
650
+ * user's cache needs to be invalidated. The full-logout path is
651
+ * [[clearAll]].
416
652
  */
417
653
  clear() {
418
654
  this.all = [];
419
655
  this.lastUpdated = 0;
420
656
  this.lastRefreshFailedAt = 0;
657
+ const suffix = this.currentSuffix;
421
658
  void this.storage.removeItem(this.storageKey).catch(() => {
422
659
  });
660
+ void this.removeSuffixFromIndex(suffix);
661
+ this.notify();
662
+ }
663
+ /**
664
+ * Logout-grade wipe — bank-grade contract: removes EVERY per-user
665
+ * entitlement slot the SDK has ever written on this device, then
666
+ * clears the index. Used by `Crossdeck.reset()` so a logout on a
667
+ * shared device can never leave another user's entitlements
668
+ * readable (layer (c) of the v1.4.0 isolation fix).
669
+ *
670
+ * Async to honour the AsyncStorage contract; safe to `void` if
671
+ * the caller doesn't need to await teardown completion.
672
+ */
673
+ async clearAll() {
674
+ this.all = [];
675
+ this.lastUpdated = 0;
676
+ this.lastRefreshFailedAt = 0;
677
+ this.currentSuffix = ANON_SUFFIX;
678
+ this.hydratedSuffixes.clear();
679
+ const suffixes = await this.readIndex();
680
+ await Promise.all(
681
+ suffixes.map(
682
+ (s) => this.storage.removeItem(`${this.storageKeyPrefix}:${s}`).catch(() => {
683
+ })
684
+ )
685
+ );
686
+ await this.storage.removeItem(`${this.storageKeyPrefix}:${ANON_SUFFIX}`).catch(() => {
687
+ });
688
+ await this.storage.removeItem(this.indexKey).catch(() => {
689
+ });
423
690
  this.notify();
424
691
  }
425
692
  /**
@@ -452,6 +719,41 @@ var EntitlementCache = class {
452
719
  void this.storage.setItem(this.storageKey, blob).catch(() => {
453
720
  });
454
721
  }
722
+ /** Read the index of all per-user suffixes the SDK has written. */
723
+ async readIndex() {
724
+ try {
725
+ const raw = await this.storage.getItem(this.indexKey);
726
+ if (!raw) return [];
727
+ const parsed = JSON.parse(raw);
728
+ if (Array.isArray(parsed)) {
729
+ return parsed.filter((x) => typeof x === "string");
730
+ }
731
+ return [];
732
+ } catch {
733
+ return [];
734
+ }
735
+ }
736
+ /** Add a suffix to the persisted index. Idempotent. */
737
+ async recordSuffixInIndex(suffix) {
738
+ const existing = await this.readIndex();
739
+ if (existing.includes(suffix)) return;
740
+ existing.push(suffix);
741
+ await this.storage.setItem(this.indexKey, JSON.stringify(existing)).catch(() => {
742
+ });
743
+ }
744
+ /** Remove a suffix from the persisted index. No-op if absent. */
745
+ async removeSuffixFromIndex(suffix) {
746
+ const existing = await this.readIndex();
747
+ const next = existing.filter((s) => s !== suffix);
748
+ if (next.length === existing.length) return;
749
+ if (next.length === 0) {
750
+ await this.storage.removeItem(this.indexKey).catch(() => {
751
+ });
752
+ } else {
753
+ await this.storage.setItem(this.indexKey, JSON.stringify(next)).catch(() => {
754
+ });
755
+ }
756
+ }
455
757
  notify() {
456
758
  if (this.listeners.size === 0) return;
457
759
  const snapshot = this.all.slice();
@@ -466,6 +768,34 @@ var EntitlementCache = class {
466
768
  }
467
769
  };
468
770
 
771
+ // src/idempotency-key.ts
772
+ function formatAsUuid(hex) {
773
+ return [
774
+ hex.slice(0, 8),
775
+ hex.slice(8, 12),
776
+ hex.slice(12, 16),
777
+ hex.slice(16, 20),
778
+ hex.slice(20, 32)
779
+ ].join("-");
780
+ }
781
+ function deriveIdempotencyKeyForPurchase(body) {
782
+ let identifier;
783
+ if (body.rail === "apple") {
784
+ identifier = body.signedTransactionInfo ?? "";
785
+ } else if (body.rail === "google") {
786
+ identifier = body.purchaseToken ?? "";
787
+ } else {
788
+ identifier = "";
789
+ }
790
+ if (!identifier) {
791
+ throw new Error(
792
+ `deriveIdempotencyKeyForPurchase: no stable identifier in body (rail=${body.rail}). Apple needs signedTransactionInfo; Google needs purchaseToken.`
793
+ );
794
+ }
795
+ const namespaced = `crossdeck:purchases/sync:${body.rail}:${identifier}`;
796
+ return formatAsUuid(sha256Hex(namespaced));
797
+ }
798
+
469
799
  // src/retry-policy.ts
470
800
  var DEFAULT_BASE = 1e3;
471
801
  var DEFAULT_MAX = 6e4;
@@ -1362,6 +1692,56 @@ var BreadcrumbBuffer = class {
1362
1692
  }
1363
1693
  };
1364
1694
 
1695
+ // src/_diagnostic-telemetry.ts
1696
+ var DIAGNOSTIC_TELEMETRY_ENDPOINT = "https://api.cross-deck.com/v1/sdk/diagnostic";
1697
+ var DIAGNOSTIC_TELEMETRY_PUBLISHABLE_KEY = "cd_pub_RELIABILITY_PLACEHOLDER_TO_BE_PROVISIONED";
1698
+ function isDiagnosticTelemetryEnabled() {
1699
+ return !DIAGNOSTIC_TELEMETRY_PUBLISHABLE_KEY.startsWith(
1700
+ "cd_pub_RELIABILITY_PLACEHOLDER"
1701
+ );
1702
+ }
1703
+ var DIAGNOSTIC_TELEMETRY_ALLOWED_KEYS = /* @__PURE__ */ new Set([
1704
+ "contract_id",
1705
+ "sdk_version",
1706
+ "sdk_platform",
1707
+ "failure_reason",
1708
+ "run_context",
1709
+ "run_id",
1710
+ "test_file",
1711
+ "test_name",
1712
+ "device_class"
1713
+ ]);
1714
+ function filterDiagnosticPayload(payload) {
1715
+ const filtered = {};
1716
+ for (const [k, v] of Object.entries(payload)) {
1717
+ if (DIAGNOSTIC_TELEMETRY_ALLOWED_KEYS.has(k) && typeof v === "string") {
1718
+ filtered[k] = v;
1719
+ }
1720
+ }
1721
+ return filtered;
1722
+ }
1723
+ function sendDiagnosticTelemetry(payload) {
1724
+ if (!isDiagnosticTelemetryEnabled()) return;
1725
+ const filtered = filterDiagnosticPayload(payload);
1726
+ if (Object.keys(filtered).length === 0) return;
1727
+ const body = JSON.stringify(filtered);
1728
+ const f = globalThis.fetch;
1729
+ if (typeof f !== "function") return;
1730
+ try {
1731
+ void f(DIAGNOSTIC_TELEMETRY_ENDPOINT, {
1732
+ method: "POST",
1733
+ headers: {
1734
+ "Content-Type": "application/json",
1735
+ Authorization: `Bearer ${DIAGNOSTIC_TELEMETRY_PUBLISHABLE_KEY}`,
1736
+ "Crossdeck-Sdk-Version": `${SDK_NAME}@${SDK_VERSION}`
1737
+ },
1738
+ body
1739
+ }).catch(() => {
1740
+ });
1741
+ } catch {
1742
+ }
1743
+ }
1744
+
1365
1745
  // src/stack-parser.ts
1366
1746
  function parseStack(stack) {
1367
1747
  if (!stack || typeof stack !== "string") return [];
@@ -1833,6 +2213,14 @@ var CrossdeckClient = class {
1833
2213
  this.state.errors?.uninstall();
1834
2214
  } catch {
1835
2215
  }
2216
+ try {
2217
+ this.state.appStateSubscription?.remove();
2218
+ } catch {
2219
+ }
2220
+ try {
2221
+ void this.state.events.flush();
2222
+ } catch {
2223
+ }
1836
2224
  }
1837
2225
  if (!options.publicKey || !options.publicKey.startsWith("cd_pub_")) {
1838
2226
  throw new CrossdeckError({
@@ -1874,11 +2262,20 @@ var CrossdeckClient = class {
1874
2262
  storagePrefix: options.storagePrefix ?? "crossdeck:",
1875
2263
  autoHeartbeat: options.autoHeartbeat ?? true,
1876
2264
  eventFlushBatchSize: options.eventFlushBatchSize ?? 20,
1877
- eventFlushIntervalMs: options.eventFlushIntervalMs ?? 5e3,
2265
+ // v1.4.0 Phase 3.3 — flush interval default parity at 2000ms
2266
+ // across every SDK. Per-instance override stays.
2267
+ eventFlushIntervalMs: options.eventFlushIntervalMs ?? 2e3,
1878
2268
  sdkVersion: options.sdkVersion ?? SDK_VERSION,
1879
2269
  appVersion: options.appVersion ?? null,
1880
2270
  platform: options.platform ?? detectPlatform(),
1881
- timeoutMs: options.timeoutMs ?? 15e3
2271
+ timeoutMs: options.timeoutMs ?? 15e3,
2272
+ // Per-platform identity claims for the bank-grade identity
2273
+ // lock. Empty string means "not supplied" — the HTTP layer
2274
+ // skips the header in that case and the backend will reject
2275
+ // with bundle_id_not_allowed / package_name_not_allowed at
2276
+ // first request if the project requires the lock.
2277
+ bundleId: options.bundleId ?? "",
2278
+ packageName: options.packageName ?? ""
1882
2279
  };
1883
2280
  const debug = new ConsoleDebugLogger();
1884
2281
  debug.enabled = options.debug === true;
@@ -1886,7 +2283,12 @@ var CrossdeckClient = class {
1886
2283
  publicKey: opts.publicKey,
1887
2284
  baseUrl: opts.baseUrl,
1888
2285
  sdkVersion: opts.sdkVersion,
1889
- timeoutMs: opts.timeoutMs
2286
+ timeoutMs: opts.timeoutMs,
2287
+ // Per-platform identity claims — sent as X-Crossdeck-Bundle-Id
2288
+ // / X-Crossdeck-Package-Name. Backend enforces these against
2289
+ // the app key's stored identity (bank-grade fail-closed).
2290
+ bundleId: options.bundleId,
2291
+ packageName: options.packageName
1890
2292
  });
1891
2293
  const effectiveStorage = persistIdentity ? storage : new MemoryStorage();
1892
2294
  const identity = new IdentityStore(effectiveStorage, opts.storagePrefix);
@@ -1951,12 +2353,32 @@ var CrossdeckClient = class {
1951
2353
  options: opts,
1952
2354
  debug,
1953
2355
  developerUserId: null,
2356
+ sessionId: null,
1954
2357
  lastServerTime: null,
1955
2358
  lastClientTime: null,
1956
2359
  started: false,
1957
2360
  hydrated: false,
1958
- ready: Promise.resolve()
2361
+ ready: Promise.resolve(),
2362
+ appStateSubscription: null
1959
2363
  };
2364
+ try {
2365
+ const RN = __require("react-native");
2366
+ const AppState = RN?.AppState;
2367
+ if (AppState && typeof AppState.addEventListener === "function") {
2368
+ const sub = AppState.addEventListener("change", (next) => {
2369
+ if (next === "background" || next === "inactive") {
2370
+ try {
2371
+ void this.state?.events.flush().catch(() => {
2372
+ });
2373
+ debug.emit("sdk.queue_persisted", "persisted on AppState background");
2374
+ } catch {
2375
+ }
2376
+ }
2377
+ });
2378
+ this.state.appStateSubscription = sub;
2379
+ }
2380
+ } catch {
2381
+ }
1960
2382
  const wantErrorCapture = options.errorCapture !== false;
1961
2383
  if (wantErrorCapture) {
1962
2384
  const tracker = new ErrorTracker({
@@ -2033,14 +2455,10 @@ var CrossdeckClient = class {
2033
2455
  };
2034
2456
  if (options?.email) body.email = options.email;
2035
2457
  if (traits) body.traits = traits;
2458
+ await s.entitlements.setUserKey(userId);
2036
2459
  const result = await s.http.request("POST", "/identity/alias", {
2037
2460
  body
2038
2461
  });
2039
- const priorCdcust = s.identity.crossdeckCustomerId;
2040
- const cacheHasEntries = s.entitlements.list().length > 0;
2041
- if (priorCdcust && result.crossdeckCustomerId && priorCdcust !== result.crossdeckCustomerId || !priorCdcust && cacheHasEntries) {
2042
- s.entitlements.clear();
2043
- }
2044
2462
  s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
2045
2463
  s.identity.setDeveloperUserId(userId);
2046
2464
  s.developerUserId = userId;
@@ -2255,6 +2673,34 @@ var CrossdeckClient = class {
2255
2673
  * stamped. Common-case `track()` after hydration runs entirely
2256
2674
  * synchronously.
2257
2675
  */
2676
+ /**
2677
+ * Emit `crossdeck.contract_failed` to the Crossdeck reliability
2678
+ * endpoint — single-fire, one-way, never visible in the customer's
2679
+ * dashboard. Goes over a dedicated HTTP path with the reliability
2680
+ * publishable key embedded at build time; the customer's track()
2681
+ * pipeline never carries `crossdeck.*` events. This is the
2682
+ * independent-controller flow described in Privacy Policy §6
2683
+ * ("Flow B"). The wire shape is fixed by the schema-lock contract
2684
+ * at `contracts/diagnostics/contract-failed-payload-schema-lock.json`.
2685
+ */
2686
+ reportContractFailure(input) {
2687
+ const payload = {
2688
+ contract_id: input.contractId,
2689
+ sdk_version: SDK_VERSION,
2690
+ sdk_platform: "react-native",
2691
+ failure_reason: input.failureReason,
2692
+ run_context: input.runContext,
2693
+ run_id: input.runId
2694
+ };
2695
+ if (input.testRef) {
2696
+ payload.test_file = input.testRef.file;
2697
+ payload.test_name = input.testRef.name;
2698
+ }
2699
+ if (input.deviceClass) {
2700
+ payload.device_class = input.deviceClass;
2701
+ }
2702
+ sendDiagnosticTelemetry(payload);
2703
+ }
2258
2704
  track(name, properties) {
2259
2705
  const s = this.requireStarted();
2260
2706
  if (!name) {
@@ -2264,11 +2710,14 @@ var CrossdeckClient = class {
2264
2710
  message: "track(name) requires a non-empty name."
2265
2711
  });
2266
2712
  }
2713
+ const callTimeSnapshot = {
2714
+ sessionId: s.sessionId
2715
+ };
2267
2716
  if (!s.hydrated) {
2268
- void s.ready.then(() => this.trackPostHydration(s, name, properties));
2717
+ void s.ready.then(() => this.trackPostHydration(s, name, properties, callTimeSnapshot));
2269
2718
  return;
2270
2719
  }
2271
- this.trackPostHydration(s, name, properties);
2720
+ this.trackPostHydration(s, name, properties, callTimeSnapshot);
2272
2721
  }
2273
2722
  /**
2274
2723
  * The body of `track()` — everything after the synchronous
@@ -2276,7 +2725,7 @@ var CrossdeckClient = class {
2276
2725
  * portion until async identity hydration completes (RN-specific —
2277
2726
  * see `track()` jsdoc).
2278
2727
  */
2279
- trackPostHydration(s, name, properties) {
2728
+ trackPostHydration(s, name, properties, callTimeSnapshot) {
2280
2729
  const isError = name.startsWith("error.");
2281
2730
  const consentGateOk = isError ? s.consent.errors : s.consent.analytics;
2282
2731
  if (!consentGateOk) {
@@ -2323,6 +2772,9 @@ var CrossdeckClient = class {
2323
2772
  if (Object.keys(groupIds).length > 0) {
2324
2773
  enriched.$groups = groupIds;
2325
2774
  }
2775
+ if (callTimeSnapshot.sessionId) {
2776
+ enriched.sessionId = callTimeSnapshot.sessionId;
2777
+ }
2326
2778
  Object.assign(enriched, validation.properties);
2327
2779
  const finalProperties = s.scrubPii ? scrubPiiFromProperties(enriched) : enriched;
2328
2780
  const event = {
@@ -2373,14 +2825,24 @@ var CrossdeckClient = class {
2373
2825
  message: "syncPurchases (google) requires a purchaseToken string from Google Billing."
2374
2826
  });
2375
2827
  }
2828
+ const body = { ...input, rail };
2829
+ const idempotencyKey = deriveIdempotencyKeyForPurchase(body);
2376
2830
  const result = await s.http.request("POST", "/purchases/sync", {
2377
- // Spread input FIRST so the explicit `rail` default below wins
2378
- // — `{ ...input, rail }` puts the default last so an explicit
2379
- // `rail: undefined` from the caller doesn't override.
2380
- body: { ...input, rail }
2831
+ body,
2832
+ idempotencyKey
2381
2833
  });
2382
2834
  s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
2383
2835
  s.entitlements.setFromList(result.entitlements);
2836
+ try {
2837
+ const sourceProductId = result.entitlements[0]?.source.productId;
2838
+ const sourceSubscriptionId = result.entitlements[0]?.source.subscriptionId;
2839
+ const props = { rail };
2840
+ if (sourceProductId) props.productId = sourceProductId;
2841
+ if (sourceSubscriptionId) props.subscriptionId = sourceSubscriptionId;
2842
+ if (result.idempotent_replay) props.idempotent_replay = true;
2843
+ this.track("purchase.completed", props);
2844
+ } catch {
2845
+ }
2384
2846
  s.debug.emit(
2385
2847
  "sdk.purchase_evidence_sent",
2386
2848
  `${rail === "apple" ? "StoreKit" : "Google Billing"} purchase evidence forwarded. Waiting for backend verification.`,
@@ -2388,6 +2850,42 @@ var CrossdeckClient = class {
2388
2850
  );
2389
2851
  return result;
2390
2852
  }
2853
+ /**
2854
+ * v1.4.0 Phase 3.4 — set the active session id. RN doesn't own
2855
+ * session lifecycle (that's the host's AppState + nav library);
2856
+ * the host calls `setSessionId()` from its AppState change
2857
+ * listener so every subsequent `track()` event carries the
2858
+ * `sessionId` property — matches the web SDK's session-anchored
2859
+ * funnel queries.
2860
+ *
2861
+ * ```ts
2862
+ * import { AppState } from "react-native";
2863
+ *
2864
+ * let sessionId = uuid();
2865
+ * AppState.addEventListener("change", (next) => {
2866
+ * if (next === "active") {
2867
+ * // New session if backgrounded > 30 min.
2868
+ * sessionId = uuid();
2869
+ * Crossdeck.setSessionId(sessionId);
2870
+ * } else if (next === "background") {
2871
+ * void Crossdeck.flush();
2872
+ * }
2873
+ * });
2874
+ * Crossdeck.setSessionId(sessionId);
2875
+ * ```
2876
+ *
2877
+ * Pass `null` to clear (between sessions, on logout, etc).
2878
+ */
2879
+ setSessionId(sessionId) {
2880
+ const s = this.requireStarted();
2881
+ s.sessionId = sessionId ?? null;
2882
+ if (s.debug.enabled) {
2883
+ s.debug.emit(
2884
+ "sdk.configured",
2885
+ sessionId ? `Session id set to ${sessionId}; subsequent track events will carry it.` : "Session id cleared; subsequent track events will omit it."
2886
+ );
2887
+ }
2888
+ }
2391
2889
  /** Toggle verbose diagnostic logging. */
2392
2890
  setDebugMode(enabled) {
2393
2891
  const s = this.requireStarted();
@@ -2430,7 +2928,7 @@ var CrossdeckClient = class {
2430
2928
  }
2431
2929
  }
2432
2930
  this.state.identity.reset();
2433
- this.state.entitlements.clear();
2931
+ void this.state.entitlements.clearAll();
2434
2932
  this.state.events.reset();
2435
2933
  this.state.superProps.clear();
2436
2934
  this.state.breadcrumbs.clear();
@@ -2555,10 +3053,534 @@ function detectPlatform() {
2555
3053
  return "web";
2556
3054
  }
2557
3055
  }
3056
+
3057
+ // src/_contracts-bundled.ts
3058
+ var BUNDLED_IN = "@cross-deck/react-native@1.5.1";
3059
+ var SDK_VERSION2 = "1.5.1";
3060
+ var BUNDLED_CONTRACTS = Object.freeze([
3061
+ {
3062
+ "id": "contract-failed-payload-schema-lock",
3063
+ "pillar": "diagnostics",
3064
+ "status": "enforced",
3065
+ "claim": "The `crossdeck.contract_failed` event payload contains ONLY the named diagnostic fields and never any end-user personal data. The wire shape is fixed \u2014 adding a new field requires (1) a pull request that updates this contract's `allowedFields` set, (2) a Privacy Policy \xA76 amendment, and (3) the Customer Disclosure Template / SDK Data Collection Reference \xA7B updates. Per-SDK assertion tests enforce the field set on every release. The `verification_phase` field is a categorical bucket \u2014 values are restricted to `boot` (the SDK self-test ran on Crossdeck.start) or `hot_path` (a verifier observed a real customer-triggered operation). The categorical nature is what preserves the diagnostic-only-not-personal classification. This is the structural guarantee that backs the independent-controller lawful basis in the Privacy Policy: the payload remains diagnostic-only, not personal, so the legitimate-interest analysis stays valid as the SDK evolves.",
3066
+ "appliesTo": [
3067
+ "web",
3068
+ "node",
3069
+ "swift",
3070
+ "android",
3071
+ "react-native"
3072
+ ],
3073
+ "allowedFields": {
3074
+ "required": [
3075
+ "contract_id",
3076
+ "sdk_version",
3077
+ "sdk_platform",
3078
+ "failure_reason",
3079
+ "run_context",
3080
+ "run_id"
3081
+ ],
3082
+ "optional": [
3083
+ "test_file",
3084
+ "test_name",
3085
+ "device_class",
3086
+ "verification_phase"
3087
+ ],
3088
+ "forbidden": [
3089
+ "anonymousId",
3090
+ "developerUserId",
3091
+ "crossdeckCustomerId",
3092
+ "email",
3093
+ "ip",
3094
+ "user_agent",
3095
+ "message",
3096
+ "stack",
3097
+ "stack_trace",
3098
+ "frames",
3099
+ "exception_message",
3100
+ "url",
3101
+ "path",
3102
+ "screen",
3103
+ "title",
3104
+ "label",
3105
+ "text",
3106
+ "ariaLabel",
3107
+ "accessibilityLabel",
3108
+ "contentDescription",
3109
+ "session_id",
3110
+ "sessionId"
3111
+ ]
3112
+ },
3113
+ "transport": "Telemetry is single-fire to the Crossdeck reliability endpoint only \u2014 NOT the customer's appId. The customer's track() pipeline never carries `crossdeck.*` events; the customer's dashboard never shows individual contract failures. Operational telemetry flows one-way to the Crossdeck operations team for SDK reliability purposes (legitimate interest, independent-controller flow per Privacy Policy \xA76). The reliability endpoint is hardcoded at SDK build time; the publishable key for the reliability project is embedded as a constant and rejects writes that don't match the schema.",
3114
+ "codeRef": [
3115
+ "sdks/web/src/crossdeck.ts",
3116
+ "sdks/node/src/crossdeck-server.ts",
3117
+ "sdks/swift/Sources/Crossdeck/Crossdeck.swift",
3118
+ "sdks/swift/Sources/Crossdeck/_DiagnosticTelemetry.swift",
3119
+ "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/Crossdeck.kt",
3120
+ "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/_DiagnosticTelemetry.kt",
3121
+ "sdks/react-native/src/crossdeck.ts",
3122
+ "backend/src/api/v1-sdk-diagnostic.ts",
3123
+ "sdks/web/src/_diagnostic-telemetry.ts",
3124
+ "sdks/node/src/_diagnostic-telemetry.ts",
3125
+ "sdks/react-native/src/_diagnostic-telemetry.ts"
3126
+ ],
3127
+ "testRef": [
3128
+ {
3129
+ "file": "sdks/web/tests/contract-failed-schema-lock.test.ts",
3130
+ "name": "reportContractFailure payload conforms to schema-lock"
3131
+ },
3132
+ {
3133
+ "file": "sdks/node/tests/contract-failed-schema-lock.test.ts",
3134
+ "name": "reportContractFailure payload conforms to schema-lock"
3135
+ },
3136
+ {
3137
+ "file": "sdks/swift/Tests/CrossdeckTests/ContractFailedSchemaLockTests.swift",
3138
+ "name": "test_reportContractFailure_payloadFieldsAreInAllowList"
3139
+ },
3140
+ {
3141
+ "file": "sdks/swift/Tests/CrossdeckTests/ContractFailedSchemaLockTests.swift",
3142
+ "name": "test_reportContractFailure_doesNotEnterCustomerTrackPipeline"
3143
+ },
3144
+ {
3145
+ "file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/ContractFailedSchemaLockTest.kt",
3146
+ "name": "reportContractFailure payload conforms to schema-lock"
3147
+ },
3148
+ {
3149
+ "file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/ContractFailedSchemaLockTest.kt",
3150
+ "name": "reportContractFailure does not enter customer track pipeline"
3151
+ },
3152
+ {
3153
+ "file": "sdks/react-native/tests/contract-failed-schema-lock.test.ts",
3154
+ "name": "reportContractFailure payload conforms to schema-lock"
3155
+ },
3156
+ {
3157
+ "file": "backend/tests/unit/v1-sdk-diagnostic.test.ts",
3158
+ "name": "forbidden fields are enumerated in the schema-lock contract"
3159
+ },
3160
+ {
3161
+ "file": "backend/tests/unit/v1-sdk-diagnostic.test.ts",
3162
+ "name": "required fields are enumerated in the schema-lock contract"
3163
+ },
3164
+ {
3165
+ "file": "backend/tests/unit/v1-sdk-diagnostic.test.ts",
3166
+ "name": "regression guard: never returns a raw IP"
3167
+ },
3168
+ {
3169
+ "file": "backend/tests/unit/v1-sdk-diagnostic.test.ts",
3170
+ "name": "verification_phase is in the optional field set"
3171
+ }
3172
+ ],
3173
+ "registeredAt": "2026-05-27",
3174
+ "firstRegisteredIn": "Diagnostic telemetry single-fire + schema-lock \u2014 independent-controller flow",
3175
+ "privacyReferences": [
3176
+ "legal/privacy/index.html#sdk-diagnostic",
3177
+ "legal/customer-disclosure/index.html#flow-b",
3178
+ "legal/security/index.html#diagnostic",
3179
+ "legal/sdk-data/index.html#b-diagnostic"
3180
+ ],
3181
+ "bundledIn": "@cross-deck/react-native@1.5.1"
3182
+ },
3183
+ {
3184
+ "id": "error-envelope-shape",
3185
+ "pillar": "errors",
3186
+ "status": "enforced",
3187
+ "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.",
3188
+ "appliesTo": [
3189
+ "web",
3190
+ "node",
3191
+ "react-native",
3192
+ "swift",
3193
+ "android",
3194
+ "backend"
3195
+ ],
3196
+ "codeRef": [
3197
+ "backend/src/api/v1-errors.ts",
3198
+ "sdks/web/src/errors.ts",
3199
+ "sdks/node/src/errors.ts",
3200
+ "sdks/react-native/src/errors.ts",
3201
+ "sdks/swift/Sources/Crossdeck/Errors.swift",
3202
+ "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/Errors.kt"
3203
+ ],
3204
+ "testRef": [
3205
+ {
3206
+ "file": "sdks/swift/Tests/CrossdeckTests/ErrorsTests.swift",
3207
+ "name": "test_errorEnvelope_fallsBackOnGarbageBody"
3208
+ },
3209
+ {
3210
+ "file": "sdks/swift/Tests/CrossdeckTests/ErrorsTests.swift",
3211
+ "name": "test_errorEnvelope_reads_XRequestId_fallback"
3212
+ },
3213
+ {
3214
+ "file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/ErrorTypeWireVocabTest.kt",
3215
+ "name": "backend 500 response parses to INTERNAL_ERROR"
3216
+ }
3217
+ ],
3218
+ "registeredAt": "2026-05-26",
3219
+ "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 8 (codifies existing contract)",
3220
+ "bundledIn": "@cross-deck/react-native@1.5.1"
3221
+ },
3222
+ {
3223
+ "id": "flush-interval-parity",
3224
+ "pillar": "analytics",
3225
+ "status": "enforced",
3226
+ "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.",
3227
+ "appliesTo": [
3228
+ "web",
3229
+ "node",
3230
+ "react-native",
3231
+ "swift",
3232
+ "android"
3233
+ ],
3234
+ "codeRef": [
3235
+ "sdks/web/src/crossdeck.ts",
3236
+ "sdks/node/src/crossdeck-server.ts",
3237
+ "sdks/react-native/src/crossdeck.ts",
3238
+ "sdks/swift/Sources/Crossdeck/EventQueue.swift",
3239
+ "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/EventQueue.kt"
3240
+ ],
3241
+ "testRef": [
3242
+ {
3243
+ "file": "sdks/swift/Sources/Crossdeck/EventQueue.swift",
3244
+ "name": "flushIntervalMs: Int = 2_000"
3245
+ },
3246
+ {
3247
+ "file": "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/EventQueue.kt",
3248
+ "name": "flushIntervalMs: Long = 2_000L"
3249
+ },
3250
+ {
3251
+ "file": "sdks/web/src/crossdeck.ts",
3252
+ "name": "options.eventFlushIntervalMs ?? 2000"
3253
+ },
3254
+ {
3255
+ "file": "sdks/node/src/crossdeck-server.ts",
3256
+ "name": "options.eventFlushIntervalMs ?? 2000"
3257
+ },
3258
+ {
3259
+ "file": "sdks/react-native/src/crossdeck.ts",
3260
+ "name": "options.eventFlushIntervalMs ?? 2000"
3261
+ }
3262
+ ],
3263
+ "registeredAt": "2026-05-26",
3264
+ "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 3.3",
3265
+ "bundledIn": "@cross-deck/react-native@1.5.1"
3266
+ },
3267
+ {
3268
+ "id": "idempotency-key-deterministic",
3269
+ "pillar": "revenue",
3270
+ "status": "enforced",
3271
+ "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.",
3272
+ "appliesTo": [
3273
+ "web",
3274
+ "node",
3275
+ "react-native",
3276
+ "swift",
3277
+ "android",
3278
+ "backend"
3279
+ ],
3280
+ "codeRef": [
3281
+ "sdks/web/src/idempotency-key.ts",
3282
+ "sdks/web/src/crossdeck.ts",
3283
+ "sdks/react-native/src/idempotency-key.ts",
3284
+ "sdks/react-native/src/crossdeck.ts",
3285
+ "sdks/node/src/idempotency-key.ts",
3286
+ "sdks/node/src/crossdeck-server.ts",
3287
+ "sdks/swift/Sources/Crossdeck/IdempotencyKey.swift",
3288
+ "sdks/swift/Sources/Crossdeck/Crossdeck.swift",
3289
+ "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/IdempotencyKey.kt",
3290
+ "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/Crossdeck.kt",
3291
+ "backend/src/lib/idempotency-response-cache.ts",
3292
+ "backend/src/api/v1-purchases.ts"
3293
+ ],
3294
+ "testRef": [
3295
+ {
3296
+ "file": "sdks/web/tests/idempotency-key.test.ts",
3297
+ "name": "cross-SDK oracle \u2014 apple JWS pins canonical vector"
3298
+ },
3299
+ {
3300
+ "file": "sdks/web/tests/idempotency-key.test.ts",
3301
+ "name": "is deterministic: same body twice -> identical key"
3302
+ },
3303
+ {
3304
+ "file": "sdks/web/tests/idempotency-key.test.ts",
3305
+ "name": "same identifier under different rails -> different keys"
3306
+ },
3307
+ {
3308
+ "file": "sdks/web/tests/idempotency-key.test.ts",
3309
+ "name": "never silently falls back to a random key on missing identifier"
3310
+ },
3311
+ {
3312
+ "file": "sdks/react-native/tests/idempotency-key.test.ts",
3313
+ "name": "is deterministic"
3314
+ },
3315
+ {
3316
+ "file": "sdks/react-native/tests/idempotency-key.test.ts",
3317
+ "name": "cross-SDK oracle \u2014 apple JWS pins canonical vector"
3318
+ },
3319
+ {
3320
+ "file": "sdks/node/tests/idempotency-key.test.ts",
3321
+ "name": "is deterministic"
3322
+ },
3323
+ {
3324
+ "file": "sdks/node/tests/idempotency-key.test.ts",
3325
+ "name": "rail namespacing prevents cross-rail collisions"
3326
+ },
3327
+ {
3328
+ "file": "sdks/node/tests/idempotency-key.test.ts",
3329
+ "name": "apple JWS produces the canonical pinned UUID across all 5 SDKs"
3330
+ },
3331
+ {
3332
+ "file": "backend/tests/unit/idempotency-response-cache.test.ts",
3333
+ "name": "is deterministic for the same input"
3334
+ },
3335
+ {
3336
+ "file": "backend/tests/unit/idempotency-response-cache.test.ts",
3337
+ "name": "injects idempotent_replay: true into a JSON object body"
3338
+ },
3339
+ {
3340
+ "file": "backend/tests/unit/idempotency-response-cache.test.ts",
3341
+ "name": "matches Stripe's 24-hour idempotency window"
3342
+ },
3343
+ {
3344
+ "file": "sdks/swift/Tests/CrossdeckTests/IdempotencyKeyTests.swift",
3345
+ "name": "test_crossSdkOracle_appleJWS"
3346
+ },
3347
+ {
3348
+ "file": "sdks/swift/Tests/CrossdeckTests/IdempotencyKeyTests.swift",
3349
+ "name": "test_railNamespacing_preventsCrossRailCollisions"
3350
+ },
3351
+ {
3352
+ "file": "sdks/swift/Tests/CrossdeckTests/IdempotencyKeyTests.swift",
3353
+ "name": "test_missingIdentifier_returnsNil"
3354
+ },
3355
+ {
3356
+ "file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/IdempotencyKeyTest.kt",
3357
+ "name": "cross-SDK oracle for apple JWS"
3358
+ },
3359
+ {
3360
+ "file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/IdempotencyKeyTest.kt",
3361
+ "name": "rail namespacing prevents cross-rail collisions"
3362
+ },
3363
+ {
3364
+ "file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/IdempotencyKeyTest.kt",
3365
+ "name": "missing identifier returns null - never silent random fallback"
3366
+ }
3367
+ ],
3368
+ "registeredAt": "2026-05-26",
3369
+ "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 2.2.a + 2.2.b + 2.2.c",
3370
+ "bundledIn": "@cross-deck/react-native@1.5.1"
3371
+ },
3372
+ {
3373
+ "id": "init-reentry-drains-prior-queue",
3374
+ "pillar": "lifecycle",
3375
+ "status": "enforced",
3376
+ "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 \u2014 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() \u2014 the durable queue belongs to the SDK lifetime, not the init() lifetime, and a survived crash mid-flush re-hydrates on the next init.",
3377
+ "appliesTo": [
3378
+ "web",
3379
+ "react-native"
3380
+ ],
3381
+ "codeRef": [
3382
+ "sdks/web/src/crossdeck.ts",
3383
+ "sdks/react-native/src/crossdeck.ts"
3384
+ ],
3385
+ "testRef": [
3386
+ {
3387
+ "file": "sdks/web/tests/init-reentry.test.ts",
3388
+ "name": "re-init drains the prior queue's pending timer before swapping state"
3389
+ },
3390
+ {
3391
+ "file": "sdks/web/tests/init-reentry.test.ts",
3392
+ "name": "re-init does NOT wipe the durable event store"
3393
+ }
3394
+ ],
3395
+ "registeredAt": "2026-05-26",
3396
+ "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 5.5",
3397
+ "bundledIn": "@cross-deck/react-native@1.5.1"
3398
+ },
3399
+ {
3400
+ "id": "per-user-cache-isolation",
3401
+ "pillar": "entitlements",
3402
+ "status": "enforced",
3403
+ "claim": "Every identify(userId) switches the entitlement cache to a physically separate per-user storage slot \u2014 `crossdeck:entitlements:<sha256(userId)>` \u2014 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.",
3404
+ "appliesTo": [
3405
+ "web",
3406
+ "react-native",
3407
+ "swift",
3408
+ "android"
3409
+ ],
3410
+ "codeRef": [
3411
+ "sdks/web/src/entitlement-cache.ts",
3412
+ "sdks/web/src/hash.ts",
3413
+ "sdks/web/src/crossdeck.ts",
3414
+ "sdks/react-native/src/entitlement-cache.ts",
3415
+ "sdks/react-native/src/hash.ts",
3416
+ "sdks/react-native/src/crossdeck.ts",
3417
+ "sdks/swift/Sources/Crossdeck/EntitlementCache.swift",
3418
+ "sdks/swift/Sources/Crossdeck/IdempotencyKey.swift",
3419
+ "sdks/swift/Sources/Crossdeck/Crossdeck.swift",
3420
+ "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/EntitlementCache.kt",
3421
+ "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/IdempotencyKey.kt",
3422
+ "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/Crossdeck.kt"
3423
+ ],
3424
+ "testRef": [
3425
+ {
3426
+ "file": "sdks/web/tests/entitlement-cache-isolation.test.ts",
3427
+ "name": "identify(B) makes A's entitlements unreachable from in-memory"
3428
+ },
3429
+ {
3430
+ "file": "sdks/web/tests/entitlement-cache-isolation.test.ts",
3431
+ "name": "clearAll() removes every per-user storage key plus the index"
3432
+ },
3433
+ {
3434
+ "file": "sdks/web/tests/entitlement-cache-isolation.test.ts",
3435
+ "name": "a second cache instance reading A's storage suffix CANNOT see B's data"
3436
+ },
3437
+ {
3438
+ "file": "sdks/react-native/tests/entitlement-cache-isolation.test.ts",
3439
+ "name": "identify(B) makes A's entitlements unreachable from in-memory"
3440
+ },
3441
+ {
3442
+ "file": "sdks/react-native/tests/entitlement-cache-isolation.test.ts",
3443
+ "name": "removes every per-user storage key plus the index"
3444
+ },
3445
+ {
3446
+ "file": "sdks/swift/Tests/CrossdeckTests/EntitlementCacheIsolationTests.swift",
3447
+ "name": "test_identifyB_makesAEntitlementsUnreachable"
3448
+ },
3449
+ {
3450
+ "file": "sdks/swift/Tests/CrossdeckTests/EntitlementCacheIsolationTests.swift",
3451
+ "name": "test_identifiedWritesLandUnderPerUserSha256Key"
3452
+ },
3453
+ {
3454
+ "file": "sdks/swift/Tests/CrossdeckTests/EntitlementCacheIsolationTests.swift",
3455
+ "name": "test_clearAll_removesEveryPerUserStorageKeyPlusIndex"
3456
+ },
3457
+ {
3458
+ "file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/EntitlementCacheIsolationTest.kt",
3459
+ "name": "identified writes land under per-user sha256 key"
3460
+ },
3461
+ {
3462
+ "file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/EntitlementCacheIsolationTest.kt",
3463
+ "name": "identify B makes A entitlements unreachable from in-memory"
3464
+ },
3465
+ {
3466
+ "file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/EntitlementCacheIsolationTest.kt",
3467
+ "name": "clearAll removes every per-user storage key plus the index"
3468
+ },
3469
+ {
3470
+ "file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/EntitlementCacheIsolationTest.kt",
3471
+ "name": "a fresh cache bound to A's key CANNOT read B's blob"
3472
+ }
3473
+ ],
3474
+ "registeredAt": "2026-05-26",
3475
+ "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 1.3 (web/RN) + dogfood-gap fix (swift + android)",
3476
+ "bundledIn": "@cross-deck/react-native@1.5.1"
3477
+ },
3478
+ {
3479
+ "id": "rn-session-id-enrichment",
3480
+ "pillar": "analytics",
3481
+ "status": "enforced",
3482
+ "claim": "RN SDK's track() pipeline attaches a `sessionId` property to every event when the host has called `setSessionId(...)` \u2014 parity with the web SDK's session-anchored funnel queries. Pre-v1.4.0 the enrichment merged device + super + groups + caller but never carried sessionId, so cross-platform funnels on session anchors returned zero RN rows. The host owns session lifecycle (AppState + nav library); the SDK exposes setSessionId() / setSessionId(null) for the host to drive. Caller-supplied sessionId in properties still wins on conflict (matches the Phase 3.2 caller > super > device precedence chain).",
3483
+ "appliesTo": [
3484
+ "react-native"
3485
+ ],
3486
+ "codeRef": [
3487
+ "sdks/react-native/src/crossdeck.ts"
3488
+ ],
3489
+ "testRef": [
3490
+ {
3491
+ "file": "sdks/react-native/tests/session-id-enrichment.test.ts",
3492
+ "name": "track() events carry sessionId after setSessionId() is called"
3493
+ },
3494
+ {
3495
+ "file": "sdks/react-native/tests/session-id-enrichment.test.ts",
3496
+ "name": "track() events do NOT carry sessionId before setSessionId() is called"
3497
+ },
3498
+ {
3499
+ "file": "sdks/react-native/tests/session-id-enrichment.test.ts",
3500
+ "name": "setSessionId(null) clears the active session"
3501
+ },
3502
+ {
3503
+ "file": "sdks/react-native/tests/session-id-enrichment.test.ts",
3504
+ "name": "caller-supplied sessionId property overrides setSessionId() value (Phase 3.2 precedence)"
3505
+ }
3506
+ ],
3507
+ "registeredAt": "2026-05-26",
3508
+ "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 3.4",
3509
+ "bundledIn": "@cross-deck/react-native@1.5.1"
3510
+ },
3511
+ {
3512
+ "id": "sync-purchases-funnel-parity",
3513
+ "pillar": "analytics",
3514
+ "status": "enforced",
3515
+ "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`.",
3516
+ "appliesTo": [
3517
+ "web",
3518
+ "node",
3519
+ "react-native",
3520
+ "swift",
3521
+ "android"
3522
+ ],
3523
+ "codeRef": [
3524
+ "sdks/web/src/crossdeck.ts",
3525
+ "sdks/node/src/crossdeck-server.ts",
3526
+ "sdks/react-native/src/crossdeck.ts",
3527
+ "sdks/swift/Sources/Crossdeck/Crossdeck.swift",
3528
+ "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/Crossdeck.kt"
3529
+ ],
3530
+ "testRef": [
3531
+ {
3532
+ "file": "sdks/web/tests/sync-purchases-funnel.test.ts",
3533
+ "name": "emits purchase.completed after a successful sync"
3534
+ },
3535
+ {
3536
+ "file": "sdks/web/tests/sync-purchases-funnel.test.ts",
3537
+ "name": "carries idempotent_replay=true when backend replied from cache"
3538
+ }
3539
+ ],
3540
+ "registeredAt": "2026-05-26",
3541
+ "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 3.5",
3542
+ "bundledIn": "@cross-deck/react-native@1.5.1"
3543
+ }
3544
+ ]);
3545
+
3546
+ // src/contracts.ts
3547
+ var CrossdeckContracts = {
3548
+ all() {
3549
+ return BUNDLED_CONTRACTS.filter((c) => c.status === "enforced");
3550
+ },
3551
+ allIncludingHistorical() {
3552
+ return BUNDLED_CONTRACTS;
3553
+ },
3554
+ byId(id) {
3555
+ return BUNDLED_CONTRACTS.find((c) => c.id === id);
3556
+ },
3557
+ byPillar(pillar) {
3558
+ return BUNDLED_CONTRACTS.filter(
3559
+ (c) => c.pillar === pillar && c.status === "enforced"
3560
+ );
3561
+ },
3562
+ withStatus(status) {
3563
+ return BUNDLED_CONTRACTS.filter((c) => c.status === status);
3564
+ },
3565
+ sdkVersion: SDK_VERSION2,
3566
+ bundledIn: BUNDLED_IN,
3567
+ /**
3568
+ * Resolve a failing test back to the contract it exercises.
3569
+ * Used by test-framework hooks to find the contract id of a
3570
+ * failed contract test so `reportContractFailure(...)` can stamp
3571
+ * the right `contract_id` on the emitted event.
3572
+ */
3573
+ findByTestName(name) {
3574
+ return BUNDLED_CONTRACTS.find(
3575
+ (c) => c.testRef.some((ref) => ref.name === name)
3576
+ );
3577
+ }
3578
+ };
2558
3579
  export {
2559
3580
  AsyncStorageAdapter,
2560
3581
  Crossdeck,
2561
3582
  CrossdeckClient,
3583
+ CrossdeckContracts,
2562
3584
  CrossdeckError,
2563
3585
  DEFAULT_BASE_URL,
2564
3586
  MemoryStorage,