@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/CHANGELOG.md +46 -0
- package/README.md +13 -0
- package/dist/contracts.json +371 -0
- package/dist/index.cjs +877 -35
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +156 -2
- package/dist/index.d.ts +156 -2
- package/dist/index.mjs +883 -35
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -2
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.
|
|
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
|
|
520
|
+
var ANON_SUFFIX = "_anon";
|
|
521
|
+
var INDEX_SUFFIX = "_index";
|
|
522
|
+
var EntitlementCache = class _EntitlementCache {
|
|
340
523
|
/**
|
|
341
|
-
* @param storage
|
|
342
|
-
* @param
|
|
343
|
-
*
|
|
344
|
-
*
|
|
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,
|
|
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.
|
|
539
|
+
this.hydratedSuffixes = /* @__PURE__ */ new Set();
|
|
540
|
+
this.currentSuffix = ANON_SUFFIX;
|
|
353
541
|
this.storage = storage;
|
|
354
|
-
this.
|
|
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
|
|
359
|
-
* `Crossdeck.init()`
|
|
360
|
-
*
|
|
361
|
-
*
|
|
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
|
-
|
|
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.
|
|
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
|
|
450
|
-
*
|
|
451
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
2414
|
-
|
|
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.
|
|
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,
|