@cross-deck/react-native 1.0.0 → 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/dist/index.cjs CHANGED
@@ -23,6 +23,7 @@ __export(index_exports, {
23
23
  AsyncStorageAdapter: () => AsyncStorageAdapter,
24
24
  Crossdeck: () => Crossdeck,
25
25
  CrossdeckClient: () => CrossdeckClient,
26
+ CrossdeckContracts: () => CrossdeckContracts,
26
27
  CrossdeckError: () => CrossdeckError,
27
28
  DEFAULT_BASE_URL: () => DEFAULT_BASE_URL,
28
29
  MemoryStorage: () => MemoryStorage,
@@ -100,7 +101,7 @@ function typeMapForStatus(status) {
100
101
  }
101
102
 
102
103
  // src/_version.ts
103
- var SDK_VERSION = "1.0.0";
104
+ var SDK_VERSION = "1.4.2";
104
105
  var SDK_NAME = "@cross-deck/react-native";
105
106
 
106
107
  // src/http.ts
@@ -126,6 +127,12 @@ var HttpClient = class {
126
127
  "Crossdeck-Sdk-Version": `${SDK_NAME}@${this.config.sdkVersion}`,
127
128
  Accept: "application/json"
128
129
  };
130
+ if (this.config.bundleId) {
131
+ headers["X-Crossdeck-Bundle-Id"] = this.config.bundleId;
132
+ }
133
+ if (this.config.packageName) {
134
+ headers["X-Crossdeck-Package-Name"] = this.config.packageName;
135
+ }
129
136
  if (options.idempotencyKey) {
130
137
  headers["Idempotency-Key"] = options.idempotencyKey;
131
138
  }
@@ -334,34 +341,230 @@ function randomChars(count) {
334
341
  return out.join("");
335
342
  }
336
343
 
344
+ // src/hash.ts
345
+ var K = new Uint32Array([
346
+ 1116352408,
347
+ 1899447441,
348
+ 3049323471,
349
+ 3921009573,
350
+ 961987163,
351
+ 1508970993,
352
+ 2453635748,
353
+ 2870763221,
354
+ 3624381080,
355
+ 310598401,
356
+ 607225278,
357
+ 1426881987,
358
+ 1925078388,
359
+ 2162078206,
360
+ 2614888103,
361
+ 3248222580,
362
+ 3835390401,
363
+ 4022224774,
364
+ 264347078,
365
+ 604807628,
366
+ 770255983,
367
+ 1249150122,
368
+ 1555081692,
369
+ 1996064986,
370
+ 2554220882,
371
+ 2821834349,
372
+ 2952996808,
373
+ 3210313671,
374
+ 3336571891,
375
+ 3584528711,
376
+ 113926993,
377
+ 338241895,
378
+ 666307205,
379
+ 773529912,
380
+ 1294757372,
381
+ 1396182291,
382
+ 1695183700,
383
+ 1986661051,
384
+ 2177026350,
385
+ 2456956037,
386
+ 2730485921,
387
+ 2820302411,
388
+ 3259730800,
389
+ 3345764771,
390
+ 3516065817,
391
+ 3600352804,
392
+ 4094571909,
393
+ 275423344,
394
+ 430227734,
395
+ 506948616,
396
+ 659060556,
397
+ 883997877,
398
+ 958139571,
399
+ 1322822218,
400
+ 1537002063,
401
+ 1747873779,
402
+ 1955562222,
403
+ 2024104815,
404
+ 2227730452,
405
+ 2361852424,
406
+ 2428436474,
407
+ 2756734187,
408
+ 3204031479,
409
+ 3329325298
410
+ ]);
411
+ function utf8Bytes(input) {
412
+ if (typeof TextEncoder !== "undefined") {
413
+ return new TextEncoder().encode(input);
414
+ }
415
+ const out = [];
416
+ for (let i = 0; i < input.length; i++) {
417
+ let codePoint = input.charCodeAt(i);
418
+ if (codePoint >= 55296 && codePoint <= 56319 && i + 1 < input.length) {
419
+ const next = input.charCodeAt(i + 1);
420
+ if (next >= 56320 && next <= 57343) {
421
+ codePoint = 65536 + (codePoint - 55296 << 10) + (next - 56320);
422
+ i++;
423
+ }
424
+ }
425
+ if (codePoint < 128) {
426
+ out.push(codePoint);
427
+ } else if (codePoint < 2048) {
428
+ out.push(192 | codePoint >> 6);
429
+ out.push(128 | codePoint & 63);
430
+ } else if (codePoint < 65536) {
431
+ out.push(224 | codePoint >> 12);
432
+ out.push(128 | codePoint >> 6 & 63);
433
+ out.push(128 | codePoint & 63);
434
+ } else {
435
+ out.push(240 | codePoint >> 18);
436
+ out.push(128 | codePoint >> 12 & 63);
437
+ out.push(128 | codePoint >> 6 & 63);
438
+ out.push(128 | codePoint & 63);
439
+ }
440
+ }
441
+ return new Uint8Array(out);
442
+ }
443
+ function sha256Hex(input) {
444
+ const bytes = utf8Bytes(input);
445
+ const bitLength = bytes.length * 8;
446
+ const blockCount = Math.floor((bytes.length + 9 + 63) / 64);
447
+ const padded = new Uint8Array(blockCount * 64);
448
+ padded.set(bytes);
449
+ padded[bytes.length] = 128;
450
+ const high = Math.floor(bitLength / 4294967296);
451
+ const low = bitLength >>> 0;
452
+ const lenOffset = padded.length - 8;
453
+ padded[lenOffset + 0] = high >>> 24 & 255;
454
+ padded[lenOffset + 1] = high >>> 16 & 255;
455
+ padded[lenOffset + 2] = high >>> 8 & 255;
456
+ padded[lenOffset + 3] = high & 255;
457
+ padded[lenOffset + 4] = low >>> 24 & 255;
458
+ padded[lenOffset + 5] = low >>> 16 & 255;
459
+ padded[lenOffset + 6] = low >>> 8 & 255;
460
+ padded[lenOffset + 7] = low & 255;
461
+ const H = new Uint32Array([
462
+ 1779033703,
463
+ 3144134277,
464
+ 1013904242,
465
+ 2773480762,
466
+ 1359893119,
467
+ 2600822924,
468
+ 528734635,
469
+ 1541459225
470
+ ]);
471
+ const W = new Uint32Array(64);
472
+ for (let block = 0; block < blockCount; block++) {
473
+ const offset = block * 64;
474
+ for (let t = 0; t < 16; t++) {
475
+ 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;
476
+ }
477
+ for (let t = 16; t < 64; t++) {
478
+ const w15 = W[t - 15];
479
+ const w2 = W[t - 2];
480
+ const s0 = (w15 >>> 7 | w15 << 25) ^ (w15 >>> 18 | w15 << 14) ^ w15 >>> 3;
481
+ const s1 = (w2 >>> 17 | w2 << 15) ^ (w2 >>> 19 | w2 << 13) ^ w2 >>> 10;
482
+ W[t] = W[t - 16] + s0 + W[t - 7] + s1 >>> 0;
483
+ }
484
+ let a = H[0], b = H[1], c = H[2], d = H[3];
485
+ let e = H[4], f = H[5], g = H[6], h = H[7];
486
+ for (let t = 0; t < 64; t++) {
487
+ const S1 = (e >>> 6 | e << 26) ^ (e >>> 11 | e << 21) ^ (e >>> 25 | e << 7);
488
+ const ch = e & f ^ ~e & g;
489
+ const temp1 = h + S1 + ch + K[t] + W[t] >>> 0;
490
+ const S0 = (a >>> 2 | a << 30) ^ (a >>> 13 | a << 19) ^ (a >>> 22 | a << 10);
491
+ const maj = a & b ^ a & c ^ b & c;
492
+ const temp2 = S0 + maj >>> 0;
493
+ h = g;
494
+ g = f;
495
+ f = e;
496
+ e = d + temp1 >>> 0;
497
+ d = c;
498
+ c = b;
499
+ b = a;
500
+ a = temp1 + temp2 >>> 0;
501
+ }
502
+ H[0] = H[0] + a >>> 0;
503
+ H[1] = H[1] + b >>> 0;
504
+ H[2] = H[2] + c >>> 0;
505
+ H[3] = H[3] + d >>> 0;
506
+ H[4] = H[4] + e >>> 0;
507
+ H[5] = H[5] + f >>> 0;
508
+ H[6] = H[6] + g >>> 0;
509
+ H[7] = H[7] + h >>> 0;
510
+ }
511
+ let hex = "";
512
+ for (let i = 0; i < 8; i++) {
513
+ hex += H[i].toString(16).padStart(8, "0");
514
+ }
515
+ return hex;
516
+ }
517
+
337
518
  // src/entitlement-cache.ts
338
519
  var DEFAULT_STALE_AFTER_MS = 24 * 60 * 60 * 1e3;
339
- var EntitlementCache = class {
520
+ var ANON_SUFFIX = "_anon";
521
+ var INDEX_SUFFIX = "_index";
522
+ var EntitlementCache = class _EntitlementCache {
340
523
  /**
341
- * @param storage Device storage adapter.
342
- * @param storageKey Full key the persisted blob lives under.
343
- * @param staleAfterMs Age past which last-known-good is flagged stale
344
- * even without a failed refresh. Default 24h.
524
+ * @param storage Device storage adapter.
525
+ * @param storageKeyPrefix Prefix used to derive per-user storage keys
526
+ * (`<prefix>:<sha256(userId)>`). Default
527
+ * `crossdeck:entitlements`. The trailing
528
+ * user suffix is filled at identify() /
529
+ * reset() time — see [[setUserKey]].
530
+ * @param staleAfterMs Age past which last-known-good is flagged stale
531
+ * even without a failed refresh. Default 24h.
345
532
  */
346
- constructor(storage, storageKey = "crossdeck:entitlements", staleAfterMs = DEFAULT_STALE_AFTER_MS) {
533
+ constructor(storage, storageKeyPrefix = "crossdeck:entitlements", staleAfterMs = DEFAULT_STALE_AFTER_MS) {
347
534
  this.all = [];
348
535
  this.lastUpdated = 0;
349
536
  this.lastRefreshFailedAt = 0;
350
537
  this.listeners = /* @__PURE__ */ new Set();
351
538
  this.listenerErrorCount = 0;
352
- this.hydrated = false;
539
+ this.hydratedSuffixes = /* @__PURE__ */ new Set();
540
+ this.currentSuffix = ANON_SUFFIX;
353
541
  this.storage = storage;
354
- this.storageKey = storageKey;
542
+ this.storageKeyPrefix = storageKeyPrefix;
355
543
  this.staleAfterMs = staleAfterMs;
356
544
  }
545
+ /** The full storage key the current-user blob is persisted under. */
546
+ get storageKey() {
547
+ return `${this.storageKeyPrefix}:${this.currentSuffix}`;
548
+ }
549
+ /** Key of the index blob — a JSON array of every suffix we've
550
+ * written. Used by clearAll() to scope a logout-wipe. */
551
+ get indexKey() {
552
+ return `${this.storageKeyPrefix}:${INDEX_SUFFIX}`;
553
+ }
554
+ /** Derive a stable suffix for a developerUserId via SHA-256. */
555
+ static suffixForUserId(userId) {
556
+ if (userId == null || userId === "") return ANON_SUFFIX;
557
+ return sha256Hex(userId);
558
+ }
357
559
  /**
358
- * Load last-known-good from device storage. Run once during
359
- * `Crossdeck.init()` so `isEntitled()` answers correctly from the
360
- * first call. Any corrupt / unparseable blob degrades silently to
361
- * an empty cache boot must never throw.
560
+ * Load last-known-good from device storage for the CURRENT
561
+ * suffix. Run during `Crossdeck.init()` (anonymous slot) and
562
+ * after every [[setUserKey]] switch. Idempotent per suffix
563
+ * a repeat call for the same suffix is a no-op.
362
564
  */
363
565
  async hydrate() {
364
- if (this.hydrated) return;
566
+ const suffix = this.currentSuffix;
567
+ if (this.hydratedSuffixes.has(suffix)) return;
365
568
  try {
366
569
  const raw = await this.storage.getItem(this.storageKey);
367
570
  if (raw) {
@@ -373,7 +576,33 @@ var EntitlementCache = class {
373
576
  }
374
577
  } catch {
375
578
  }
376
- this.hydrated = true;
579
+ this.hydratedSuffixes.add(suffix);
580
+ }
581
+ /**
582
+ * Switch the cache to a different user's storage slot. Bank-grade
583
+ * three-layer isolation (v1.4.0 Phase 1.3):
584
+ * (a) Physical key separation — `<prefix>:<sha256(userId)>` so
585
+ * a user-switch can't physically read prior user's data
586
+ * even if the in-memory clear was skipped.
587
+ * (b) Unconditional in-memory clear — invoked whenever the
588
+ * active suffix changes, even on same-id re-identify.
589
+ * (c) Re-hydrate from the new slot — a returning user observes
590
+ * their last-known-good cache from storage immediately.
591
+ *
592
+ * Caller (identify() / reset()) MUST `await` this BEFORE the
593
+ * next `setFromList()` so the write lands under the right key.
594
+ */
595
+ async setUserKey(userId) {
596
+ const nextSuffix = _EntitlementCache.suffixForUserId(userId);
597
+ this.all = [];
598
+ this.lastUpdated = 0;
599
+ this.lastRefreshFailedAt = 0;
600
+ if (nextSuffix !== this.currentSuffix) {
601
+ this.currentSuffix = nextSuffix;
602
+ this.hydratedSuffixes.delete(nextSuffix);
603
+ }
604
+ await this.hydrate();
605
+ this.notify();
377
606
  }
378
607
  /**
379
608
  * Sync read — true iff the entitlement is currently granting
@@ -443,19 +672,51 @@ var EntitlementCache = class {
443
672
  this.lastUpdated = Date.now();
444
673
  this.lastRefreshFailedAt = 0;
445
674
  this.persist();
675
+ void this.recordSuffixInIndex(this.currentSuffix);
446
676
  this.notify();
447
677
  }
448
678
  /**
449
- * Wipe used on `reset()` (logout) and on an identity switch.
450
- * Clears BOTH memory and durable storage so a prior user's
451
- * entitlements can never leak to the next person on this device.
679
+ * Wipe the CURRENT user's slot. Used internally when a single
680
+ * user's cache needs to be invalidated. The full-logout path is
681
+ * [[clearAll]].
452
682
  */
453
683
  clear() {
454
684
  this.all = [];
455
685
  this.lastUpdated = 0;
456
686
  this.lastRefreshFailedAt = 0;
687
+ const suffix = this.currentSuffix;
457
688
  void this.storage.removeItem(this.storageKey).catch(() => {
458
689
  });
690
+ void this.removeSuffixFromIndex(suffix);
691
+ this.notify();
692
+ }
693
+ /**
694
+ * Logout-grade wipe — bank-grade contract: removes EVERY per-user
695
+ * entitlement slot the SDK has ever written on this device, then
696
+ * clears the index. Used by `Crossdeck.reset()` so a logout on a
697
+ * shared device can never leave another user's entitlements
698
+ * readable (layer (c) of the v1.4.0 isolation fix).
699
+ *
700
+ * Async to honour the AsyncStorage contract; safe to `void` if
701
+ * the caller doesn't need to await teardown completion.
702
+ */
703
+ async clearAll() {
704
+ this.all = [];
705
+ this.lastUpdated = 0;
706
+ this.lastRefreshFailedAt = 0;
707
+ this.currentSuffix = ANON_SUFFIX;
708
+ this.hydratedSuffixes.clear();
709
+ const suffixes = await this.readIndex();
710
+ await Promise.all(
711
+ suffixes.map(
712
+ (s) => this.storage.removeItem(`${this.storageKeyPrefix}:${s}`).catch(() => {
713
+ })
714
+ )
715
+ );
716
+ await this.storage.removeItem(`${this.storageKeyPrefix}:${ANON_SUFFIX}`).catch(() => {
717
+ });
718
+ await this.storage.removeItem(this.indexKey).catch(() => {
719
+ });
459
720
  this.notify();
460
721
  }
461
722
  /**
@@ -488,6 +749,41 @@ var EntitlementCache = class {
488
749
  void this.storage.setItem(this.storageKey, blob).catch(() => {
489
750
  });
490
751
  }
752
+ /** Read the index of all per-user suffixes the SDK has written. */
753
+ async readIndex() {
754
+ try {
755
+ const raw = await this.storage.getItem(this.indexKey);
756
+ if (!raw) return [];
757
+ const parsed = JSON.parse(raw);
758
+ if (Array.isArray(parsed)) {
759
+ return parsed.filter((x) => typeof x === "string");
760
+ }
761
+ return [];
762
+ } catch {
763
+ return [];
764
+ }
765
+ }
766
+ /** Add a suffix to the persisted index. Idempotent. */
767
+ async recordSuffixInIndex(suffix) {
768
+ const existing = await this.readIndex();
769
+ if (existing.includes(suffix)) return;
770
+ existing.push(suffix);
771
+ await this.storage.setItem(this.indexKey, JSON.stringify(existing)).catch(() => {
772
+ });
773
+ }
774
+ /** Remove a suffix from the persisted index. No-op if absent. */
775
+ async removeSuffixFromIndex(suffix) {
776
+ const existing = await this.readIndex();
777
+ const next = existing.filter((s) => s !== suffix);
778
+ if (next.length === existing.length) return;
779
+ if (next.length === 0) {
780
+ await this.storage.removeItem(this.indexKey).catch(() => {
781
+ });
782
+ } else {
783
+ await this.storage.setItem(this.indexKey, JSON.stringify(next)).catch(() => {
784
+ });
785
+ }
786
+ }
491
787
  notify() {
492
788
  if (this.listeners.size === 0) return;
493
789
  const snapshot = this.all.slice();
@@ -502,6 +798,34 @@ var EntitlementCache = class {
502
798
  }
503
799
  };
504
800
 
801
+ // src/idempotency-key.ts
802
+ function formatAsUuid(hex) {
803
+ return [
804
+ hex.slice(0, 8),
805
+ hex.slice(8, 12),
806
+ hex.slice(12, 16),
807
+ hex.slice(16, 20),
808
+ hex.slice(20, 32)
809
+ ].join("-");
810
+ }
811
+ function deriveIdempotencyKeyForPurchase(body) {
812
+ let identifier;
813
+ if (body.rail === "apple") {
814
+ identifier = body.signedTransactionInfo ?? "";
815
+ } else if (body.rail === "google") {
816
+ identifier = body.purchaseToken ?? "";
817
+ } else {
818
+ identifier = "";
819
+ }
820
+ if (!identifier) {
821
+ throw new Error(
822
+ `deriveIdempotencyKeyForPurchase: no stable identifier in body (rail=${body.rail}). Apple needs signedTransactionInfo; Google needs purchaseToken.`
823
+ );
824
+ }
825
+ const namespaced = `crossdeck:purchases/sync:${body.rail}:${identifier}`;
826
+ return formatAsUuid(sha256Hex(namespaced));
827
+ }
828
+
505
829
  // src/retry-policy.ts
506
830
  var DEFAULT_BASE = 1e3;
507
831
  var DEFAULT_MAX = 6e4;
@@ -1869,6 +2193,14 @@ var CrossdeckClient = class {
1869
2193
  this.state.errors?.uninstall();
1870
2194
  } catch {
1871
2195
  }
2196
+ try {
2197
+ this.state.appStateSubscription?.remove();
2198
+ } catch {
2199
+ }
2200
+ try {
2201
+ void this.state.events.flush();
2202
+ } catch {
2203
+ }
1872
2204
  }
1873
2205
  if (!options.publicKey || !options.publicKey.startsWith("cd_pub_")) {
1874
2206
  throw new CrossdeckError({
@@ -1910,11 +2242,20 @@ var CrossdeckClient = class {
1910
2242
  storagePrefix: options.storagePrefix ?? "crossdeck:",
1911
2243
  autoHeartbeat: options.autoHeartbeat ?? true,
1912
2244
  eventFlushBatchSize: options.eventFlushBatchSize ?? 20,
1913
- eventFlushIntervalMs: options.eventFlushIntervalMs ?? 5e3,
2245
+ // v1.4.0 Phase 3.3 — flush interval default parity at 2000ms
2246
+ // across every SDK. Per-instance override stays.
2247
+ eventFlushIntervalMs: options.eventFlushIntervalMs ?? 2e3,
1914
2248
  sdkVersion: options.sdkVersion ?? SDK_VERSION,
1915
2249
  appVersion: options.appVersion ?? null,
1916
2250
  platform: options.platform ?? detectPlatform(),
1917
- timeoutMs: options.timeoutMs ?? 15e3
2251
+ timeoutMs: options.timeoutMs ?? 15e3,
2252
+ // Per-platform identity claims for the bank-grade identity
2253
+ // lock. Empty string means "not supplied" — the HTTP layer
2254
+ // skips the header in that case and the backend will reject
2255
+ // with bundle_id_not_allowed / package_name_not_allowed at
2256
+ // first request if the project requires the lock.
2257
+ bundleId: options.bundleId ?? "",
2258
+ packageName: options.packageName ?? ""
1918
2259
  };
1919
2260
  const debug = new ConsoleDebugLogger();
1920
2261
  debug.enabled = options.debug === true;
@@ -1922,7 +2263,12 @@ var CrossdeckClient = class {
1922
2263
  publicKey: opts.publicKey,
1923
2264
  baseUrl: opts.baseUrl,
1924
2265
  sdkVersion: opts.sdkVersion,
1925
- timeoutMs: opts.timeoutMs
2266
+ timeoutMs: opts.timeoutMs,
2267
+ // Per-platform identity claims — sent as X-Crossdeck-Bundle-Id
2268
+ // / X-Crossdeck-Package-Name. Backend enforces these against
2269
+ // the app key's stored identity (bank-grade fail-closed).
2270
+ bundleId: options.bundleId,
2271
+ packageName: options.packageName
1926
2272
  });
1927
2273
  const effectiveStorage = persistIdentity ? storage : new MemoryStorage();
1928
2274
  const identity = new IdentityStore(effectiveStorage, opts.storagePrefix);
@@ -1987,12 +2333,32 @@ var CrossdeckClient = class {
1987
2333
  options: opts,
1988
2334
  debug,
1989
2335
  developerUserId: null,
2336
+ sessionId: null,
1990
2337
  lastServerTime: null,
1991
2338
  lastClientTime: null,
1992
2339
  started: false,
1993
2340
  hydrated: false,
1994
- ready: Promise.resolve()
2341
+ ready: Promise.resolve(),
2342
+ appStateSubscription: null
1995
2343
  };
2344
+ try {
2345
+ const RN = require("react-native");
2346
+ const AppState = RN?.AppState;
2347
+ if (AppState && typeof AppState.addEventListener === "function") {
2348
+ const sub = AppState.addEventListener("change", (next) => {
2349
+ if (next === "background" || next === "inactive") {
2350
+ try {
2351
+ void this.state?.events.flush().catch(() => {
2352
+ });
2353
+ debug.emit("sdk.queue_persisted", "persisted on AppState background");
2354
+ } catch {
2355
+ }
2356
+ }
2357
+ });
2358
+ this.state.appStateSubscription = sub;
2359
+ }
2360
+ } catch {
2361
+ }
1996
2362
  const wantErrorCapture = options.errorCapture !== false;
1997
2363
  if (wantErrorCapture) {
1998
2364
  const tracker = new ErrorTracker({
@@ -2069,14 +2435,10 @@ var CrossdeckClient = class {
2069
2435
  };
2070
2436
  if (options?.email) body.email = options.email;
2071
2437
  if (traits) body.traits = traits;
2438
+ await s.entitlements.setUserKey(userId);
2072
2439
  const result = await s.http.request("POST", "/identity/alias", {
2073
2440
  body
2074
2441
  });
2075
- const priorCdcust = s.identity.crossdeckCustomerId;
2076
- const cacheHasEntries = s.entitlements.list().length > 0;
2077
- if (priorCdcust && result.crossdeckCustomerId && priorCdcust !== result.crossdeckCustomerId || !priorCdcust && cacheHasEntries) {
2078
- s.entitlements.clear();
2079
- }
2080
2442
  s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
2081
2443
  s.identity.setDeveloperUserId(userId);
2082
2444
  s.developerUserId = userId;
@@ -2291,6 +2653,32 @@ var CrossdeckClient = class {
2291
2653
  * stamped. Common-case `track()` after hydration runs entirely
2292
2654
  * synchronously.
2293
2655
  */
2656
+ /**
2657
+ * Emit `crossdeck.contract_failed` with the canonical property
2658
+ * shape. Same wire shape every Crossdeck SDK uses for contract
2659
+ * verification telemetry — see `contracts/README.md`. No new
2660
+ * endpoint, goes through the standard track() pipeline.
2661
+ */
2662
+ reportContractFailure(input) {
2663
+ const props = {
2664
+ contract_id: input.contractId,
2665
+ sdk_version: SDK_VERSION,
2666
+ sdk_platform: "react-native",
2667
+ failure_reason: input.failureReason,
2668
+ run_context: input.runContext,
2669
+ run_id: input.runId
2670
+ };
2671
+ if (input.testRef) {
2672
+ props.test_file = input.testRef.file;
2673
+ props.test_name = input.testRef.name;
2674
+ }
2675
+ if (input.extra) {
2676
+ for (const [k, v] of Object.entries(input.extra)) {
2677
+ if (props[k] === void 0) props[k] = v;
2678
+ }
2679
+ }
2680
+ this.track("crossdeck.contract_failed", props);
2681
+ }
2294
2682
  track(name, properties) {
2295
2683
  const s = this.requireStarted();
2296
2684
  if (!name) {
@@ -2300,11 +2688,14 @@ var CrossdeckClient = class {
2300
2688
  message: "track(name) requires a non-empty name."
2301
2689
  });
2302
2690
  }
2691
+ const callTimeSnapshot = {
2692
+ sessionId: s.sessionId
2693
+ };
2303
2694
  if (!s.hydrated) {
2304
- void s.ready.then(() => this.trackPostHydration(s, name, properties));
2695
+ void s.ready.then(() => this.trackPostHydration(s, name, properties, callTimeSnapshot));
2305
2696
  return;
2306
2697
  }
2307
- this.trackPostHydration(s, name, properties);
2698
+ this.trackPostHydration(s, name, properties, callTimeSnapshot);
2308
2699
  }
2309
2700
  /**
2310
2701
  * The body of `track()` — everything after the synchronous
@@ -2312,7 +2703,7 @@ var CrossdeckClient = class {
2312
2703
  * portion until async identity hydration completes (RN-specific —
2313
2704
  * see `track()` jsdoc).
2314
2705
  */
2315
- trackPostHydration(s, name, properties) {
2706
+ trackPostHydration(s, name, properties, callTimeSnapshot) {
2316
2707
  const isError = name.startsWith("error.");
2317
2708
  const consentGateOk = isError ? s.consent.errors : s.consent.analytics;
2318
2709
  if (!consentGateOk) {
@@ -2359,6 +2750,9 @@ var CrossdeckClient = class {
2359
2750
  if (Object.keys(groupIds).length > 0) {
2360
2751
  enriched.$groups = groupIds;
2361
2752
  }
2753
+ if (callTimeSnapshot.sessionId) {
2754
+ enriched.sessionId = callTimeSnapshot.sessionId;
2755
+ }
2362
2756
  Object.assign(enriched, validation.properties);
2363
2757
  const finalProperties = s.scrubPii ? scrubPiiFromProperties(enriched) : enriched;
2364
2758
  const event = {
@@ -2409,14 +2803,24 @@ var CrossdeckClient = class {
2409
2803
  message: "syncPurchases (google) requires a purchaseToken string from Google Billing."
2410
2804
  });
2411
2805
  }
2806
+ const body = { ...input, rail };
2807
+ const idempotencyKey = deriveIdempotencyKeyForPurchase(body);
2412
2808
  const result = await s.http.request("POST", "/purchases/sync", {
2413
- // Spread input FIRST so the explicit `rail` default below wins
2414
- // — `{ ...input, rail }` puts the default last so an explicit
2415
- // `rail: undefined` from the caller doesn't override.
2416
- body: { ...input, rail }
2809
+ body,
2810
+ idempotencyKey
2417
2811
  });
2418
2812
  s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
2419
2813
  s.entitlements.setFromList(result.entitlements);
2814
+ try {
2815
+ const sourceProductId = result.entitlements[0]?.source.productId;
2816
+ const sourceSubscriptionId = result.entitlements[0]?.source.subscriptionId;
2817
+ const props = { rail };
2818
+ if (sourceProductId) props.productId = sourceProductId;
2819
+ if (sourceSubscriptionId) props.subscriptionId = sourceSubscriptionId;
2820
+ if (result.idempotent_replay) props.idempotent_replay = true;
2821
+ this.track("purchase.completed", props);
2822
+ } catch {
2823
+ }
2420
2824
  s.debug.emit(
2421
2825
  "sdk.purchase_evidence_sent",
2422
2826
  `${rail === "apple" ? "StoreKit" : "Google Billing"} purchase evidence forwarded. Waiting for backend verification.`,
@@ -2424,6 +2828,42 @@ var CrossdeckClient = class {
2424
2828
  );
2425
2829
  return result;
2426
2830
  }
2831
+ /**
2832
+ * v1.4.0 Phase 3.4 — set the active session id. RN doesn't own
2833
+ * session lifecycle (that's the host's AppState + nav library);
2834
+ * the host calls `setSessionId()` from its AppState change
2835
+ * listener so every subsequent `track()` event carries the
2836
+ * `sessionId` property — matches the web SDK's session-anchored
2837
+ * funnel queries.
2838
+ *
2839
+ * ```ts
2840
+ * import { AppState } from "react-native";
2841
+ *
2842
+ * let sessionId = uuid();
2843
+ * AppState.addEventListener("change", (next) => {
2844
+ * if (next === "active") {
2845
+ * // New session if backgrounded > 30 min.
2846
+ * sessionId = uuid();
2847
+ * Crossdeck.setSessionId(sessionId);
2848
+ * } else if (next === "background") {
2849
+ * void Crossdeck.flush();
2850
+ * }
2851
+ * });
2852
+ * Crossdeck.setSessionId(sessionId);
2853
+ * ```
2854
+ *
2855
+ * Pass `null` to clear (between sessions, on logout, etc).
2856
+ */
2857
+ setSessionId(sessionId) {
2858
+ const s = this.requireStarted();
2859
+ s.sessionId = sessionId ?? null;
2860
+ if (s.debug.enabled) {
2861
+ s.debug.emit(
2862
+ "sdk.configured",
2863
+ sessionId ? `Session id set to ${sessionId}; subsequent track events will carry it.` : "Session id cleared; subsequent track events will omit it."
2864
+ );
2865
+ }
2866
+ }
2427
2867
  /** Toggle verbose diagnostic logging. */
2428
2868
  setDebugMode(enabled) {
2429
2869
  const s = this.requireStarted();
@@ -2466,7 +2906,7 @@ var CrossdeckClient = class {
2466
2906
  }
2467
2907
  }
2468
2908
  this.state.identity.reset();
2469
- this.state.entitlements.clear();
2909
+ void this.state.entitlements.clearAll();
2470
2910
  this.state.events.reset();
2471
2911
  this.state.superProps.clear();
2472
2912
  this.state.breadcrumbs.clear();
@@ -2591,11 +3031,413 @@ function detectPlatform() {
2591
3031
  return "web";
2592
3032
  }
2593
3033
  }
3034
+
3035
+ // src/_contracts-bundled.ts
3036
+ var BUNDLED_IN = "@cross-deck/react-native@1.5.0";
3037
+ var SDK_VERSION2 = "1.5.0";
3038
+ var BUNDLED_CONTRACTS = Object.freeze([
3039
+ {
3040
+ "id": "error-envelope-shape",
3041
+ "pillar": "errors",
3042
+ "status": "enforced",
3043
+ "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.",
3044
+ "appliesTo": [
3045
+ "web",
3046
+ "node",
3047
+ "react-native",
3048
+ "swift",
3049
+ "android",
3050
+ "backend"
3051
+ ],
3052
+ "codeRef": [
3053
+ "backend/src/api/v1-errors.ts",
3054
+ "sdks/web/src/errors.ts",
3055
+ "sdks/node/src/errors.ts",
3056
+ "sdks/react-native/src/errors.ts",
3057
+ "sdks/swift/Sources/Crossdeck/Errors.swift",
3058
+ "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/Errors.kt"
3059
+ ],
3060
+ "testRef": [
3061
+ {
3062
+ "file": "sdks/swift/Tests/CrossdeckTests/ErrorsTests.swift",
3063
+ "name": "test_errorEnvelope_fallsBackOnGarbageBody"
3064
+ },
3065
+ {
3066
+ "file": "sdks/swift/Tests/CrossdeckTests/ErrorsTests.swift",
3067
+ "name": "test_errorEnvelope_reads_XRequestId_fallback"
3068
+ },
3069
+ {
3070
+ "file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/ErrorTypeWireVocabTest.kt",
3071
+ "name": "backend 500 response parses to INTERNAL_ERROR"
3072
+ }
3073
+ ],
3074
+ "registeredAt": "2026-05-26",
3075
+ "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 8 (codifies existing contract)",
3076
+ "bundledIn": "@cross-deck/react-native@1.5.0"
3077
+ },
3078
+ {
3079
+ "id": "flush-interval-parity",
3080
+ "pillar": "analytics",
3081
+ "status": "enforced",
3082
+ "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.",
3083
+ "appliesTo": [
3084
+ "web",
3085
+ "node",
3086
+ "react-native",
3087
+ "swift",
3088
+ "android"
3089
+ ],
3090
+ "codeRef": [
3091
+ "sdks/web/src/crossdeck.ts",
3092
+ "sdks/node/src/crossdeck-server.ts",
3093
+ "sdks/react-native/src/crossdeck.ts",
3094
+ "sdks/swift/Sources/Crossdeck/EventQueue.swift",
3095
+ "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/EventQueue.kt"
3096
+ ],
3097
+ "testRef": [
3098
+ {
3099
+ "file": "sdks/swift/Sources/Crossdeck/EventQueue.swift",
3100
+ "name": "flushIntervalMs: Int = 2_000"
3101
+ },
3102
+ {
3103
+ "file": "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/EventQueue.kt",
3104
+ "name": "flushIntervalMs: Long = 2_000L"
3105
+ },
3106
+ {
3107
+ "file": "sdks/web/src/crossdeck.ts",
3108
+ "name": "options.eventFlushIntervalMs ?? 2000"
3109
+ },
3110
+ {
3111
+ "file": "sdks/node/src/crossdeck-server.ts",
3112
+ "name": "options.eventFlushIntervalMs ?? 2000"
3113
+ },
3114
+ {
3115
+ "file": "sdks/react-native/src/crossdeck.ts",
3116
+ "name": "options.eventFlushIntervalMs ?? 2000"
3117
+ }
3118
+ ],
3119
+ "registeredAt": "2026-05-26",
3120
+ "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 3.3",
3121
+ "bundledIn": "@cross-deck/react-native@1.5.0"
3122
+ },
3123
+ {
3124
+ "id": "idempotency-key-deterministic",
3125
+ "pillar": "revenue",
3126
+ "status": "enforced",
3127
+ "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.",
3128
+ "appliesTo": [
3129
+ "web",
3130
+ "node",
3131
+ "react-native",
3132
+ "swift",
3133
+ "android",
3134
+ "backend"
3135
+ ],
3136
+ "codeRef": [
3137
+ "sdks/web/src/idempotency-key.ts",
3138
+ "sdks/web/src/crossdeck.ts",
3139
+ "sdks/react-native/src/idempotency-key.ts",
3140
+ "sdks/react-native/src/crossdeck.ts",
3141
+ "sdks/node/src/idempotency-key.ts",
3142
+ "sdks/node/src/crossdeck-server.ts",
3143
+ "sdks/swift/Sources/Crossdeck/IdempotencyKey.swift",
3144
+ "sdks/swift/Sources/Crossdeck/Crossdeck.swift",
3145
+ "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/IdempotencyKey.kt",
3146
+ "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/Crossdeck.kt",
3147
+ "backend/src/lib/idempotency-response-cache.ts",
3148
+ "backend/src/api/v1-purchases.ts"
3149
+ ],
3150
+ "testRef": [
3151
+ {
3152
+ "file": "sdks/web/tests/idempotency-key.test.ts",
3153
+ "name": "cross-SDK oracle \u2014 apple JWS pins canonical vector"
3154
+ },
3155
+ {
3156
+ "file": "sdks/web/tests/idempotency-key.test.ts",
3157
+ "name": "is deterministic: same body twice -> identical key"
3158
+ },
3159
+ {
3160
+ "file": "sdks/web/tests/idempotency-key.test.ts",
3161
+ "name": "same identifier under different rails -> different keys"
3162
+ },
3163
+ {
3164
+ "file": "sdks/web/tests/idempotency-key.test.ts",
3165
+ "name": "never silently falls back to a random key on missing identifier"
3166
+ },
3167
+ {
3168
+ "file": "sdks/react-native/tests/idempotency-key.test.ts",
3169
+ "name": "is deterministic"
3170
+ },
3171
+ {
3172
+ "file": "sdks/react-native/tests/idempotency-key.test.ts",
3173
+ "name": "cross-SDK oracle \u2014 apple JWS pins canonical vector"
3174
+ },
3175
+ {
3176
+ "file": "sdks/node/tests/idempotency-key.test.ts",
3177
+ "name": "is deterministic"
3178
+ },
3179
+ {
3180
+ "file": "sdks/node/tests/idempotency-key.test.ts",
3181
+ "name": "rail namespacing prevents cross-rail collisions"
3182
+ },
3183
+ {
3184
+ "file": "sdks/node/tests/idempotency-key.test.ts",
3185
+ "name": "apple JWS produces the canonical pinned UUID across all 5 SDKs"
3186
+ },
3187
+ {
3188
+ "file": "backend/tests/unit/idempotency-response-cache.test.ts",
3189
+ "name": "is deterministic for the same input"
3190
+ },
3191
+ {
3192
+ "file": "backend/tests/unit/idempotency-response-cache.test.ts",
3193
+ "name": "injects idempotent_replay: true into a JSON object body"
3194
+ },
3195
+ {
3196
+ "file": "backend/tests/unit/idempotency-response-cache.test.ts",
3197
+ "name": "matches Stripe's 24-hour idempotency window"
3198
+ },
3199
+ {
3200
+ "file": "sdks/swift/Tests/CrossdeckTests/IdempotencyKeyTests.swift",
3201
+ "name": "test_crossSdkOracle_appleJWS"
3202
+ },
3203
+ {
3204
+ "file": "sdks/swift/Tests/CrossdeckTests/IdempotencyKeyTests.swift",
3205
+ "name": "test_railNamespacing_preventsCrossRailCollisions"
3206
+ },
3207
+ {
3208
+ "file": "sdks/swift/Tests/CrossdeckTests/IdempotencyKeyTests.swift",
3209
+ "name": "test_missingIdentifier_returnsNil"
3210
+ },
3211
+ {
3212
+ "file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/IdempotencyKeyTest.kt",
3213
+ "name": "cross-SDK oracle for apple JWS"
3214
+ },
3215
+ {
3216
+ "file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/IdempotencyKeyTest.kt",
3217
+ "name": "rail namespacing prevents cross-rail collisions"
3218
+ },
3219
+ {
3220
+ "file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/IdempotencyKeyTest.kt",
3221
+ "name": "missing identifier returns null - never silent random fallback"
3222
+ }
3223
+ ],
3224
+ "registeredAt": "2026-05-26",
3225
+ "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 2.2.a + 2.2.b + 2.2.c",
3226
+ "bundledIn": "@cross-deck/react-native@1.5.0"
3227
+ },
3228
+ {
3229
+ "id": "init-reentry-drains-prior-queue",
3230
+ "pillar": "lifecycle",
3231
+ "status": "enforced",
3232
+ "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.",
3233
+ "appliesTo": [
3234
+ "web",
3235
+ "react-native"
3236
+ ],
3237
+ "codeRef": [
3238
+ "sdks/web/src/crossdeck.ts",
3239
+ "sdks/react-native/src/crossdeck.ts"
3240
+ ],
3241
+ "testRef": [
3242
+ {
3243
+ "file": "sdks/web/tests/init-reentry.test.ts",
3244
+ "name": "re-init drains the prior queue's pending timer before swapping state"
3245
+ },
3246
+ {
3247
+ "file": "sdks/web/tests/init-reentry.test.ts",
3248
+ "name": "re-init does NOT wipe the durable event store"
3249
+ }
3250
+ ],
3251
+ "registeredAt": "2026-05-26",
3252
+ "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 5.5",
3253
+ "bundledIn": "@cross-deck/react-native@1.5.0"
3254
+ },
3255
+ {
3256
+ "id": "per-user-cache-isolation",
3257
+ "pillar": "entitlements",
3258
+ "status": "enforced",
3259
+ "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.",
3260
+ "appliesTo": [
3261
+ "web",
3262
+ "react-native",
3263
+ "swift",
3264
+ "android"
3265
+ ],
3266
+ "codeRef": [
3267
+ "sdks/web/src/entitlement-cache.ts",
3268
+ "sdks/web/src/hash.ts",
3269
+ "sdks/web/src/crossdeck.ts",
3270
+ "sdks/react-native/src/entitlement-cache.ts",
3271
+ "sdks/react-native/src/hash.ts",
3272
+ "sdks/react-native/src/crossdeck.ts",
3273
+ "sdks/swift/Sources/Crossdeck/EntitlementCache.swift",
3274
+ "sdks/swift/Sources/Crossdeck/IdempotencyKey.swift",
3275
+ "sdks/swift/Sources/Crossdeck/Crossdeck.swift",
3276
+ "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/EntitlementCache.kt",
3277
+ "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/IdempotencyKey.kt",
3278
+ "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/Crossdeck.kt"
3279
+ ],
3280
+ "testRef": [
3281
+ {
3282
+ "file": "sdks/web/tests/entitlement-cache-isolation.test.ts",
3283
+ "name": "identify(B) makes A's entitlements unreachable from in-memory"
3284
+ },
3285
+ {
3286
+ "file": "sdks/web/tests/entitlement-cache-isolation.test.ts",
3287
+ "name": "clearAll() removes every per-user storage key plus the index"
3288
+ },
3289
+ {
3290
+ "file": "sdks/web/tests/entitlement-cache-isolation.test.ts",
3291
+ "name": "a second cache instance reading A's storage suffix CANNOT see B's data"
3292
+ },
3293
+ {
3294
+ "file": "sdks/react-native/tests/entitlement-cache-isolation.test.ts",
3295
+ "name": "identify(B) makes A's entitlements unreachable from in-memory"
3296
+ },
3297
+ {
3298
+ "file": "sdks/react-native/tests/entitlement-cache-isolation.test.ts",
3299
+ "name": "removes every per-user storage key plus the index"
3300
+ },
3301
+ {
3302
+ "file": "sdks/swift/Tests/CrossdeckTests/EntitlementCacheIsolationTests.swift",
3303
+ "name": "test_identifyB_makesAEntitlementsUnreachable"
3304
+ },
3305
+ {
3306
+ "file": "sdks/swift/Tests/CrossdeckTests/EntitlementCacheIsolationTests.swift",
3307
+ "name": "test_identifiedWritesLandUnderPerUserSha256Key"
3308
+ },
3309
+ {
3310
+ "file": "sdks/swift/Tests/CrossdeckTests/EntitlementCacheIsolationTests.swift",
3311
+ "name": "test_clearAll_removesEveryPerUserStorageKeyPlusIndex"
3312
+ },
3313
+ {
3314
+ "file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/EntitlementCacheIsolationTest.kt",
3315
+ "name": "identified writes land under per-user sha256 key"
3316
+ },
3317
+ {
3318
+ "file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/EntitlementCacheIsolationTest.kt",
3319
+ "name": "identify B makes A entitlements unreachable from in-memory"
3320
+ },
3321
+ {
3322
+ "file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/EntitlementCacheIsolationTest.kt",
3323
+ "name": "clearAll removes every per-user storage key plus the index"
3324
+ },
3325
+ {
3326
+ "file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/EntitlementCacheIsolationTest.kt",
3327
+ "name": "a fresh cache bound to A's key CANNOT read B's blob"
3328
+ }
3329
+ ],
3330
+ "registeredAt": "2026-05-26",
3331
+ "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 1.3 (web/RN) + dogfood-gap fix (swift + android)",
3332
+ "bundledIn": "@cross-deck/react-native@1.5.0"
3333
+ },
3334
+ {
3335
+ "id": "rn-session-id-enrichment",
3336
+ "pillar": "analytics",
3337
+ "status": "enforced",
3338
+ "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).",
3339
+ "appliesTo": [
3340
+ "react-native"
3341
+ ],
3342
+ "codeRef": [
3343
+ "sdks/react-native/src/crossdeck.ts"
3344
+ ],
3345
+ "testRef": [
3346
+ {
3347
+ "file": "sdks/react-native/tests/session-id-enrichment.test.ts",
3348
+ "name": "track() events carry sessionId after setSessionId() is called"
3349
+ },
3350
+ {
3351
+ "file": "sdks/react-native/tests/session-id-enrichment.test.ts",
3352
+ "name": "track() events do NOT carry sessionId before setSessionId() is called"
3353
+ },
3354
+ {
3355
+ "file": "sdks/react-native/tests/session-id-enrichment.test.ts",
3356
+ "name": "setSessionId(null) clears the active session"
3357
+ },
3358
+ {
3359
+ "file": "sdks/react-native/tests/session-id-enrichment.test.ts",
3360
+ "name": "caller-supplied sessionId property overrides setSessionId() value (Phase 3.2 precedence)"
3361
+ }
3362
+ ],
3363
+ "registeredAt": "2026-05-26",
3364
+ "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 3.4",
3365
+ "bundledIn": "@cross-deck/react-native@1.5.0"
3366
+ },
3367
+ {
3368
+ "id": "sync-purchases-funnel-parity",
3369
+ "pillar": "analytics",
3370
+ "status": "enforced",
3371
+ "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`.",
3372
+ "appliesTo": [
3373
+ "web",
3374
+ "node",
3375
+ "react-native",
3376
+ "swift",
3377
+ "android"
3378
+ ],
3379
+ "codeRef": [
3380
+ "sdks/web/src/crossdeck.ts",
3381
+ "sdks/node/src/crossdeck-server.ts",
3382
+ "sdks/react-native/src/crossdeck.ts",
3383
+ "sdks/swift/Sources/Crossdeck/Crossdeck.swift",
3384
+ "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/Crossdeck.kt"
3385
+ ],
3386
+ "testRef": [
3387
+ {
3388
+ "file": "sdks/web/tests/sync-purchases-funnel.test.ts",
3389
+ "name": "emits purchase.completed after a successful sync"
3390
+ },
3391
+ {
3392
+ "file": "sdks/web/tests/sync-purchases-funnel.test.ts",
3393
+ "name": "carries idempotent_replay=true when backend replied from cache"
3394
+ }
3395
+ ],
3396
+ "registeredAt": "2026-05-26",
3397
+ "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 3.5",
3398
+ "bundledIn": "@cross-deck/react-native@1.5.0"
3399
+ }
3400
+ ]);
3401
+
3402
+ // src/contracts.ts
3403
+ var CrossdeckContracts = {
3404
+ all() {
3405
+ return BUNDLED_CONTRACTS.filter((c) => c.status === "enforced");
3406
+ },
3407
+ allIncludingHistorical() {
3408
+ return BUNDLED_CONTRACTS;
3409
+ },
3410
+ byId(id) {
3411
+ return BUNDLED_CONTRACTS.find((c) => c.id === id);
3412
+ },
3413
+ byPillar(pillar) {
3414
+ return BUNDLED_CONTRACTS.filter(
3415
+ (c) => c.pillar === pillar && c.status === "enforced"
3416
+ );
3417
+ },
3418
+ withStatus(status) {
3419
+ return BUNDLED_CONTRACTS.filter((c) => c.status === status);
3420
+ },
3421
+ sdkVersion: SDK_VERSION2,
3422
+ bundledIn: BUNDLED_IN,
3423
+ /**
3424
+ * Resolve a failing test back to the contract it exercises.
3425
+ * Used by test-framework hooks to find the contract id of a
3426
+ * failed contract test so `reportContractFailure(...)` can stamp
3427
+ * the right `contract_id` on the emitted event.
3428
+ */
3429
+ findByTestName(name) {
3430
+ return BUNDLED_CONTRACTS.find(
3431
+ (c) => c.testRef.some((ref) => ref.name === name)
3432
+ );
3433
+ }
3434
+ };
2594
3435
  // Annotate the CommonJS export names for ESM import in node:
2595
3436
  0 && (module.exports = {
2596
3437
  AsyncStorageAdapter,
2597
3438
  Crossdeck,
2598
3439
  CrossdeckClient,
3440
+ CrossdeckContracts,
2599
3441
  CrossdeckError,
2600
3442
  DEFAULT_BASE_URL,
2601
3443
  MemoryStorage,