@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/CHANGELOG.md +97 -0
- package/README.md +67 -0
- package/dist/contracts.json +493 -0
- package/dist/index.cjs +1051 -35
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +177 -2
- package/dist/index.d.ts +177 -2
- package/dist/index.mjs +1057 -35
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -2
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.
|
|
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
|
|
490
|
+
var ANON_SUFFIX = "_anon";
|
|
491
|
+
var INDEX_SUFFIX = "_index";
|
|
492
|
+
var EntitlementCache = class _EntitlementCache {
|
|
304
493
|
/**
|
|
305
|
-
* @param storage
|
|
306
|
-
* @param
|
|
307
|
-
*
|
|
308
|
-
*
|
|
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,
|
|
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.
|
|
509
|
+
this.hydratedSuffixes = /* @__PURE__ */ new Set();
|
|
510
|
+
this.currentSuffix = ANON_SUFFIX;
|
|
317
511
|
this.storage = storage;
|
|
318
|
-
this.
|
|
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
|
|
323
|
-
* `Crossdeck.init()`
|
|
324
|
-
*
|
|
325
|
-
*
|
|
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
|
-
|
|
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.
|
|
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
|
|
414
|
-
*
|
|
415
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
2378
|
-
|
|
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.
|
|
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,
|