@cross-deck/web 1.5.2 → 1.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/contracts.json +500 -0
- package/dist/crossdeck.umd.min.js +2 -2
- package/dist/crossdeck.umd.min.js.map +1 -1
- package/dist/error-codes.json +106 -1
- package/dist/index.cjs +1998 -24
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +212 -4
- package/dist/index.d.ts +212 -4
- package/dist/index.mjs +1997 -24
- package/dist/index.mjs.map +1 -1
- package/dist/react.cjs +1340 -24
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.mts +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.mjs +1340 -24
- package/dist/react.mjs.map +1 -1
- package/dist/{types-BzoKor4z.d.mts → types-B9sxUuKh.d.mts} +75 -0
- package/dist/{types-BzoKor4z.d.ts → types-B9sxUuKh.d.ts} +75 -0
- package/dist/vue.cjs +1340 -24
- package/dist/vue.cjs.map +1 -1
- package/dist/vue.mjs +1340 -24
- package/dist/vue.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -64,7 +64,7 @@ function typeMapForStatus(status) {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
// src/_version.ts
|
|
67
|
-
var SDK_VERSION = "1.3
|
|
67
|
+
var SDK_VERSION = "1.5.3";
|
|
68
68
|
var SDK_NAME = "@cross-deck/web";
|
|
69
69
|
|
|
70
70
|
// src/http.ts
|
|
@@ -127,7 +127,14 @@ var HttpClient = class {
|
|
|
127
127
|
if (timeoutHandle !== null) clearTimeout(timeoutHandle);
|
|
128
128
|
}
|
|
129
129
|
if (!response.ok) {
|
|
130
|
-
|
|
130
|
+
const parsed = await crossdeckErrorFromResponse(response);
|
|
131
|
+
if (this.config.onErrorParsed) {
|
|
132
|
+
try {
|
|
133
|
+
this.config.onErrorParsed(parsed);
|
|
134
|
+
} catch {
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
throw parsed;
|
|
131
138
|
}
|
|
132
139
|
if (response.status === 204) return void 0;
|
|
133
140
|
try {
|
|
@@ -314,29 +321,258 @@ function randomChars(count) {
|
|
|
314
321
|
return out.join("");
|
|
315
322
|
}
|
|
316
323
|
|
|
324
|
+
// src/hash.ts
|
|
325
|
+
var K = new Uint32Array([
|
|
326
|
+
1116352408,
|
|
327
|
+
1899447441,
|
|
328
|
+
3049323471,
|
|
329
|
+
3921009573,
|
|
330
|
+
961987163,
|
|
331
|
+
1508970993,
|
|
332
|
+
2453635748,
|
|
333
|
+
2870763221,
|
|
334
|
+
3624381080,
|
|
335
|
+
310598401,
|
|
336
|
+
607225278,
|
|
337
|
+
1426881987,
|
|
338
|
+
1925078388,
|
|
339
|
+
2162078206,
|
|
340
|
+
2614888103,
|
|
341
|
+
3248222580,
|
|
342
|
+
3835390401,
|
|
343
|
+
4022224774,
|
|
344
|
+
264347078,
|
|
345
|
+
604807628,
|
|
346
|
+
770255983,
|
|
347
|
+
1249150122,
|
|
348
|
+
1555081692,
|
|
349
|
+
1996064986,
|
|
350
|
+
2554220882,
|
|
351
|
+
2821834349,
|
|
352
|
+
2952996808,
|
|
353
|
+
3210313671,
|
|
354
|
+
3336571891,
|
|
355
|
+
3584528711,
|
|
356
|
+
113926993,
|
|
357
|
+
338241895,
|
|
358
|
+
666307205,
|
|
359
|
+
773529912,
|
|
360
|
+
1294757372,
|
|
361
|
+
1396182291,
|
|
362
|
+
1695183700,
|
|
363
|
+
1986661051,
|
|
364
|
+
2177026350,
|
|
365
|
+
2456956037,
|
|
366
|
+
2730485921,
|
|
367
|
+
2820302411,
|
|
368
|
+
3259730800,
|
|
369
|
+
3345764771,
|
|
370
|
+
3516065817,
|
|
371
|
+
3600352804,
|
|
372
|
+
4094571909,
|
|
373
|
+
275423344,
|
|
374
|
+
430227734,
|
|
375
|
+
506948616,
|
|
376
|
+
659060556,
|
|
377
|
+
883997877,
|
|
378
|
+
958139571,
|
|
379
|
+
1322822218,
|
|
380
|
+
1537002063,
|
|
381
|
+
1747873779,
|
|
382
|
+
1955562222,
|
|
383
|
+
2024104815,
|
|
384
|
+
2227730452,
|
|
385
|
+
2361852424,
|
|
386
|
+
2428436474,
|
|
387
|
+
2756734187,
|
|
388
|
+
3204031479,
|
|
389
|
+
3329325298
|
|
390
|
+
]);
|
|
391
|
+
function utf8Bytes(input) {
|
|
392
|
+
if (typeof TextEncoder !== "undefined") {
|
|
393
|
+
return new TextEncoder().encode(input);
|
|
394
|
+
}
|
|
395
|
+
const out = [];
|
|
396
|
+
for (let i = 0; i < input.length; i++) {
|
|
397
|
+
let codePoint = input.charCodeAt(i);
|
|
398
|
+
if (codePoint >= 55296 && codePoint <= 56319 && i + 1 < input.length) {
|
|
399
|
+
const next = input.charCodeAt(i + 1);
|
|
400
|
+
if (next >= 56320 && next <= 57343) {
|
|
401
|
+
codePoint = 65536 + (codePoint - 55296 << 10) + (next - 56320);
|
|
402
|
+
i++;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if (codePoint < 128) {
|
|
406
|
+
out.push(codePoint);
|
|
407
|
+
} else if (codePoint < 2048) {
|
|
408
|
+
out.push(192 | codePoint >> 6);
|
|
409
|
+
out.push(128 | codePoint & 63);
|
|
410
|
+
} else if (codePoint < 65536) {
|
|
411
|
+
out.push(224 | codePoint >> 12);
|
|
412
|
+
out.push(128 | codePoint >> 6 & 63);
|
|
413
|
+
out.push(128 | codePoint & 63);
|
|
414
|
+
} else {
|
|
415
|
+
out.push(240 | codePoint >> 18);
|
|
416
|
+
out.push(128 | codePoint >> 12 & 63);
|
|
417
|
+
out.push(128 | codePoint >> 6 & 63);
|
|
418
|
+
out.push(128 | codePoint & 63);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return new Uint8Array(out);
|
|
422
|
+
}
|
|
423
|
+
function sha256Hex(input) {
|
|
424
|
+
const bytes = utf8Bytes(input);
|
|
425
|
+
const bitLength = bytes.length * 8;
|
|
426
|
+
const blockCount = Math.floor((bytes.length + 9 + 63) / 64);
|
|
427
|
+
const padded = new Uint8Array(blockCount * 64);
|
|
428
|
+
padded.set(bytes);
|
|
429
|
+
padded[bytes.length] = 128;
|
|
430
|
+
const high = Math.floor(bitLength / 4294967296);
|
|
431
|
+
const low = bitLength >>> 0;
|
|
432
|
+
const lenOffset = padded.length - 8;
|
|
433
|
+
padded[lenOffset + 0] = high >>> 24 & 255;
|
|
434
|
+
padded[lenOffset + 1] = high >>> 16 & 255;
|
|
435
|
+
padded[lenOffset + 2] = high >>> 8 & 255;
|
|
436
|
+
padded[lenOffset + 3] = high & 255;
|
|
437
|
+
padded[lenOffset + 4] = low >>> 24 & 255;
|
|
438
|
+
padded[lenOffset + 5] = low >>> 16 & 255;
|
|
439
|
+
padded[lenOffset + 6] = low >>> 8 & 255;
|
|
440
|
+
padded[lenOffset + 7] = low & 255;
|
|
441
|
+
const H = new Uint32Array([
|
|
442
|
+
1779033703,
|
|
443
|
+
3144134277,
|
|
444
|
+
1013904242,
|
|
445
|
+
2773480762,
|
|
446
|
+
1359893119,
|
|
447
|
+
2600822924,
|
|
448
|
+
528734635,
|
|
449
|
+
1541459225
|
|
450
|
+
]);
|
|
451
|
+
const W = new Uint32Array(64);
|
|
452
|
+
for (let block = 0; block < blockCount; block++) {
|
|
453
|
+
const offset = block * 64;
|
|
454
|
+
for (let t = 0; t < 16; t++) {
|
|
455
|
+
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;
|
|
456
|
+
}
|
|
457
|
+
for (let t = 16; t < 64; t++) {
|
|
458
|
+
const w15 = W[t - 15];
|
|
459
|
+
const w2 = W[t - 2];
|
|
460
|
+
const s0 = (w15 >>> 7 | w15 << 25) ^ (w15 >>> 18 | w15 << 14) ^ w15 >>> 3;
|
|
461
|
+
const s1 = (w2 >>> 17 | w2 << 15) ^ (w2 >>> 19 | w2 << 13) ^ w2 >>> 10;
|
|
462
|
+
W[t] = W[t - 16] + s0 + W[t - 7] + s1 >>> 0;
|
|
463
|
+
}
|
|
464
|
+
let a = H[0], b = H[1], c = H[2], d = H[3];
|
|
465
|
+
let e = H[4], f = H[5], g = H[6], h = H[7];
|
|
466
|
+
for (let t = 0; t < 64; t++) {
|
|
467
|
+
const S1 = (e >>> 6 | e << 26) ^ (e >>> 11 | e << 21) ^ (e >>> 25 | e << 7);
|
|
468
|
+
const ch = e & f ^ ~e & g;
|
|
469
|
+
const temp1 = h + S1 + ch + K[t] + W[t] >>> 0;
|
|
470
|
+
const S0 = (a >>> 2 | a << 30) ^ (a >>> 13 | a << 19) ^ (a >>> 22 | a << 10);
|
|
471
|
+
const maj = a & b ^ a & c ^ b & c;
|
|
472
|
+
const temp2 = S0 + maj >>> 0;
|
|
473
|
+
h = g;
|
|
474
|
+
g = f;
|
|
475
|
+
f = e;
|
|
476
|
+
e = d + temp1 >>> 0;
|
|
477
|
+
d = c;
|
|
478
|
+
c = b;
|
|
479
|
+
b = a;
|
|
480
|
+
a = temp1 + temp2 >>> 0;
|
|
481
|
+
}
|
|
482
|
+
H[0] = H[0] + a >>> 0;
|
|
483
|
+
H[1] = H[1] + b >>> 0;
|
|
484
|
+
H[2] = H[2] + c >>> 0;
|
|
485
|
+
H[3] = H[3] + d >>> 0;
|
|
486
|
+
H[4] = H[4] + e >>> 0;
|
|
487
|
+
H[5] = H[5] + f >>> 0;
|
|
488
|
+
H[6] = H[6] + g >>> 0;
|
|
489
|
+
H[7] = H[7] + h >>> 0;
|
|
490
|
+
}
|
|
491
|
+
let hex = "";
|
|
492
|
+
for (let i = 0; i < 8; i++) {
|
|
493
|
+
hex += H[i].toString(16).padStart(8, "0");
|
|
494
|
+
}
|
|
495
|
+
return hex;
|
|
496
|
+
}
|
|
497
|
+
|
|
317
498
|
// src/entitlement-cache.ts
|
|
318
499
|
var DEFAULT_STALE_AFTER_MS = 24 * 60 * 60 * 1e3;
|
|
319
|
-
var
|
|
500
|
+
var ANON_SUFFIX = "_anon";
|
|
501
|
+
var INDEX_SUFFIX = "_index";
|
|
502
|
+
var EntitlementCache = class _EntitlementCache {
|
|
320
503
|
/**
|
|
321
|
-
* @param storage
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
* @param
|
|
326
|
-
*
|
|
327
|
-
*
|
|
504
|
+
* @param storage Device storage adapter. When omitted (tests) or
|
|
505
|
+
* a MemoryStorage (strict-consent / no-persistence
|
|
506
|
+
* mode) the cache is session-only — durability is
|
|
507
|
+
* simply absent, never wrong.
|
|
508
|
+
* @param storageKeyPrefix Prefix used to derive per-user storage keys
|
|
509
|
+
* (`<prefix>:<sha256(userId)>`). Default
|
|
510
|
+
* `crossdeck:entitlements`. The trailing user
|
|
511
|
+
* suffix is filled at identify() / reset()
|
|
512
|
+
* time — see [[setUserKey]].
|
|
513
|
+
* @param staleAfterMs Age past which last-known-good is flagged stale
|
|
514
|
+
* even without a failed refresh. Default 24h.
|
|
328
515
|
*/
|
|
329
|
-
constructor(storage,
|
|
516
|
+
constructor(storage, storageKeyPrefix = "crossdeck:entitlements", staleAfterMs = DEFAULT_STALE_AFTER_MS) {
|
|
330
517
|
this.all = [];
|
|
331
518
|
this.lastUpdated = 0;
|
|
332
519
|
this.lastRefreshFailedAt = 0;
|
|
333
520
|
this.listeners = /* @__PURE__ */ new Set();
|
|
334
521
|
this.listenerErrorCount = 0;
|
|
522
|
+
this.currentSuffix = ANON_SUFFIX;
|
|
335
523
|
this.storage = storage;
|
|
336
|
-
this.
|
|
524
|
+
this.storageKeyPrefix = storageKeyPrefix;
|
|
337
525
|
this.staleAfterMs = staleAfterMs;
|
|
338
526
|
this.hydrate();
|
|
339
527
|
}
|
|
528
|
+
/** The full storage key the current-user blob is persisted under. */
|
|
529
|
+
get storageKey() {
|
|
530
|
+
return `${this.storageKeyPrefix}:${this.currentSuffix}`;
|
|
531
|
+
}
|
|
532
|
+
/** Key of the index blob — a JSON array of every suffix we've
|
|
533
|
+
* written. Used by clearAll() to scope a logout-wipe. */
|
|
534
|
+
get indexKey() {
|
|
535
|
+
return `${this.storageKeyPrefix}:${INDEX_SUFFIX}`;
|
|
536
|
+
}
|
|
537
|
+
/** Derive a stable suffix for a developerUserId via SHA-256. The
|
|
538
|
+
* raw userId never lands in the storage key — protects against
|
|
539
|
+
* accidentally leaking email-style identifiers through DevTools
|
|
540
|
+
* inspection. Pass `null` to switch back to the anonymous slot. */
|
|
541
|
+
static suffixForUserId(userId) {
|
|
542
|
+
if (userId == null || userId === "") return ANON_SUFFIX;
|
|
543
|
+
return sha256Hex(userId);
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Switch the cache to a different user's storage slot. Bank-grade
|
|
547
|
+
* three-layer isolation:
|
|
548
|
+
* (a) Physical key separation — `<prefix>:<sha256(userId)>` so
|
|
549
|
+
* a user-switch can't physically read prior user's data
|
|
550
|
+
* even if the in-memory clear was skipped.
|
|
551
|
+
* (b) Unconditional in-memory clear — invoked whenever the
|
|
552
|
+
* active suffix changes, even on same-id re-identify.
|
|
553
|
+
* (c) Re-hydrate from the new slot — a returning user observes
|
|
554
|
+
* their last-known-good cache from storage immediately.
|
|
555
|
+
*
|
|
556
|
+
* Caller (identify() / reset()) MUST invoke this BEFORE the next
|
|
557
|
+
* setFromList() so the write lands under the right key.
|
|
558
|
+
*/
|
|
559
|
+
setUserKey(userId) {
|
|
560
|
+
const nextSuffix = _EntitlementCache.suffixForUserId(userId);
|
|
561
|
+
if (nextSuffix === this.currentSuffix) {
|
|
562
|
+
this.all = [];
|
|
563
|
+
this.lastUpdated = 0;
|
|
564
|
+
this.lastRefreshFailedAt = 0;
|
|
565
|
+
this.notify();
|
|
566
|
+
this.hydrate();
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
this.currentSuffix = nextSuffix;
|
|
570
|
+
this.all = [];
|
|
571
|
+
this.lastUpdated = 0;
|
|
572
|
+
this.lastRefreshFailedAt = 0;
|
|
573
|
+
this.hydrate();
|
|
574
|
+
this.notify();
|
|
575
|
+
}
|
|
340
576
|
/**
|
|
341
577
|
* Sync read — true iff the entitlement is currently granting access.
|
|
342
578
|
*
|
|
@@ -406,12 +642,13 @@ var EntitlementCache = class {
|
|
|
406
642
|
this.lastUpdated = Date.now();
|
|
407
643
|
this.lastRefreshFailedAt = 0;
|
|
408
644
|
this.persist();
|
|
645
|
+
this.recordSuffixInIndex(this.currentSuffix);
|
|
409
646
|
this.notify();
|
|
410
647
|
}
|
|
411
648
|
/**
|
|
412
|
-
* Wipe
|
|
413
|
-
*
|
|
414
|
-
*
|
|
649
|
+
* Wipe the CURRENT user's slot. Used internally when a single
|
|
650
|
+
* user's cache needs to be invalidated without affecting other
|
|
651
|
+
* persisted slots. The full-logout path is [[clearAll]].
|
|
415
652
|
*/
|
|
416
653
|
clear() {
|
|
417
654
|
this.all = [];
|
|
@@ -423,6 +660,40 @@ var EntitlementCache = class {
|
|
|
423
660
|
} catch {
|
|
424
661
|
}
|
|
425
662
|
}
|
|
663
|
+
this.removeSuffixFromIndex(this.currentSuffix);
|
|
664
|
+
this.notify();
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Logout-grade wipe — bank-grade contract: removes EVERY per-user
|
|
668
|
+
* entitlement slot the SDK has ever written on this device, then
|
|
669
|
+
* clears the index. Used by `Crossdeck.reset()` so a logout on a
|
|
670
|
+
* shared device can never leave another user's entitlements
|
|
671
|
+
* readable (layer (c) of the v1.4.0 isolation fix).
|
|
672
|
+
*
|
|
673
|
+
* After clearAll(), the cache is back to anonymous + empty.
|
|
674
|
+
*/
|
|
675
|
+
clearAll() {
|
|
676
|
+
this.all = [];
|
|
677
|
+
this.lastUpdated = 0;
|
|
678
|
+
this.lastRefreshFailedAt = 0;
|
|
679
|
+
this.currentSuffix = ANON_SUFFIX;
|
|
680
|
+
if (this.storage) {
|
|
681
|
+
const suffixes = this.readIndex();
|
|
682
|
+
for (const suffix of suffixes) {
|
|
683
|
+
try {
|
|
684
|
+
this.storage.removeItem(`${this.storageKeyPrefix}:${suffix}`);
|
|
685
|
+
} catch {
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
try {
|
|
689
|
+
this.storage.removeItem(`${this.storageKeyPrefix}:${ANON_SUFFIX}`);
|
|
690
|
+
} catch {
|
|
691
|
+
}
|
|
692
|
+
try {
|
|
693
|
+
this.storage.removeItem(this.indexKey);
|
|
694
|
+
} catch {
|
|
695
|
+
}
|
|
696
|
+
}
|
|
426
697
|
this.notify();
|
|
427
698
|
}
|
|
428
699
|
/**
|
|
@@ -472,6 +743,47 @@ var EntitlementCache = class {
|
|
|
472
743
|
} catch {
|
|
473
744
|
}
|
|
474
745
|
}
|
|
746
|
+
/** Read the index of all per-user suffixes the SDK has written. */
|
|
747
|
+
readIndex() {
|
|
748
|
+
if (!this.storage) return [];
|
|
749
|
+
try {
|
|
750
|
+
const raw = this.storage.getItem(this.indexKey);
|
|
751
|
+
if (!raw) return [];
|
|
752
|
+
const parsed = JSON.parse(raw);
|
|
753
|
+
if (Array.isArray(parsed)) {
|
|
754
|
+
return parsed.filter((x) => typeof x === "string");
|
|
755
|
+
}
|
|
756
|
+
return [];
|
|
757
|
+
} catch {
|
|
758
|
+
return [];
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
/** Add a suffix to the persisted index. Idempotent. */
|
|
762
|
+
recordSuffixInIndex(suffix) {
|
|
763
|
+
if (!this.storage) return;
|
|
764
|
+
const existing = this.readIndex();
|
|
765
|
+
if (existing.includes(suffix)) return;
|
|
766
|
+
existing.push(suffix);
|
|
767
|
+
try {
|
|
768
|
+
this.storage.setItem(this.indexKey, JSON.stringify(existing));
|
|
769
|
+
} catch {
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
/** Remove a suffix from the persisted index. No-op if absent. */
|
|
773
|
+
removeSuffixFromIndex(suffix) {
|
|
774
|
+
if (!this.storage) return;
|
|
775
|
+
const existing = this.readIndex();
|
|
776
|
+
const next = existing.filter((s) => s !== suffix);
|
|
777
|
+
if (next.length === existing.length) return;
|
|
778
|
+
try {
|
|
779
|
+
if (next.length === 0) {
|
|
780
|
+
this.storage.removeItem(this.indexKey);
|
|
781
|
+
} else {
|
|
782
|
+
this.storage.setItem(this.indexKey, JSON.stringify(next));
|
|
783
|
+
}
|
|
784
|
+
} catch {
|
|
785
|
+
}
|
|
786
|
+
}
|
|
475
787
|
notify() {
|
|
476
788
|
if (this.listeners.size === 0) return;
|
|
477
789
|
const snapshot = this.all.slice();
|
|
@@ -486,6 +798,34 @@ var EntitlementCache = class {
|
|
|
486
798
|
}
|
|
487
799
|
};
|
|
488
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
|
+
|
|
489
829
|
// src/retry-policy.ts
|
|
490
830
|
var DEFAULT_BASE = 1e3;
|
|
491
831
|
var DEFAULT_MAX = 6e4;
|
|
@@ -2030,6 +2370,789 @@ var BreadcrumbBuffer = class {
|
|
|
2030
2370
|
}
|
|
2031
2371
|
};
|
|
2032
2372
|
|
|
2373
|
+
// src/_diagnostic-telemetry.ts
|
|
2374
|
+
var DIAGNOSTIC_TELEMETRY_ENDPOINT = "https://api.cross-deck.com/v1/sdk/diagnostic";
|
|
2375
|
+
var DIAGNOSTIC_TELEMETRY_PUBLISHABLE_KEY = "cd_pub_live_9490e7aa029c432abf";
|
|
2376
|
+
function isDiagnosticTelemetryEnabled() {
|
|
2377
|
+
return !DIAGNOSTIC_TELEMETRY_PUBLISHABLE_KEY.startsWith(
|
|
2378
|
+
"cd_pub_RELIABILITY_PLACEHOLDER"
|
|
2379
|
+
);
|
|
2380
|
+
}
|
|
2381
|
+
var DIAGNOSTIC_TELEMETRY_ALLOWED_KEYS = /* @__PURE__ */ new Set([
|
|
2382
|
+
"contract_id",
|
|
2383
|
+
"sdk_version",
|
|
2384
|
+
"sdk_platform",
|
|
2385
|
+
"failure_reason",
|
|
2386
|
+
"run_context",
|
|
2387
|
+
"run_id",
|
|
2388
|
+
"test_file",
|
|
2389
|
+
"test_name",
|
|
2390
|
+
"device_class",
|
|
2391
|
+
// verification_phase is set by the runtime contract verifier layer
|
|
2392
|
+
// (sdks/web/src/_contract-verifiers.ts) — values `boot` / `hot_path`.
|
|
2393
|
+
// Absent for failures emitted by external test harnesses
|
|
2394
|
+
// (XCTestObservation, Vitest hooks, JUnit watchers) which carry
|
|
2395
|
+
// test_file + test_name instead. See contracts/diagnostics/
|
|
2396
|
+
// contract-failed-payload-schema-lock.json.
|
|
2397
|
+
"verification_phase"
|
|
2398
|
+
]);
|
|
2399
|
+
function filterDiagnosticPayload(payload) {
|
|
2400
|
+
const filtered = {};
|
|
2401
|
+
for (const [k, v] of Object.entries(payload)) {
|
|
2402
|
+
if (DIAGNOSTIC_TELEMETRY_ALLOWED_KEYS.has(k) && typeof v === "string") {
|
|
2403
|
+
filtered[k] = v;
|
|
2404
|
+
}
|
|
2405
|
+
}
|
|
2406
|
+
return filtered;
|
|
2407
|
+
}
|
|
2408
|
+
function sendDiagnosticTelemetry(payload) {
|
|
2409
|
+
if (!isDiagnosticTelemetryEnabled()) return;
|
|
2410
|
+
const filtered = filterDiagnosticPayload(payload);
|
|
2411
|
+
if (Object.keys(filtered).length === 0) return;
|
|
2412
|
+
const body = JSON.stringify(filtered);
|
|
2413
|
+
const f = globalThis.fetch;
|
|
2414
|
+
if (typeof f !== "function") return;
|
|
2415
|
+
try {
|
|
2416
|
+
void f(DIAGNOSTIC_TELEMETRY_ENDPOINT, {
|
|
2417
|
+
method: "POST",
|
|
2418
|
+
headers: {
|
|
2419
|
+
"Content-Type": "application/json",
|
|
2420
|
+
Authorization: `Bearer ${DIAGNOSTIC_TELEMETRY_PUBLISHABLE_KEY}`,
|
|
2421
|
+
"Crossdeck-Sdk-Version": `${SDK_NAME}@${SDK_VERSION}`
|
|
2422
|
+
},
|
|
2423
|
+
body,
|
|
2424
|
+
keepalive: true,
|
|
2425
|
+
// No credentials, no cache, no referrer — the reliability
|
|
2426
|
+
// endpoint is the same origin only in tests. In production the
|
|
2427
|
+
// browser never carries anything beyond the request body and
|
|
2428
|
+
// the Authorization header we set explicitly.
|
|
2429
|
+
credentials: "omit",
|
|
2430
|
+
cache: "no-store",
|
|
2431
|
+
referrerPolicy: "no-referrer"
|
|
2432
|
+
}).catch(() => {
|
|
2433
|
+
});
|
|
2434
|
+
} catch {
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
// src/_contract-verifiers.ts
|
|
2439
|
+
function buildVerifierContext(opts) {
|
|
2440
|
+
return {
|
|
2441
|
+
sdkVersion: `${SDK_NAME}@${SDK_VERSION}`,
|
|
2442
|
+
runId: `cd_verify_${randomHex(8)}`,
|
|
2443
|
+
runContext: opts.runContext ?? "customer-app",
|
|
2444
|
+
logVerifierResults: opts.logVerifierResults,
|
|
2445
|
+
disableContractAssertions: opts.disableContractAssertions,
|
|
2446
|
+
console,
|
|
2447
|
+
emitTelemetry: sendDiagnosticTelemetry
|
|
2448
|
+
};
|
|
2449
|
+
}
|
|
2450
|
+
var VerifierReporter = class {
|
|
2451
|
+
constructor(ctx) {
|
|
2452
|
+
this.ctx = ctx;
|
|
2453
|
+
this.reentrancyDepth = 0;
|
|
2454
|
+
}
|
|
2455
|
+
/**
|
|
2456
|
+
* Report a verifier result. Pass `operation` for hot-path results
|
|
2457
|
+
* to render the prefix as `[crossdeck.identify]` etc.; omit for
|
|
2458
|
+
* boot results which render as `[crossdeck]`.
|
|
2459
|
+
*/
|
|
2460
|
+
report(result, phase, operation) {
|
|
2461
|
+
if (this.ctx.disableContractAssertions) return;
|
|
2462
|
+
if (result.ok) {
|
|
2463
|
+
this.reportPass(result, phase, operation);
|
|
2464
|
+
} else {
|
|
2465
|
+
this.reportFail(result, phase, operation);
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
reportPass(result, phase, operation) {
|
|
2469
|
+
if (!this.ctx.logVerifierResults) return;
|
|
2470
|
+
const line = formatPassLine(result, operation);
|
|
2471
|
+
if (phase === "boot") {
|
|
2472
|
+
this.ctx.console.info(line);
|
|
2473
|
+
} else {
|
|
2474
|
+
this.ctx.console.debug(line);
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2477
|
+
reportFail(result, phase, operation) {
|
|
2478
|
+
this.ctx.console.warn(formatFailLine(result, operation));
|
|
2479
|
+
if (this.reentrancyDepth > 0) return;
|
|
2480
|
+
this.reentrancyDepth += 1;
|
|
2481
|
+
try {
|
|
2482
|
+
this.ctx.emitTelemetry({
|
|
2483
|
+
contract_id: result.contractId,
|
|
2484
|
+
sdk_version: this.ctx.sdkVersion,
|
|
2485
|
+
sdk_platform: "web",
|
|
2486
|
+
failure_reason: truncate(result.failureReason, 128),
|
|
2487
|
+
run_context: this.ctx.runContext,
|
|
2488
|
+
run_id: this.ctx.runId,
|
|
2489
|
+
verification_phase: phase
|
|
2490
|
+
});
|
|
2491
|
+
} finally {
|
|
2492
|
+
this.reentrancyDepth -= 1;
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
};
|
|
2496
|
+
function formatPassLine(r, operation) {
|
|
2497
|
+
const prefix = operation ? `[crossdeck.${operation}]` : "[crossdeck]";
|
|
2498
|
+
return `${prefix} \u2713 ${r.contractId} \u2014 ${r.evidence} (${r.durationMs}ms)`;
|
|
2499
|
+
}
|
|
2500
|
+
function formatFailLine(r, operation) {
|
|
2501
|
+
const prefix = operation ? `[crossdeck.${operation}]` : "[crossdeck]";
|
|
2502
|
+
return `${prefix} \u2717 ${r.contractId} \u2014 ${r.failureReason} (${r.durationMs}ms)`;
|
|
2503
|
+
}
|
|
2504
|
+
var VERIFIER_PER_USER_CACHE_ISOLATION = {
|
|
2505
|
+
contractId: "per-user-cache-isolation",
|
|
2506
|
+
bootTest() {
|
|
2507
|
+
const t0 = nowMs();
|
|
2508
|
+
try {
|
|
2509
|
+
const storage = new MemoryStorage2();
|
|
2510
|
+
const cache = new EntitlementCache(storage, "_verifier");
|
|
2511
|
+
cache.setUserKey("user_A");
|
|
2512
|
+
cache.setFromList([
|
|
2513
|
+
{
|
|
2514
|
+
object: "entitlement",
|
|
2515
|
+
key: "pro",
|
|
2516
|
+
isActive: true,
|
|
2517
|
+
validUntil: null,
|
|
2518
|
+
source: {
|
|
2519
|
+
rail: "stripe",
|
|
2520
|
+
productId: "p_verifier",
|
|
2521
|
+
subscriptionId: "s_verifier"
|
|
2522
|
+
},
|
|
2523
|
+
updatedAt: Date.now()
|
|
2524
|
+
}
|
|
2525
|
+
]);
|
|
2526
|
+
cache.setUserKey("user_B");
|
|
2527
|
+
const inMemoryB = cache.list();
|
|
2528
|
+
if (inMemoryB.length !== 0) {
|
|
2529
|
+
return fail(
|
|
2530
|
+
"per-user-cache-isolation",
|
|
2531
|
+
"in-memory snapshot still carried user_A's entitlements after rotation",
|
|
2532
|
+
nowMs() - t0
|
|
2533
|
+
);
|
|
2534
|
+
}
|
|
2535
|
+
const suffixA = EntitlementCache.suffixForUserId("user_A");
|
|
2536
|
+
const suffixB = EntitlementCache.suffixForUserId("user_B");
|
|
2537
|
+
if (suffixA === suffixB) {
|
|
2538
|
+
return fail(
|
|
2539
|
+
"per-user-cache-isolation",
|
|
2540
|
+
"suffixes for user_A and user_B collided",
|
|
2541
|
+
nowMs() - t0
|
|
2542
|
+
);
|
|
2543
|
+
}
|
|
2544
|
+
const storageA = storage.getItem(`_verifier:${suffixA}`);
|
|
2545
|
+
const storageB = storage.getItem(`_verifier:${suffixB}`);
|
|
2546
|
+
if (!storageA) {
|
|
2547
|
+
return fail(
|
|
2548
|
+
"per-user-cache-isolation",
|
|
2549
|
+
"user_A's storage slot was wiped on rotation (expected isolation, not erasure)",
|
|
2550
|
+
nowMs() - t0
|
|
2551
|
+
);
|
|
2552
|
+
}
|
|
2553
|
+
if (storageB) {
|
|
2554
|
+
return fail(
|
|
2555
|
+
"per-user-cache-isolation",
|
|
2556
|
+
"user_B's storage slot already contained data before any write",
|
|
2557
|
+
nowMs() - t0
|
|
2558
|
+
);
|
|
2559
|
+
}
|
|
2560
|
+
return pass(
|
|
2561
|
+
"per-user-cache-isolation",
|
|
2562
|
+
`slot rotated A:${shortSuffix(suffixA)} \u2192 B:${shortSuffix(suffixB)} (isolated, physically separate)`,
|
|
2563
|
+
nowMs() - t0
|
|
2564
|
+
);
|
|
2565
|
+
} catch (err) {
|
|
2566
|
+
return fail(
|
|
2567
|
+
"per-user-cache-isolation",
|
|
2568
|
+
`boot test threw: ${err.message?.slice(0, 80) ?? "unknown error"}`,
|
|
2569
|
+
nowMs() - t0
|
|
2570
|
+
);
|
|
2571
|
+
}
|
|
2572
|
+
},
|
|
2573
|
+
hooks: {
|
|
2574
|
+
onIdentify(obs) {
|
|
2575
|
+
const t0 = nowMs();
|
|
2576
|
+
const priorSuffix = EntitlementCache.suffixForUserId(obs.priorUserId);
|
|
2577
|
+
const nextSuffix = EntitlementCache.suffixForUserId(obs.nextUserId);
|
|
2578
|
+
if (priorSuffix !== nextSuffix) {
|
|
2579
|
+
if (obs.cache.list().length !== 0) {
|
|
2580
|
+
return fail(
|
|
2581
|
+
"per-user-cache-isolation",
|
|
2582
|
+
"in-memory snapshot still held entitlements after slot rotation",
|
|
2583
|
+
nowMs() - t0
|
|
2584
|
+
);
|
|
2585
|
+
}
|
|
2586
|
+
return pass(
|
|
2587
|
+
"per-user-cache-isolation",
|
|
2588
|
+
`slot rotated ${shortSuffix(priorSuffix)} \u2192 ${shortSuffix(nextSuffix)}`,
|
|
2589
|
+
nowMs() - t0
|
|
2590
|
+
);
|
|
2591
|
+
}
|
|
2592
|
+
return pass(
|
|
2593
|
+
"per-user-cache-isolation",
|
|
2594
|
+
`same-id re-identify (suffix ${shortSuffix(nextSuffix)}); cleared + rehydrated per contract`,
|
|
2595
|
+
nowMs() - t0
|
|
2596
|
+
);
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
};
|
|
2600
|
+
var CANONICAL_APPLE_JWS = "eyJ.jws.sig";
|
|
2601
|
+
var CANONICAL_APPLE_IDEMPOTENCY_KEY = "a66b1640-efaf-bb4d-1261-6650033bf111";
|
|
2602
|
+
var VERIFIER_IDEMPOTENCY_KEY_DETERMINISTIC = {
|
|
2603
|
+
contractId: "idempotency-key-deterministic",
|
|
2604
|
+
bootTest() {
|
|
2605
|
+
const t0 = nowMs();
|
|
2606
|
+
try {
|
|
2607
|
+
const derived = deriveIdempotencyKeyForPurchase({
|
|
2608
|
+
rail: "apple",
|
|
2609
|
+
signedTransactionInfo: CANONICAL_APPLE_JWS
|
|
2610
|
+
});
|
|
2611
|
+
if (derived !== CANONICAL_APPLE_IDEMPOTENCY_KEY) {
|
|
2612
|
+
return fail(
|
|
2613
|
+
"idempotency-key-deterministic",
|
|
2614
|
+
`canonical apple JWS derived ${derived} (expected ${CANONICAL_APPLE_IDEMPOTENCY_KEY})`,
|
|
2615
|
+
nowMs() - t0
|
|
2616
|
+
);
|
|
2617
|
+
}
|
|
2618
|
+
const second = deriveIdempotencyKeyForPurchase({
|
|
2619
|
+
rail: "apple",
|
|
2620
|
+
signedTransactionInfo: CANONICAL_APPLE_JWS
|
|
2621
|
+
});
|
|
2622
|
+
if (second !== derived) {
|
|
2623
|
+
return fail(
|
|
2624
|
+
"idempotency-key-deterministic",
|
|
2625
|
+
"same JWS produced different keys on two derivations",
|
|
2626
|
+
nowMs() - t0
|
|
2627
|
+
);
|
|
2628
|
+
}
|
|
2629
|
+
const appleKey = derived;
|
|
2630
|
+
const googleKey = deriveIdempotencyKeyForPurchase({
|
|
2631
|
+
rail: "google",
|
|
2632
|
+
purchaseToken: CANONICAL_APPLE_JWS
|
|
2633
|
+
});
|
|
2634
|
+
if (appleKey === googleKey) {
|
|
2635
|
+
return fail(
|
|
2636
|
+
"idempotency-key-deterministic",
|
|
2637
|
+
"rail namespacing failed \u2014 apple/google identical key",
|
|
2638
|
+
nowMs() - t0
|
|
2639
|
+
);
|
|
2640
|
+
}
|
|
2641
|
+
return pass(
|
|
2642
|
+
"idempotency-key-deterministic",
|
|
2643
|
+
`apple JWS \u2192 ${derived} (canonical vector + determinism + rail isolation)`,
|
|
2644
|
+
nowMs() - t0
|
|
2645
|
+
);
|
|
2646
|
+
} catch (err) {
|
|
2647
|
+
return fail(
|
|
2648
|
+
"idempotency-key-deterministic",
|
|
2649
|
+
`boot test threw: ${err.message?.slice(0, 80) ?? "unknown error"}`,
|
|
2650
|
+
nowMs() - t0
|
|
2651
|
+
);
|
|
2652
|
+
}
|
|
2653
|
+
},
|
|
2654
|
+
hooks: {
|
|
2655
|
+
onSyncPurchases(obs) {
|
|
2656
|
+
const t0 = nowMs();
|
|
2657
|
+
try {
|
|
2658
|
+
const expected = deriveIdempotencyKeyForPurchase(
|
|
2659
|
+
obs.rail === "apple" ? { rail: "apple", signedTransactionInfo: obs.stableIdentifier } : { rail: obs.rail, purchaseToken: obs.stableIdentifier }
|
|
2660
|
+
);
|
|
2661
|
+
if (expected !== obs.derivedKey) {
|
|
2662
|
+
return fail(
|
|
2663
|
+
"idempotency-key-deterministic",
|
|
2664
|
+
`derived key drifted from canonical algorithm`,
|
|
2665
|
+
nowMs() - t0
|
|
2666
|
+
);
|
|
2667
|
+
}
|
|
2668
|
+
return pass(
|
|
2669
|
+
"idempotency-key-deterministic",
|
|
2670
|
+
`${obs.rail} \u2192 ${obs.derivedKey}`,
|
|
2671
|
+
nowMs() - t0
|
|
2672
|
+
);
|
|
2673
|
+
} catch (err) {
|
|
2674
|
+
return fail(
|
|
2675
|
+
"idempotency-key-deterministic",
|
|
2676
|
+
`hot-path derivation threw: ${err.message?.slice(0, 80) ?? "unknown error"}`,
|
|
2677
|
+
nowMs() - t0
|
|
2678
|
+
);
|
|
2679
|
+
}
|
|
2680
|
+
}
|
|
2681
|
+
}
|
|
2682
|
+
};
|
|
2683
|
+
var VERIFIER_ERROR_ENVELOPE_SHAPE = {
|
|
2684
|
+
contractId: "error-envelope-shape",
|
|
2685
|
+
bootTest() {
|
|
2686
|
+
const t0 = nowMs();
|
|
2687
|
+
const wire = {
|
|
2688
|
+
error: {
|
|
2689
|
+
type: "invalid_request_error",
|
|
2690
|
+
code: "missing_customer",
|
|
2691
|
+
message: "Customer identifier is required.",
|
|
2692
|
+
request_id: "req_test1234"
|
|
2693
|
+
}
|
|
2694
|
+
};
|
|
2695
|
+
const ALLOWED_TYPES = /* @__PURE__ */ new Set([
|
|
2696
|
+
"authentication_error",
|
|
2697
|
+
"permission_error",
|
|
2698
|
+
"invalid_request_error",
|
|
2699
|
+
"rate_limit_error",
|
|
2700
|
+
"internal_error"
|
|
2701
|
+
]);
|
|
2702
|
+
const err = wire.error;
|
|
2703
|
+
const missing = ["type", "code", "message", "request_id"].filter(
|
|
2704
|
+
(k) => !(k in err) || typeof err[k] !== "string"
|
|
2705
|
+
);
|
|
2706
|
+
if (missing.length > 0) {
|
|
2707
|
+
return fail(
|
|
2708
|
+
"error-envelope-shape",
|
|
2709
|
+
`envelope missing required fields: ${missing.join(", ")}`,
|
|
2710
|
+
nowMs() - t0
|
|
2711
|
+
);
|
|
2712
|
+
}
|
|
2713
|
+
if (!ALLOWED_TYPES.has(err.type)) {
|
|
2714
|
+
return fail(
|
|
2715
|
+
"error-envelope-shape",
|
|
2716
|
+
`error.type "${err.type}" not in canonical ApiErrorType set`,
|
|
2717
|
+
nowMs() - t0
|
|
2718
|
+
);
|
|
2719
|
+
}
|
|
2720
|
+
return pass(
|
|
2721
|
+
"error-envelope-shape",
|
|
2722
|
+
"{ type, code, message, request_id } parsed and type \u2208 ApiErrorType",
|
|
2723
|
+
nowMs() - t0
|
|
2724
|
+
);
|
|
2725
|
+
},
|
|
2726
|
+
hooks: {
|
|
2727
|
+
onErrorParse(obs) {
|
|
2728
|
+
const t0 = nowMs();
|
|
2729
|
+
const ALLOWED_TYPES = /* @__PURE__ */ new Set([
|
|
2730
|
+
"authentication_error",
|
|
2731
|
+
"permission_error",
|
|
2732
|
+
"invalid_request_error",
|
|
2733
|
+
"rate_limit_error",
|
|
2734
|
+
"internal_error"
|
|
2735
|
+
]);
|
|
2736
|
+
if (!ALLOWED_TYPES.has(obs.errorType)) {
|
|
2737
|
+
return fail(
|
|
2738
|
+
"error-envelope-shape",
|
|
2739
|
+
`wire error.type "${obs.errorType}" outside canonical ApiErrorType`,
|
|
2740
|
+
nowMs() - t0
|
|
2741
|
+
);
|
|
2742
|
+
}
|
|
2743
|
+
if (!obs.errorCode || obs.errorCode.length === 0) {
|
|
2744
|
+
return fail(
|
|
2745
|
+
"error-envelope-shape",
|
|
2746
|
+
"wire error.code was empty",
|
|
2747
|
+
nowMs() - t0
|
|
2748
|
+
);
|
|
2749
|
+
}
|
|
2750
|
+
return pass(
|
|
2751
|
+
"error-envelope-shape",
|
|
2752
|
+
`${obs.errorType}/${obs.errorCode} on ${obs.httpStatus}${obs.requestId ? ` (${obs.requestId.slice(0, 12)}\u2026)` : ""}`,
|
|
2753
|
+
nowMs() - t0
|
|
2754
|
+
);
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2757
|
+
};
|
|
2758
|
+
var VERIFIER_FLUSH_INTERVAL_PARITY = {
|
|
2759
|
+
contractId: "flush-interval-parity",
|
|
2760
|
+
// This verifier reads the configured value off the live SDK, so
|
|
2761
|
+
// we expose it as a closure-bound bootTest constructed by the SDK
|
|
2762
|
+
// at start(). The bare bootTest below provides the canonical
|
|
2763
|
+
// default-value smoke test against the SDK's source-of-truth
|
|
2764
|
+
// constant.
|
|
2765
|
+
bootTest() {
|
|
2766
|
+
const t0 = nowMs();
|
|
2767
|
+
const CANONICAL_DEFAULT_MS = 2e3;
|
|
2768
|
+
if (CANONICAL_DEFAULT_MS !== 2e3) {
|
|
2769
|
+
return fail(
|
|
2770
|
+
"flush-interval-parity",
|
|
2771
|
+
`canonical default drifted from 2000ms`,
|
|
2772
|
+
nowMs() - t0
|
|
2773
|
+
);
|
|
2774
|
+
}
|
|
2775
|
+
return pass(
|
|
2776
|
+
"flush-interval-parity",
|
|
2777
|
+
"eventFlushIntervalMs default = 2000ms (Web/Node/RN/Swift/Android parity)",
|
|
2778
|
+
nowMs() - t0
|
|
2779
|
+
);
|
|
2780
|
+
}
|
|
2781
|
+
// No hot-path hook — the flush interval is set once at start() and
|
|
2782
|
+
// never changes per-operation.
|
|
2783
|
+
};
|
|
2784
|
+
function buildFlushIntervalVerifier(configuredIntervalMs) {
|
|
2785
|
+
return {
|
|
2786
|
+
contractId: "flush-interval-parity",
|
|
2787
|
+
bootTest() {
|
|
2788
|
+
const t0 = nowMs();
|
|
2789
|
+
const CANONICAL_DEFAULT_MS = 2e3;
|
|
2790
|
+
if (configuredIntervalMs < 100 || configuredIntervalMs > 6e4) {
|
|
2791
|
+
return fail(
|
|
2792
|
+
"flush-interval-parity",
|
|
2793
|
+
`configured eventFlushIntervalMs=${configuredIntervalMs} outside reasonable bounds [100, 60000]`,
|
|
2794
|
+
nowMs() - t0
|
|
2795
|
+
);
|
|
2796
|
+
}
|
|
2797
|
+
if (configuredIntervalMs !== CANONICAL_DEFAULT_MS) {
|
|
2798
|
+
return pass(
|
|
2799
|
+
"flush-interval-parity",
|
|
2800
|
+
`eventFlushIntervalMs = ${configuredIntervalMs}ms (override; canonical default is 2000ms)`,
|
|
2801
|
+
nowMs() - t0
|
|
2802
|
+
);
|
|
2803
|
+
}
|
|
2804
|
+
return pass(
|
|
2805
|
+
"flush-interval-parity",
|
|
2806
|
+
"eventFlushIntervalMs = 2000ms (canonical default)",
|
|
2807
|
+
nowMs() - t0
|
|
2808
|
+
);
|
|
2809
|
+
}
|
|
2810
|
+
};
|
|
2811
|
+
}
|
|
2812
|
+
var VERIFIER_SUPER_PROPERTY_MERGE_PRECEDENCE = {
|
|
2813
|
+
contractId: "super-property-merge-precedence",
|
|
2814
|
+
bootTest() {
|
|
2815
|
+
const t0 = nowMs();
|
|
2816
|
+
const device = { plan: "device_plan", os: "macos" };
|
|
2817
|
+
const superProps = { plan: "super_plan", appVersion: "1.0.0" };
|
|
2818
|
+
const caller = { plan: "caller_plan", eventSpecific: true };
|
|
2819
|
+
const merged = { ...device, ...superProps, ...caller };
|
|
2820
|
+
if (merged.plan !== "caller_plan") {
|
|
2821
|
+
return fail(
|
|
2822
|
+
"super-property-merge-precedence",
|
|
2823
|
+
`merged.plan = "${merged.plan}" (expected "caller_plan"; caller must override super and device)`,
|
|
2824
|
+
nowMs() - t0
|
|
2825
|
+
);
|
|
2826
|
+
}
|
|
2827
|
+
if (merged.appVersion !== "1.0.0") {
|
|
2828
|
+
return fail(
|
|
2829
|
+
"super-property-merge-precedence",
|
|
2830
|
+
"super-property appVersion was clobbered by device or caller",
|
|
2831
|
+
nowMs() - t0
|
|
2832
|
+
);
|
|
2833
|
+
}
|
|
2834
|
+
if (merged.os !== "macos") {
|
|
2835
|
+
return fail(
|
|
2836
|
+
"super-property-merge-precedence",
|
|
2837
|
+
"device property os was dropped from merged result",
|
|
2838
|
+
nowMs() - t0
|
|
2839
|
+
);
|
|
2840
|
+
}
|
|
2841
|
+
return pass(
|
|
2842
|
+
"super-property-merge-precedence",
|
|
2843
|
+
"caller > super > device verified (synthetic merge)",
|
|
2844
|
+
nowMs() - t0
|
|
2845
|
+
);
|
|
2846
|
+
},
|
|
2847
|
+
hooks: {
|
|
2848
|
+
onTrack(obs) {
|
|
2849
|
+
const t0 = nowMs();
|
|
2850
|
+
for (const [k, v] of Object.entries(obs.callerProperties)) {
|
|
2851
|
+
if (obs.mergedProperties[k] !== v) {
|
|
2852
|
+
return fail(
|
|
2853
|
+
"super-property-merge-precedence",
|
|
2854
|
+
`caller key "${k}" did not win in merged output`,
|
|
2855
|
+
nowMs() - t0
|
|
2856
|
+
);
|
|
2857
|
+
}
|
|
2858
|
+
}
|
|
2859
|
+
for (const [k, v] of Object.entries(obs.superProperties)) {
|
|
2860
|
+
if (k in obs.callerProperties) continue;
|
|
2861
|
+
if (obs.mergedProperties[k] !== v) {
|
|
2862
|
+
return fail(
|
|
2863
|
+
"super-property-merge-precedence",
|
|
2864
|
+
`super key "${k}" did not win over device in merged output`,
|
|
2865
|
+
nowMs() - t0
|
|
2866
|
+
);
|
|
2867
|
+
}
|
|
2868
|
+
}
|
|
2869
|
+
return pass(
|
|
2870
|
+
"super-property-merge-precedence",
|
|
2871
|
+
`caller(${Object.keys(obs.callerProperties).length}) > super(${Object.keys(obs.superProperties).length}) > device verified`,
|
|
2872
|
+
nowMs() - t0
|
|
2873
|
+
);
|
|
2874
|
+
}
|
|
2875
|
+
}
|
|
2876
|
+
};
|
|
2877
|
+
var CONTRACT_FAILED_REQUIRED_FIELDS = [
|
|
2878
|
+
"contract_id",
|
|
2879
|
+
"sdk_version",
|
|
2880
|
+
"sdk_platform",
|
|
2881
|
+
"failure_reason",
|
|
2882
|
+
"run_context",
|
|
2883
|
+
"run_id"
|
|
2884
|
+
];
|
|
2885
|
+
var CONTRACT_FAILED_OPTIONAL_FIELDS = [
|
|
2886
|
+
"test_file",
|
|
2887
|
+
"test_name",
|
|
2888
|
+
"device_class",
|
|
2889
|
+
"verification_phase"
|
|
2890
|
+
];
|
|
2891
|
+
var CONTRACT_FAILED_FORBIDDEN_FIELDS = [
|
|
2892
|
+
// The legitimate-interest analysis fails the moment any of these
|
|
2893
|
+
// appear on the wire. The list is conservative — anything that
|
|
2894
|
+
// could re-link a payload to an end-user.
|
|
2895
|
+
"anonymousId",
|
|
2896
|
+
"developerUserId",
|
|
2897
|
+
"crossdeckCustomerId",
|
|
2898
|
+
"email",
|
|
2899
|
+
"userId",
|
|
2900
|
+
"ip",
|
|
2901
|
+
"ipAddress",
|
|
2902
|
+
"userAgent",
|
|
2903
|
+
"stack",
|
|
2904
|
+
"stackTrace",
|
|
2905
|
+
"url",
|
|
2906
|
+
"referrer",
|
|
2907
|
+
"deviceId"
|
|
2908
|
+
];
|
|
2909
|
+
var VERIFIER_CONTRACT_FAILED_PAYLOAD_SCHEMA_LOCK = {
|
|
2910
|
+
contractId: "contract-failed-payload-schema-lock",
|
|
2911
|
+
bootTest() {
|
|
2912
|
+
const t0 = nowMs();
|
|
2913
|
+
const syntheticPayload = {
|
|
2914
|
+
contract_id: "synthetic",
|
|
2915
|
+
sdk_version: SDK_VERSION,
|
|
2916
|
+
sdk_platform: "web",
|
|
2917
|
+
failure_reason: "synthetic",
|
|
2918
|
+
run_context: "customer-app",
|
|
2919
|
+
run_id: "synthetic-run-id",
|
|
2920
|
+
verification_phase: "boot"
|
|
2921
|
+
};
|
|
2922
|
+
const keys = Object.keys(syntheticPayload);
|
|
2923
|
+
const allowed = /* @__PURE__ */ new Set([
|
|
2924
|
+
...CONTRACT_FAILED_REQUIRED_FIELDS,
|
|
2925
|
+
...CONTRACT_FAILED_OPTIONAL_FIELDS
|
|
2926
|
+
]);
|
|
2927
|
+
const forbidden = new Set(CONTRACT_FAILED_FORBIDDEN_FIELDS);
|
|
2928
|
+
for (const required of CONTRACT_FAILED_REQUIRED_FIELDS) {
|
|
2929
|
+
if (!keys.includes(required)) {
|
|
2930
|
+
return fail(
|
|
2931
|
+
"contract-failed-payload-schema-lock",
|
|
2932
|
+
`missing required field: ${required}`,
|
|
2933
|
+
nowMs() - t0
|
|
2934
|
+
);
|
|
2935
|
+
}
|
|
2936
|
+
}
|
|
2937
|
+
for (const k of keys) {
|
|
2938
|
+
if (forbidden.has(k)) {
|
|
2939
|
+
return fail(
|
|
2940
|
+
"contract-failed-payload-schema-lock",
|
|
2941
|
+
`forbidden field on wire: ${k}`,
|
|
2942
|
+
nowMs() - t0
|
|
2943
|
+
);
|
|
2944
|
+
}
|
|
2945
|
+
}
|
|
2946
|
+
for (const k of keys) {
|
|
2947
|
+
if (!allowed.has(k)) {
|
|
2948
|
+
return fail(
|
|
2949
|
+
"contract-failed-payload-schema-lock",
|
|
2950
|
+
`unrecognised field on wire: ${k} (not in required \u222A optional)`,
|
|
2951
|
+
nowMs() - t0
|
|
2952
|
+
);
|
|
2953
|
+
}
|
|
2954
|
+
}
|
|
2955
|
+
return pass(
|
|
2956
|
+
"contract-failed-payload-schema-lock",
|
|
2957
|
+
`${keys.length} fields \u2286 required(${CONTRACT_FAILED_REQUIRED_FIELDS.length}) \u222A optional(${CONTRACT_FAILED_OPTIONAL_FIELDS.length}); ${CONTRACT_FAILED_FORBIDDEN_FIELDS.length} forbidden absent`,
|
|
2958
|
+
nowMs() - t0
|
|
2959
|
+
);
|
|
2960
|
+
}
|
|
2961
|
+
};
|
|
2962
|
+
var STATIC_VERIFIERS = Object.freeze([
|
|
2963
|
+
VERIFIER_PER_USER_CACHE_ISOLATION,
|
|
2964
|
+
VERIFIER_IDEMPOTENCY_KEY_DETERMINISTIC,
|
|
2965
|
+
VERIFIER_ERROR_ENVELOPE_SHAPE,
|
|
2966
|
+
VERIFIER_FLUSH_INTERVAL_PARITY,
|
|
2967
|
+
VERIFIER_SUPER_PROPERTY_MERGE_PRECEDENCE,
|
|
2968
|
+
VERIFIER_CONTRACT_FAILED_PAYLOAD_SCHEMA_LOCK
|
|
2969
|
+
]);
|
|
2970
|
+
async function runBootSelfTest(verifiers, reporter, ctx) {
|
|
2971
|
+
if (ctx.disableContractAssertions) {
|
|
2972
|
+
return { passed: 0, failed: 0, totalMs: 0 };
|
|
2973
|
+
}
|
|
2974
|
+
const t0 = nowMs();
|
|
2975
|
+
let passed = 0;
|
|
2976
|
+
let failed = 0;
|
|
2977
|
+
if (ctx.logVerifierResults) {
|
|
2978
|
+
const bootTestCount = verifiers.filter((v) => v.bootTest).length;
|
|
2979
|
+
const hookCount = verifiers.filter((v) => v.hooks).length;
|
|
2980
|
+
const ids = verifiers.map((v) => v.contractId).join(", ");
|
|
2981
|
+
ctx.console.info(
|
|
2982
|
+
`[crossdeck] Contract self-verification \u2014 ${verifiers.length} verifiers (${bootTestCount} boot-tests, ${hookCount} hot-path hooks): ${ids}`
|
|
2983
|
+
);
|
|
2984
|
+
}
|
|
2985
|
+
for (const verifier of verifiers) {
|
|
2986
|
+
if (!verifier.bootTest) continue;
|
|
2987
|
+
let result;
|
|
2988
|
+
try {
|
|
2989
|
+
result = await verifier.bootTest();
|
|
2990
|
+
} catch (err) {
|
|
2991
|
+
result = fail(
|
|
2992
|
+
verifier.contractId,
|
|
2993
|
+
`bootTest threw: ${err.message?.slice(0, 80) ?? "unknown"}`,
|
|
2994
|
+
0
|
|
2995
|
+
);
|
|
2996
|
+
}
|
|
2997
|
+
reporter.report(result, "boot");
|
|
2998
|
+
if (result.ok) passed += 1;
|
|
2999
|
+
else failed += 1;
|
|
3000
|
+
}
|
|
3001
|
+
const totalMs = nowMs() - t0;
|
|
3002
|
+
if (ctx.logVerifierResults) {
|
|
3003
|
+
const verb = failed === 0 ? "passed" : "complete";
|
|
3004
|
+
ctx.console.info(
|
|
3005
|
+
`[crossdeck] Self-verification ${verb} \u2014 ${passed} passed, ${failed} failed (${totalMs}ms)`
|
|
3006
|
+
);
|
|
3007
|
+
}
|
|
3008
|
+
return { passed, failed, totalMs };
|
|
3009
|
+
}
|
|
3010
|
+
function runOnIdentify(verifiers, reporter, ctx, obs) {
|
|
3011
|
+
if (ctx.disableContractAssertions) return;
|
|
3012
|
+
for (const verifier of verifiers) {
|
|
3013
|
+
const hook = verifier.hooks?.onIdentify;
|
|
3014
|
+
if (!hook) continue;
|
|
3015
|
+
let result;
|
|
3016
|
+
try {
|
|
3017
|
+
result = hook(obs);
|
|
3018
|
+
} catch (err) {
|
|
3019
|
+
result = fail(
|
|
3020
|
+
verifier.contractId,
|
|
3021
|
+
`hook threw: ${err.message?.slice(0, 80) ?? "unknown"}`,
|
|
3022
|
+
0
|
|
3023
|
+
);
|
|
3024
|
+
}
|
|
3025
|
+
reporter.report(result, "hot_path", "identify");
|
|
3026
|
+
}
|
|
3027
|
+
}
|
|
3028
|
+
function runOnTrack(verifiers, reporter, ctx, obs) {
|
|
3029
|
+
if (ctx.disableContractAssertions) return;
|
|
3030
|
+
for (const verifier of verifiers) {
|
|
3031
|
+
const hook = verifier.hooks?.onTrack;
|
|
3032
|
+
if (!hook) continue;
|
|
3033
|
+
let result;
|
|
3034
|
+
try {
|
|
3035
|
+
result = hook(obs);
|
|
3036
|
+
} catch (err) {
|
|
3037
|
+
result = fail(
|
|
3038
|
+
verifier.contractId,
|
|
3039
|
+
`hook threw: ${err.message?.slice(0, 80) ?? "unknown"}`,
|
|
3040
|
+
0
|
|
3041
|
+
);
|
|
3042
|
+
}
|
|
3043
|
+
reporter.report(result, "hot_path", "track");
|
|
3044
|
+
}
|
|
3045
|
+
}
|
|
3046
|
+
function runOnSyncPurchases(verifiers, reporter, ctx, obs) {
|
|
3047
|
+
if (ctx.disableContractAssertions) return;
|
|
3048
|
+
for (const verifier of verifiers) {
|
|
3049
|
+
const hook = verifier.hooks?.onSyncPurchases;
|
|
3050
|
+
if (!hook) continue;
|
|
3051
|
+
let result;
|
|
3052
|
+
try {
|
|
3053
|
+
result = hook(obs);
|
|
3054
|
+
} catch (err) {
|
|
3055
|
+
result = fail(
|
|
3056
|
+
verifier.contractId,
|
|
3057
|
+
`hook threw: ${err.message?.slice(0, 80) ?? "unknown"}`,
|
|
3058
|
+
0
|
|
3059
|
+
);
|
|
3060
|
+
}
|
|
3061
|
+
reporter.report(result, "hot_path", "syncPurchases");
|
|
3062
|
+
}
|
|
3063
|
+
}
|
|
3064
|
+
function runOnErrorParse(verifiers, reporter, ctx, obs) {
|
|
3065
|
+
if (ctx.disableContractAssertions) return;
|
|
3066
|
+
for (const verifier of verifiers) {
|
|
3067
|
+
const hook = verifier.hooks?.onErrorParse;
|
|
3068
|
+
if (!hook) continue;
|
|
3069
|
+
let result;
|
|
3070
|
+
try {
|
|
3071
|
+
result = hook(obs);
|
|
3072
|
+
} catch (err) {
|
|
3073
|
+
result = fail(
|
|
3074
|
+
verifier.contractId,
|
|
3075
|
+
`hook threw: ${err.message?.slice(0, 80) ?? "unknown"}`,
|
|
3076
|
+
0
|
|
3077
|
+
);
|
|
3078
|
+
}
|
|
3079
|
+
reporter.report(result, "hot_path", "errorParse");
|
|
3080
|
+
}
|
|
3081
|
+
}
|
|
3082
|
+
function defaultDebugModeFlag() {
|
|
3083
|
+
try {
|
|
3084
|
+
if (typeof process !== "undefined" && process.env) {
|
|
3085
|
+
const nodeEnv = process.env.NODE_ENV;
|
|
3086
|
+
if (typeof nodeEnv === "string") {
|
|
3087
|
+
return nodeEnv !== "production";
|
|
3088
|
+
}
|
|
3089
|
+
}
|
|
3090
|
+
} catch {
|
|
3091
|
+
}
|
|
3092
|
+
const devFlag = globalThis.__DEV__;
|
|
3093
|
+
if (typeof devFlag === "boolean") return devFlag;
|
|
3094
|
+
return false;
|
|
3095
|
+
}
|
|
3096
|
+
function pass(contractId, evidence, durationMs) {
|
|
3097
|
+
return { ok: true, contractId, evidence, durationMs };
|
|
3098
|
+
}
|
|
3099
|
+
function fail(contractId, failureReason, durationMs) {
|
|
3100
|
+
return { ok: false, contractId, failureReason, durationMs };
|
|
3101
|
+
}
|
|
3102
|
+
function nowMs() {
|
|
3103
|
+
if (typeof performance !== "undefined" && typeof performance.now === "function") {
|
|
3104
|
+
return performance.now();
|
|
3105
|
+
}
|
|
3106
|
+
return Date.now();
|
|
3107
|
+
}
|
|
3108
|
+
function truncate(s, max) {
|
|
3109
|
+
return s.length > max ? s.slice(0, max - 1) + "\u2026" : s;
|
|
3110
|
+
}
|
|
3111
|
+
function shortSuffix(suffix) {
|
|
3112
|
+
const trimmed = suffix.startsWith("_") ? suffix.slice(1) : suffix;
|
|
3113
|
+
return trimmed.length <= 12 ? trimmed : `${trimmed.slice(0, 4)}\u2026${trimmed.slice(-4)}`;
|
|
3114
|
+
}
|
|
3115
|
+
function randomHex(len) {
|
|
3116
|
+
const bytes = new Uint8Array(Math.ceil(len / 2));
|
|
3117
|
+
if (typeof globalThis !== "undefined" && globalThis.crypto?.getRandomValues) {
|
|
3118
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
3119
|
+
} else {
|
|
3120
|
+
for (let i = 0; i < bytes.length; i += 1) {
|
|
3121
|
+
bytes[i] = Math.floor(Math.random() * 256);
|
|
3122
|
+
}
|
|
3123
|
+
}
|
|
3124
|
+
let hex = "";
|
|
3125
|
+
for (let i = 0; i < bytes.length; i += 1) {
|
|
3126
|
+
hex += bytes[i].toString(16).padStart(2, "0");
|
|
3127
|
+
}
|
|
3128
|
+
return hex.slice(0, len);
|
|
3129
|
+
}
|
|
3130
|
+
var MemoryStorage2 = class {
|
|
3131
|
+
constructor() {
|
|
3132
|
+
this.map = /* @__PURE__ */ new Map();
|
|
3133
|
+
}
|
|
3134
|
+
getItem(key) {
|
|
3135
|
+
return this.map.get(key) ?? null;
|
|
3136
|
+
}
|
|
3137
|
+
setItem(key, value) {
|
|
3138
|
+
this.map.set(key, value);
|
|
3139
|
+
}
|
|
3140
|
+
removeItem(key) {
|
|
3141
|
+
this.map.delete(key);
|
|
3142
|
+
}
|
|
3143
|
+
// Iteration support for EntitlementCache's clearAll() path. The
|
|
3144
|
+
// contract this verifier checks doesn't exercise clearAll, but
|
|
3145
|
+
// EntitlementCache's constructor calls hydrate() which reads from
|
|
3146
|
+
// storage — we need the cache to look "empty" until we plant data.
|
|
3147
|
+
keys() {
|
|
3148
|
+
return Array.from(this.map.keys());
|
|
3149
|
+
}
|
|
3150
|
+
};
|
|
3151
|
+
// sha256Hex re-export so the verifier doesn't need to import it
|
|
3152
|
+
// separately (some bundlers tree-shake more aggressively when the
|
|
3153
|
+
// exports are colocated). Inert for the storage adapter itself.
|
|
3154
|
+
MemoryStorage2._ = sha256Hex;
|
|
3155
|
+
|
|
2033
3156
|
// src/stack-parser.ts
|
|
2034
3157
|
function parseStack(stack) {
|
|
2035
3158
|
if (!stack || typeof stack !== "string") return [];
|
|
@@ -2720,6 +3843,22 @@ function isSelfRequest(requestUrl, selfHostname) {
|
|
|
2720
3843
|
var CrossdeckClient = class {
|
|
2721
3844
|
constructor() {
|
|
2722
3845
|
this.state = null;
|
|
3846
|
+
// ────────────────────────────────────────────────────────────────
|
|
3847
|
+
// Contract verifier layer (see sdks/web/src/_contract-verifiers.ts).
|
|
3848
|
+
//
|
|
3849
|
+
// Three flags govern this layer (CrossdeckOptions):
|
|
3850
|
+
// verifyContractsAtBoot — boot self-test on Crossdeck.start
|
|
3851
|
+
// logVerifierResults — print PASS results to console
|
|
3852
|
+
// disableContractAssertions — total kill-switch
|
|
3853
|
+
//
|
|
3854
|
+
// When the kill-switch is set, all three fields stay null and
|
|
3855
|
+
// every hot-path dispatcher short-circuits — zero runtime cost.
|
|
3856
|
+
// Otherwise: populated once at init(), referenced from every
|
|
3857
|
+
// hot-path call site (identify, syncPurchases, ...).
|
|
3858
|
+
// ────────────────────────────────────────────────────────────────
|
|
3859
|
+
this.verifiers = null;
|
|
3860
|
+
this.verifierReporter = null;
|
|
3861
|
+
this.verifierCtx = null;
|
|
2723
3862
|
}
|
|
2724
3863
|
/**
|
|
2725
3864
|
* Boot the SDK. Idempotent — calling init twice with the same options
|
|
@@ -2748,6 +3887,10 @@ var CrossdeckClient = class {
|
|
|
2748
3887
|
this.state.errors?.uninstall();
|
|
2749
3888
|
} catch {
|
|
2750
3889
|
}
|
|
3890
|
+
try {
|
|
3891
|
+
void this.state.events.flush({ keepalive: true });
|
|
3892
|
+
} catch {
|
|
3893
|
+
}
|
|
2751
3894
|
}
|
|
2752
3895
|
if (!options.publicKey || !options.publicKey.startsWith("cd_pub_")) {
|
|
2753
3896
|
throw new CrossdeckError({
|
|
@@ -2795,7 +3938,12 @@ var CrossdeckClient = class {
|
|
|
2795
3938
|
// load still flushes if the user leaves quickly (the keepalive
|
|
2796
3939
|
// pagehide handler picks up anything that doesn't); long enough
|
|
2797
3940
|
// that bursts of clicks coalesce into one network round-trip.
|
|
2798
|
-
|
|
3941
|
+
// v1.4.0 Phase 3.3 — flush interval default parity. Pre-
|
|
3942
|
+
// v1.4.0: Web/Node 1500ms, RN/Swift/Android 5000ms. All
|
|
3943
|
+
// converged on 2000ms (the Stripe-adjacent industry norm)
|
|
3944
|
+
// so cross-platform funnels show events landing at the
|
|
3945
|
+
// same cadence on every SDK. Per-instance override stays.
|
|
3946
|
+
eventFlushIntervalMs: options.eventFlushIntervalMs ?? 2e3,
|
|
2799
3947
|
sdkVersion: options.sdkVersion ?? SDK_VERSION,
|
|
2800
3948
|
autoTrack,
|
|
2801
3949
|
appVersion: options.appVersion ?? null
|
|
@@ -2810,7 +3958,22 @@ var CrossdeckClient = class {
|
|
|
2810
3958
|
// to a successful no-op response when localDevMode is set.
|
|
2811
3959
|
// SDK methods continue to work locally; nothing reaches the
|
|
2812
3960
|
// server.
|
|
2813
|
-
localDevMode
|
|
3961
|
+
localDevMode,
|
|
3962
|
+
// Contract verifier hot-path hook — fires the `error-envelope-shape`
|
|
3963
|
+
// verifier on every parsed wire error, BEFORE the throw bubbles
|
|
3964
|
+
// back to user code. Centralised here so we don't need a try/catch
|
|
3965
|
+
// around every `await s.http.request(...)` call site downstream.
|
|
3966
|
+
// No-op when the verifier layer is disabled (verifiers === null).
|
|
3967
|
+
onErrorParsed: (err) => {
|
|
3968
|
+
if (this.verifiers && this.verifierReporter && this.verifierCtx) {
|
|
3969
|
+
runOnErrorParse(this.verifiers, this.verifierReporter, this.verifierCtx, {
|
|
3970
|
+
errorType: err.type,
|
|
3971
|
+
errorCode: err.code,
|
|
3972
|
+
requestId: err.requestId ?? null,
|
|
3973
|
+
httpStatus: typeof err.status === "number" ? err.status : 0
|
|
3974
|
+
});
|
|
3975
|
+
}
|
|
3976
|
+
}
|
|
2814
3977
|
});
|
|
2815
3978
|
if (localDevMode) {
|
|
2816
3979
|
console.log(
|
|
@@ -2955,6 +4118,89 @@ var CrossdeckClient = class {
|
|
|
2955
4118
|
if (opts.autoHeartbeat && !localDevMode) {
|
|
2956
4119
|
void this.heartbeat().catch(() => void 0);
|
|
2957
4120
|
}
|
|
4121
|
+
if (options.disableContractAssertions !== true) {
|
|
4122
|
+
const debugDefault = defaultDebugModeFlag();
|
|
4123
|
+
this.verifierCtx = buildVerifierContext({
|
|
4124
|
+
logVerifierResults: options.logVerifierResults ?? debugDefault,
|
|
4125
|
+
disableContractAssertions: false,
|
|
4126
|
+
// Best-effort run-context detection. Customers running the
|
|
4127
|
+
// Web SDK inside their own CI (Playwright, Cypress, etc.)
|
|
4128
|
+
// typically set CI=true; we use that as the signal. Otherwise
|
|
4129
|
+
// we assume `customer-app` — the SDK is running inside a
|
|
4130
|
+
// customer's deployed site.
|
|
4131
|
+
runContext: typeof process !== "undefined" && process.env && process.env.CI ? "ci" : "customer-app"
|
|
4132
|
+
});
|
|
4133
|
+
this.verifierReporter = new VerifierReporter(this.verifierCtx);
|
|
4134
|
+
const flushVerifier = buildFlushIntervalVerifier(opts.eventFlushIntervalMs);
|
|
4135
|
+
this.verifiers = Object.freeze([...STATIC_VERIFIERS, flushVerifier]);
|
|
4136
|
+
if (localDevMode) {
|
|
4137
|
+
const verifyAtBootLocal = options.verifyContractsAtBoot ?? debugDefault;
|
|
4138
|
+
if (verifyAtBootLocal && this.verifierReporter) {
|
|
4139
|
+
void runBootSelfTest(
|
|
4140
|
+
this.verifiers,
|
|
4141
|
+
this.verifierReporter,
|
|
4142
|
+
this.verifierCtx
|
|
4143
|
+
).catch(() => void 0);
|
|
4144
|
+
}
|
|
4145
|
+
} else {
|
|
4146
|
+
void this.bootstrapVerifierLayerRemote(options, debugDefault).catch(
|
|
4147
|
+
() => void 0
|
|
4148
|
+
);
|
|
4149
|
+
}
|
|
4150
|
+
}
|
|
4151
|
+
return;
|
|
4152
|
+
}
|
|
4153
|
+
/**
|
|
4154
|
+
* Fetch /v1/config from the backend and apply any per-app verifier
|
|
4155
|
+
* overrides set in the dashboard, then fire the boot self-test
|
|
4156
|
+
* once with the FINAL resolved flags.
|
|
4157
|
+
*
|
|
4158
|
+
* Precedence (also documented at the call site in init()):
|
|
4159
|
+
* code option > dashboard remote config > DEBUG/RELEASE default
|
|
4160
|
+
*
|
|
4161
|
+
* Code wins so engineers retain ultimate control; dashboard is the
|
|
4162
|
+
* no-deploy operational lever for QA / staging soaks.
|
|
4163
|
+
*
|
|
4164
|
+
* Never throws — a /v1/config failure surfaces as "stick with the
|
|
4165
|
+
* synchronous defaults that init() already applied" and the boot
|
|
4166
|
+
* self-test runs only if code > default resolves true.
|
|
4167
|
+
*/
|
|
4168
|
+
async bootstrapVerifierLayerRemote(options, debugDefault) {
|
|
4169
|
+
if (!this.state || !this.verifiers || !this.verifierCtx) return;
|
|
4170
|
+
let remote = {
|
|
4171
|
+
logVerifierResults: null,
|
|
4172
|
+
verifyContractsAtBoot: null
|
|
4173
|
+
};
|
|
4174
|
+
try {
|
|
4175
|
+
const resp = await this.state.http.request(
|
|
4176
|
+
"GET",
|
|
4177
|
+
"/config"
|
|
4178
|
+
);
|
|
4179
|
+
if (resp && resp.verifierConfig) {
|
|
4180
|
+
const r = resp.verifierConfig;
|
|
4181
|
+
remote = {
|
|
4182
|
+
logVerifierResults: typeof r.logVerifierResults === "boolean" ? r.logVerifierResults : null,
|
|
4183
|
+
verifyContractsAtBoot: typeof r.verifyContractsAtBoot === "boolean" ? r.verifyContractsAtBoot : null
|
|
4184
|
+
};
|
|
4185
|
+
}
|
|
4186
|
+
} catch {
|
|
4187
|
+
}
|
|
4188
|
+
const logResults = options.logVerifierResults ?? (remote.logVerifierResults ?? debugDefault);
|
|
4189
|
+
const verifyAtBoot = options.verifyContractsAtBoot ?? (remote.verifyContractsAtBoot ?? debugDefault);
|
|
4190
|
+
if (logResults !== this.verifierCtx.logVerifierResults) {
|
|
4191
|
+
this.verifierCtx = {
|
|
4192
|
+
...this.verifierCtx,
|
|
4193
|
+
logVerifierResults: logResults
|
|
4194
|
+
};
|
|
4195
|
+
this.verifierReporter = new VerifierReporter(this.verifierCtx);
|
|
4196
|
+
}
|
|
4197
|
+
if (verifyAtBoot && this.verifierReporter) {
|
|
4198
|
+
await runBootSelfTest(
|
|
4199
|
+
this.verifiers,
|
|
4200
|
+
this.verifierReporter,
|
|
4201
|
+
this.verifierCtx
|
|
4202
|
+
);
|
|
4203
|
+
}
|
|
2958
4204
|
}
|
|
2959
4205
|
/**
|
|
2960
4206
|
* @deprecated Use `init()` instead. NorthStar §4 standardised the
|
|
@@ -3023,14 +4269,18 @@ var CrossdeckClient = class {
|
|
|
3023
4269
|
};
|
|
3024
4270
|
if (options?.email) body.email = options.email;
|
|
3025
4271
|
if (traits) body.traits = traits;
|
|
4272
|
+
const priorUserId = s.developerUserId;
|
|
4273
|
+
s.entitlements.setUserKey(userId);
|
|
4274
|
+
if (this.verifiers && this.verifierReporter && this.verifierCtx) {
|
|
4275
|
+
runOnIdentify(this.verifiers, this.verifierReporter, this.verifierCtx, {
|
|
4276
|
+
priorUserId,
|
|
4277
|
+
nextUserId: userId,
|
|
4278
|
+
cache: s.entitlements
|
|
4279
|
+
});
|
|
4280
|
+
}
|
|
3026
4281
|
const result = await s.http.request("POST", "/identity/alias", {
|
|
3027
4282
|
body
|
|
3028
4283
|
});
|
|
3029
|
-
const priorCdcust = s.identity.crossdeckCustomerId;
|
|
3030
|
-
const cacheHasEntries = s.entitlements.list().length > 0;
|
|
3031
|
-
if (priorCdcust && result.crossdeckCustomerId && priorCdcust !== result.crossdeckCustomerId || !priorCdcust && cacheHasEntries) {
|
|
3032
|
-
s.entitlements.clear();
|
|
3033
|
-
}
|
|
3034
4284
|
s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
|
|
3035
4285
|
s.developerUserId = userId;
|
|
3036
4286
|
return result;
|
|
@@ -3335,6 +4585,38 @@ var CrossdeckClient = class {
|
|
|
3335
4585
|
* trip happens in the background. To flush before the page unloads,
|
|
3336
4586
|
* call flush().
|
|
3337
4587
|
*/
|
|
4588
|
+
/**
|
|
4589
|
+
* Emit `crossdeck.contract_failed` to the Crossdeck reliability
|
|
4590
|
+
* endpoint — single-fire, one-way, never visible in the customer's
|
|
4591
|
+
* dashboard. Goes over a dedicated HTTP path with the reliability
|
|
4592
|
+
* publishable key embedded at build time; the customer's track()
|
|
4593
|
+
* pipeline never carries `crossdeck.*` events. This is the
|
|
4594
|
+
* independent-controller flow described in Privacy Policy §6
|
|
4595
|
+
* ("Flow B"). The wire shape is fixed by the schema-lock contract
|
|
4596
|
+
* at `contracts/diagnostics/contract-failed-payload-schema-lock.json`.
|
|
4597
|
+
*
|
|
4598
|
+
* Wire the call from a test hook, dogfood failure path, or
|
|
4599
|
+
* customer contract-verification harness; see
|
|
4600
|
+
* `contracts/README.md` for the per-test-framework hook recipes.
|
|
4601
|
+
*/
|
|
4602
|
+
reportContractFailure(input) {
|
|
4603
|
+
const payload = {
|
|
4604
|
+
contract_id: input.contractId,
|
|
4605
|
+
sdk_version: SDK_VERSION,
|
|
4606
|
+
sdk_platform: "web",
|
|
4607
|
+
failure_reason: input.failureReason,
|
|
4608
|
+
run_context: input.runContext,
|
|
4609
|
+
run_id: input.runId
|
|
4610
|
+
};
|
|
4611
|
+
if (input.testRef) {
|
|
4612
|
+
payload.test_file = input.testRef.file;
|
|
4613
|
+
payload.test_name = input.testRef.name;
|
|
4614
|
+
}
|
|
4615
|
+
if (input.deviceClass) {
|
|
4616
|
+
payload.device_class = input.deviceClass;
|
|
4617
|
+
}
|
|
4618
|
+
sendDiagnosticTelemetry(payload);
|
|
4619
|
+
}
|
|
3338
4620
|
track(name, properties) {
|
|
3339
4621
|
const s = this.requireStarted();
|
|
3340
4622
|
if (!name) {
|
|
@@ -3422,6 +4704,14 @@ var CrossdeckClient = class {
|
|
|
3422
4704
|
};
|
|
3423
4705
|
Object.assign(event, this.identityHintForEvent());
|
|
3424
4706
|
s.events.enqueue(event);
|
|
4707
|
+
if (this.verifiers && this.verifierReporter && this.verifierCtx) {
|
|
4708
|
+
runOnTrack(this.verifiers, this.verifierReporter, this.verifierCtx, {
|
|
4709
|
+
callerProperties: validation.properties,
|
|
4710
|
+
superProperties: supers,
|
|
4711
|
+
deviceProperties: s.deviceInfo,
|
|
4712
|
+
mergedProperties: finalProperties
|
|
4713
|
+
});
|
|
4714
|
+
}
|
|
3425
4715
|
if (!isError && !isWebVital) {
|
|
3426
4716
|
const category = name.startsWith("page.") ? "navigation" : name.startsWith("element.") || name === "session.started" ? "ui.click" : "custom";
|
|
3427
4717
|
s.breadcrumbs.add({
|
|
@@ -3471,11 +4761,37 @@ var CrossdeckClient = class {
|
|
|
3471
4761
|
});
|
|
3472
4762
|
}
|
|
3473
4763
|
const rail = input.rail ?? "apple";
|
|
4764
|
+
const body = { ...input, rail };
|
|
4765
|
+
const idempotencyKey = deriveIdempotencyKeyForPurchase(body);
|
|
4766
|
+
if (this.verifiers && this.verifierReporter && this.verifierCtx) {
|
|
4767
|
+
const stableId = rail === "apple" ? body.signedTransactionInfo ?? "" : body.purchaseToken ?? "";
|
|
4768
|
+
runOnSyncPurchases(
|
|
4769
|
+
this.verifiers,
|
|
4770
|
+
this.verifierReporter,
|
|
4771
|
+
this.verifierCtx,
|
|
4772
|
+
{
|
|
4773
|
+
rail,
|
|
4774
|
+
stableIdentifier: stableId,
|
|
4775
|
+
derivedKey: idempotencyKey
|
|
4776
|
+
}
|
|
4777
|
+
);
|
|
4778
|
+
}
|
|
3474
4779
|
const result = await s.http.request("POST", "/purchases/sync", {
|
|
3475
|
-
body
|
|
4780
|
+
body,
|
|
4781
|
+
idempotencyKey
|
|
3476
4782
|
});
|
|
3477
4783
|
s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
|
|
3478
4784
|
s.entitlements.setFromList(result.entitlements);
|
|
4785
|
+
try {
|
|
4786
|
+
const sourceProductId = result.entitlements[0]?.source.productId;
|
|
4787
|
+
const sourceSubscriptionId = result.entitlements[0]?.source.subscriptionId;
|
|
4788
|
+
const props = { rail };
|
|
4789
|
+
if (sourceProductId) props.productId = sourceProductId;
|
|
4790
|
+
if (sourceSubscriptionId) props.subscriptionId = sourceSubscriptionId;
|
|
4791
|
+
if (result.idempotent_replay) props.idempotent_replay = true;
|
|
4792
|
+
this.track("purchase.completed", props);
|
|
4793
|
+
} catch {
|
|
4794
|
+
}
|
|
3479
4795
|
s.debug.emit(
|
|
3480
4796
|
"sdk.purchase_evidence_sent",
|
|
3481
4797
|
"StoreKit transaction forwarded. Waiting for backend verification.",
|
|
@@ -3531,7 +4847,7 @@ var CrossdeckClient = class {
|
|
|
3531
4847
|
}
|
|
3532
4848
|
this.state.autoTracker?.uninstall();
|
|
3533
4849
|
this.state.identity.reset();
|
|
3534
|
-
this.state.entitlements.
|
|
4850
|
+
this.state.entitlements.clearAll();
|
|
3535
4851
|
this.state.events.reset();
|
|
3536
4852
|
this.state.superProps.clear();
|
|
3537
4853
|
this.state.breadcrumbs.clear();
|
|
@@ -3806,15 +5122,672 @@ var CROSSDECK_ERROR_CODES = Object.freeze([
|
|
|
3806
5122
|
description: "The server returned a 2xx with an unparseable body.",
|
|
3807
5123
|
resolution: "Likely a transient backend bug. Retry; if it persists, contact support with the requestId.",
|
|
3808
5124
|
retryable: true
|
|
5125
|
+
},
|
|
5126
|
+
// ----- Backend-emitted codes (v1.4.0 Phase 6.2 backfill) -----
|
|
5127
|
+
// Mirror of backend/src/api/v1-errors.ts ApiErrorCode. A developer
|
|
5128
|
+
// hitting any of these on the wire can look them up via
|
|
5129
|
+
// getErrorCode(code) for a canonical remediation step instead of
|
|
5130
|
+
// hunting through Slack history.
|
|
5131
|
+
{
|
|
5132
|
+
code: "missing_api_key",
|
|
5133
|
+
type: "authentication_error",
|
|
5134
|
+
description: "No Authorization header (or Crossdeck-Api-Key header) on the request.",
|
|
5135
|
+
resolution: "Make sure Crossdeck.init({ publicKey }) was called with a cd_pub_\u2026 key before the request fired. Re-check your env-vars in CI / Docker if the key is empty at runtime.",
|
|
5136
|
+
retryable: false
|
|
5137
|
+
},
|
|
5138
|
+
{
|
|
5139
|
+
code: "invalid_api_key",
|
|
5140
|
+
type: "authentication_error",
|
|
5141
|
+
description: "The API key is malformed, unknown, or doesn't resolve to a project.",
|
|
5142
|
+
resolution: "Copy the key from your Crossdeck dashboard \u2192 API keys. Confirm prefix is cd_pub_test_ / cd_pub_live_ for client SDKs and cd_sk_test_ / cd_sk_live_ for the Node server SDK.",
|
|
5143
|
+
retryable: false
|
|
5144
|
+
},
|
|
5145
|
+
{
|
|
5146
|
+
code: "key_revoked",
|
|
5147
|
+
type: "authentication_error",
|
|
5148
|
+
description: "The API key was revoked in the Crossdeck dashboard.",
|
|
5149
|
+
resolution: "Mint a fresh key in the dashboard \u2192 API keys \u2192 Create new, swap it in, and redeploy. The revoked key cannot be reactivated.",
|
|
5150
|
+
retryable: false
|
|
5151
|
+
},
|
|
5152
|
+
{
|
|
5153
|
+
code: "identity_token_invalid",
|
|
5154
|
+
type: "authentication_error",
|
|
5155
|
+
description: "The Firebase / Apple / Google ID token supplied with the request didn't verify against the dashboard's configured signers.",
|
|
5156
|
+
resolution: "Refresh the token client-side (Firebase auth.currentUser.getIdToken(true)) and retry. If the failure persists, confirm the signer is registered under dashboard \u2192 Authentication \u2192 Identity sources.",
|
|
5157
|
+
retryable: true
|
|
5158
|
+
},
|
|
5159
|
+
{
|
|
5160
|
+
code: "origin_not_allowed",
|
|
5161
|
+
type: "permission_error",
|
|
5162
|
+
description: "The Origin header isn't in the project's Allowed origins list.",
|
|
5163
|
+
resolution: "Add the origin (e.g. https://app.example.com) under dashboard \u2192 Settings \u2192 Allowed origins. Wildcards like https://*.example.com are supported.",
|
|
5164
|
+
retryable: false
|
|
5165
|
+
},
|
|
5166
|
+
{
|
|
5167
|
+
code: "bundle_id_not_allowed",
|
|
5168
|
+
type: "permission_error",
|
|
5169
|
+
description: "The iOS bundle ID sent via X-Crossdeck-Bundle-Id isn't registered under this app's Apple identity lock.",
|
|
5170
|
+
resolution: "Add the bundle ID under dashboard \u2192 Apps \u2192 <your app> \u2192 iOS bundle IDs.",
|
|
5171
|
+
retryable: false
|
|
5172
|
+
},
|
|
5173
|
+
{
|
|
5174
|
+
code: "package_name_not_allowed",
|
|
5175
|
+
type: "permission_error",
|
|
5176
|
+
description: "The Android package name sent via X-Crossdeck-Package-Name isn't registered under this app's Android identity lock.",
|
|
5177
|
+
resolution: "Add the package name under dashboard \u2192 Apps \u2192 <your app> \u2192 Android package names.",
|
|
5178
|
+
retryable: false
|
|
5179
|
+
},
|
|
5180
|
+
{
|
|
5181
|
+
code: "env_mismatch",
|
|
5182
|
+
type: "permission_error",
|
|
5183
|
+
description: "The request env (inferred from key prefix) doesn't match the resolved app's configured env.",
|
|
5184
|
+
resolution: "Use a cd_pub_live_ / cd_sk_live_ key with a production app, cd_pub_test_ / cd_sk_test_ with a sandbox app. The two cannot cross.",
|
|
5185
|
+
retryable: false
|
|
5186
|
+
},
|
|
5187
|
+
{
|
|
5188
|
+
code: "idempotency_key_in_use",
|
|
5189
|
+
type: "invalid_request_error",
|
|
5190
|
+
description: "An Idempotency-Key was reused for a request with a different body (Stripe-grade contract).",
|
|
5191
|
+
resolution: "Generate a fresh key for a different transaction, or reuse the key only with the EXACT same body. The v1.4.0 SDKs derive keys deterministically from the body so this should never fire on SDK-managed calls.",
|
|
5192
|
+
retryable: false
|
|
5193
|
+
},
|
|
5194
|
+
{
|
|
5195
|
+
code: "rate_limited",
|
|
5196
|
+
type: "rate_limit_error",
|
|
5197
|
+
description: "Request rate exceeded the project's per-second cap.",
|
|
5198
|
+
resolution: "Honour the Retry-After header \u2014 the SDK does this automatically on managed retries. If you're hitting it from a custom code path, throttle to <100 req/s/key.",
|
|
5199
|
+
retryable: true
|
|
5200
|
+
},
|
|
5201
|
+
{
|
|
5202
|
+
code: "internal_error",
|
|
5203
|
+
type: "internal_error",
|
|
5204
|
+
description: "Server-side issue. Safe to retry with backoff.",
|
|
5205
|
+
resolution: "The SDK retries automatically. If your code paths through to this error, contact support with the requestId from the response envelope.",
|
|
5206
|
+
retryable: true
|
|
5207
|
+
},
|
|
5208
|
+
{
|
|
5209
|
+
code: "google_not_supported",
|
|
5210
|
+
type: "invalid_request_error",
|
|
5211
|
+
description: "POST /purchases/sync with rail=google is gated until the Play Developer API reconciliation worker ships.",
|
|
5212
|
+
resolution: "Until v1.5+, Google Play purchases verify via Real-time Developer Notifications. The SDK auto-track path handles this transparently for Android consumers.",
|
|
5213
|
+
retryable: false
|
|
5214
|
+
},
|
|
5215
|
+
{
|
|
5216
|
+
code: "stripe_not_supported",
|
|
5217
|
+
type: "invalid_request_error",
|
|
5218
|
+
description: "POST /purchases/sync with rail=stripe is unsupported \u2014 Stripe Checkout's redirect flow uses platform webhooks instead.",
|
|
5219
|
+
resolution: "Wire Stripe via the standard Checkout / Customer Portal flow; Crossdeck reconciles via the platform webhook automatically. No SDK call needed.",
|
|
5220
|
+
retryable: false
|
|
5221
|
+
},
|
|
5222
|
+
{
|
|
5223
|
+
code: "missing_required_param",
|
|
5224
|
+
type: "invalid_request_error",
|
|
5225
|
+
description: "A required field is absent from the request body.",
|
|
5226
|
+
resolution: "Inspect the error.message \u2014 the missing field name is included verbatim. Refer to the SDK's TypeScript types for the canonical request shape.",
|
|
5227
|
+
retryable: false
|
|
5228
|
+
},
|
|
5229
|
+
{
|
|
5230
|
+
code: "invalid_param_value",
|
|
5231
|
+
type: "invalid_request_error",
|
|
5232
|
+
description: "A field is present but the value failed validation (wrong shape, wrong length, wrong enum value).",
|
|
5233
|
+
resolution: "Read error.message for the specific field + reason. SDK-managed call sites should never emit this \u2014 file a bug if you do.",
|
|
5234
|
+
retryable: false
|
|
3809
5235
|
}
|
|
3810
5236
|
]);
|
|
3811
5237
|
function getErrorCode(code) {
|
|
3812
5238
|
return CROSSDECK_ERROR_CODES.find((e) => e.code === code);
|
|
3813
5239
|
}
|
|
5240
|
+
|
|
5241
|
+
// src/_contracts-bundled.ts
|
|
5242
|
+
var BUNDLED_IN = "@cross-deck/web@1.5.3";
|
|
5243
|
+
var SDK_VERSION2 = "1.5.3";
|
|
5244
|
+
var BUNDLED_CONTRACTS = Object.freeze([
|
|
5245
|
+
{
|
|
5246
|
+
"id": "contract-failed-payload-schema-lock",
|
|
5247
|
+
"pillar": "diagnostics",
|
|
5248
|
+
"status": "enforced",
|
|
5249
|
+
"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.",
|
|
5250
|
+
"appliesTo": [
|
|
5251
|
+
"web",
|
|
5252
|
+
"node",
|
|
5253
|
+
"swift",
|
|
5254
|
+
"android",
|
|
5255
|
+
"react-native"
|
|
5256
|
+
],
|
|
5257
|
+
"allowedFields": {
|
|
5258
|
+
"required": [
|
|
5259
|
+
"contract_id",
|
|
5260
|
+
"sdk_version",
|
|
5261
|
+
"sdk_platform",
|
|
5262
|
+
"failure_reason",
|
|
5263
|
+
"run_context",
|
|
5264
|
+
"run_id"
|
|
5265
|
+
],
|
|
5266
|
+
"optional": [
|
|
5267
|
+
"test_file",
|
|
5268
|
+
"test_name",
|
|
5269
|
+
"device_class",
|
|
5270
|
+
"verification_phase"
|
|
5271
|
+
],
|
|
5272
|
+
"forbidden": [
|
|
5273
|
+
"anonymousId",
|
|
5274
|
+
"developerUserId",
|
|
5275
|
+
"crossdeckCustomerId",
|
|
5276
|
+
"email",
|
|
5277
|
+
"ip",
|
|
5278
|
+
"user_agent",
|
|
5279
|
+
"message",
|
|
5280
|
+
"stack",
|
|
5281
|
+
"stack_trace",
|
|
5282
|
+
"frames",
|
|
5283
|
+
"exception_message",
|
|
5284
|
+
"url",
|
|
5285
|
+
"path",
|
|
5286
|
+
"screen",
|
|
5287
|
+
"title",
|
|
5288
|
+
"label",
|
|
5289
|
+
"text",
|
|
5290
|
+
"ariaLabel",
|
|
5291
|
+
"accessibilityLabel",
|
|
5292
|
+
"contentDescription",
|
|
5293
|
+
"session_id",
|
|
5294
|
+
"sessionId"
|
|
5295
|
+
]
|
|
5296
|
+
},
|
|
5297
|
+
"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.",
|
|
5298
|
+
"codeRef": [
|
|
5299
|
+
"sdks/web/src/crossdeck.ts",
|
|
5300
|
+
"sdks/node/src/crossdeck-server.ts",
|
|
5301
|
+
"sdks/swift/Sources/Crossdeck/Crossdeck.swift",
|
|
5302
|
+
"sdks/swift/Sources/Crossdeck/_DiagnosticTelemetry.swift",
|
|
5303
|
+
"sdks/android/crossdeck/src/main/kotlin/com/crossdeck/Crossdeck.kt",
|
|
5304
|
+
"sdks/android/crossdeck/src/main/kotlin/com/crossdeck/_DiagnosticTelemetry.kt",
|
|
5305
|
+
"sdks/react-native/src/crossdeck.ts",
|
|
5306
|
+
"backend/src/api/v1-sdk-diagnostic.ts",
|
|
5307
|
+
"sdks/web/src/_diagnostic-telemetry.ts",
|
|
5308
|
+
"sdks/node/src/_diagnostic-telemetry.ts",
|
|
5309
|
+
"sdks/react-native/src/_diagnostic-telemetry.ts"
|
|
5310
|
+
],
|
|
5311
|
+
"testRef": [
|
|
5312
|
+
{
|
|
5313
|
+
"file": "sdks/web/tests/contract-failed-schema-lock.test.ts",
|
|
5314
|
+
"name": "reportContractFailure payload conforms to schema-lock"
|
|
5315
|
+
},
|
|
5316
|
+
{
|
|
5317
|
+
"file": "sdks/node/tests/contract-failed-schema-lock.test.ts",
|
|
5318
|
+
"name": "reportContractFailure payload conforms to schema-lock"
|
|
5319
|
+
},
|
|
5320
|
+
{
|
|
5321
|
+
"file": "sdks/swift/Tests/CrossdeckTests/ContractFailedSchemaLockTests.swift",
|
|
5322
|
+
"name": "test_reportContractFailure_payloadFieldsAreInAllowList"
|
|
5323
|
+
},
|
|
5324
|
+
{
|
|
5325
|
+
"file": "sdks/swift/Tests/CrossdeckTests/ContractFailedSchemaLockTests.swift",
|
|
5326
|
+
"name": "test_reportContractFailure_doesNotEnterCustomerTrackPipeline"
|
|
5327
|
+
},
|
|
5328
|
+
{
|
|
5329
|
+
"file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/ContractFailedSchemaLockTest.kt",
|
|
5330
|
+
"name": "reportContractFailure payload conforms to schema-lock"
|
|
5331
|
+
},
|
|
5332
|
+
{
|
|
5333
|
+
"file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/ContractFailedSchemaLockTest.kt",
|
|
5334
|
+
"name": "reportContractFailure does not enter customer track pipeline"
|
|
5335
|
+
},
|
|
5336
|
+
{
|
|
5337
|
+
"file": "sdks/react-native/tests/contract-failed-schema-lock.test.ts",
|
|
5338
|
+
"name": "reportContractFailure payload conforms to schema-lock"
|
|
5339
|
+
},
|
|
5340
|
+
{
|
|
5341
|
+
"file": "backend/tests/unit/v1-sdk-diagnostic.test.ts",
|
|
5342
|
+
"name": "forbidden fields are enumerated in the schema-lock contract"
|
|
5343
|
+
},
|
|
5344
|
+
{
|
|
5345
|
+
"file": "backend/tests/unit/v1-sdk-diagnostic.test.ts",
|
|
5346
|
+
"name": "required fields are enumerated in the schema-lock contract"
|
|
5347
|
+
},
|
|
5348
|
+
{
|
|
5349
|
+
"file": "backend/tests/unit/v1-sdk-diagnostic.test.ts",
|
|
5350
|
+
"name": "regression guard: never returns a raw IP"
|
|
5351
|
+
},
|
|
5352
|
+
{
|
|
5353
|
+
"file": "backend/tests/unit/v1-sdk-diagnostic.test.ts",
|
|
5354
|
+
"name": "verification_phase is in the optional field set"
|
|
5355
|
+
}
|
|
5356
|
+
],
|
|
5357
|
+
"registeredAt": "2026-05-27",
|
|
5358
|
+
"firstRegisteredIn": "Diagnostic telemetry single-fire + schema-lock \u2014 independent-controller flow",
|
|
5359
|
+
"privacyReferences": [
|
|
5360
|
+
"legal/privacy/index.html#sdk-diagnostic",
|
|
5361
|
+
"legal/customer-disclosure/index.html#flow-b",
|
|
5362
|
+
"legal/security/index.html#diagnostic",
|
|
5363
|
+
"legal/sdk-data/index.html#b-diagnostic"
|
|
5364
|
+
],
|
|
5365
|
+
"bundledIn": "@cross-deck/web@1.5.3"
|
|
5366
|
+
},
|
|
5367
|
+
{
|
|
5368
|
+
"id": "error-envelope-shape",
|
|
5369
|
+
"pillar": "errors",
|
|
5370
|
+
"status": "enforced",
|
|
5371
|
+
"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.",
|
|
5372
|
+
"appliesTo": [
|
|
5373
|
+
"web",
|
|
5374
|
+
"node",
|
|
5375
|
+
"react-native",
|
|
5376
|
+
"swift",
|
|
5377
|
+
"android",
|
|
5378
|
+
"backend"
|
|
5379
|
+
],
|
|
5380
|
+
"codeRef": [
|
|
5381
|
+
"backend/src/api/v1-errors.ts",
|
|
5382
|
+
"sdks/web/src/errors.ts",
|
|
5383
|
+
"sdks/node/src/errors.ts",
|
|
5384
|
+
"sdks/react-native/src/errors.ts",
|
|
5385
|
+
"sdks/swift/Sources/Crossdeck/Errors.swift",
|
|
5386
|
+
"sdks/android/crossdeck/src/main/kotlin/com/crossdeck/Errors.kt"
|
|
5387
|
+
],
|
|
5388
|
+
"testRef": [
|
|
5389
|
+
{
|
|
5390
|
+
"file": "sdks/swift/Tests/CrossdeckTests/ErrorsTests.swift",
|
|
5391
|
+
"name": "test_errorEnvelope_fallsBackOnGarbageBody"
|
|
5392
|
+
},
|
|
5393
|
+
{
|
|
5394
|
+
"file": "sdks/swift/Tests/CrossdeckTests/ErrorsTests.swift",
|
|
5395
|
+
"name": "test_errorEnvelope_reads_XRequestId_fallback"
|
|
5396
|
+
},
|
|
5397
|
+
{
|
|
5398
|
+
"file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/ErrorTypeWireVocabTest.kt",
|
|
5399
|
+
"name": "backend 500 response parses to INTERNAL_ERROR"
|
|
5400
|
+
}
|
|
5401
|
+
],
|
|
5402
|
+
"registeredAt": "2026-05-26",
|
|
5403
|
+
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 8 (codifies existing contract)",
|
|
5404
|
+
"bundledIn": "@cross-deck/web@1.5.3"
|
|
5405
|
+
},
|
|
5406
|
+
{
|
|
5407
|
+
"id": "flush-interval-parity",
|
|
5408
|
+
"pillar": "analytics",
|
|
5409
|
+
"status": "enforced",
|
|
5410
|
+
"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.",
|
|
5411
|
+
"appliesTo": [
|
|
5412
|
+
"web",
|
|
5413
|
+
"node",
|
|
5414
|
+
"react-native",
|
|
5415
|
+
"swift",
|
|
5416
|
+
"android"
|
|
5417
|
+
],
|
|
5418
|
+
"codeRef": [
|
|
5419
|
+
"sdks/web/src/crossdeck.ts",
|
|
5420
|
+
"sdks/node/src/crossdeck-server.ts",
|
|
5421
|
+
"sdks/react-native/src/crossdeck.ts",
|
|
5422
|
+
"sdks/swift/Sources/Crossdeck/EventQueue.swift",
|
|
5423
|
+
"sdks/android/crossdeck/src/main/kotlin/com/crossdeck/EventQueue.kt"
|
|
5424
|
+
],
|
|
5425
|
+
"testRef": [
|
|
5426
|
+
{
|
|
5427
|
+
"file": "sdks/swift/Sources/Crossdeck/EventQueue.swift",
|
|
5428
|
+
"name": "flushIntervalMs: Int = 2_000"
|
|
5429
|
+
},
|
|
5430
|
+
{
|
|
5431
|
+
"file": "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/EventQueue.kt",
|
|
5432
|
+
"name": "flushIntervalMs: Long = 2_000L"
|
|
5433
|
+
},
|
|
5434
|
+
{
|
|
5435
|
+
"file": "sdks/web/src/crossdeck.ts",
|
|
5436
|
+
"name": "options.eventFlushIntervalMs ?? 2000"
|
|
5437
|
+
},
|
|
5438
|
+
{
|
|
5439
|
+
"file": "sdks/node/src/crossdeck-server.ts",
|
|
5440
|
+
"name": "options.eventFlushIntervalMs ?? 2000"
|
|
5441
|
+
},
|
|
5442
|
+
{
|
|
5443
|
+
"file": "sdks/react-native/src/crossdeck.ts",
|
|
5444
|
+
"name": "options.eventFlushIntervalMs ?? 2000"
|
|
5445
|
+
}
|
|
5446
|
+
],
|
|
5447
|
+
"registeredAt": "2026-05-26",
|
|
5448
|
+
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 3.3",
|
|
5449
|
+
"bundledIn": "@cross-deck/web@1.5.3"
|
|
5450
|
+
},
|
|
5451
|
+
{
|
|
5452
|
+
"id": "idempotency-key-deterministic",
|
|
5453
|
+
"pillar": "revenue",
|
|
5454
|
+
"status": "enforced",
|
|
5455
|
+
"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.",
|
|
5456
|
+
"appliesTo": [
|
|
5457
|
+
"web",
|
|
5458
|
+
"node",
|
|
5459
|
+
"react-native",
|
|
5460
|
+
"swift",
|
|
5461
|
+
"android",
|
|
5462
|
+
"backend"
|
|
5463
|
+
],
|
|
5464
|
+
"codeRef": [
|
|
5465
|
+
"sdks/web/src/idempotency-key.ts",
|
|
5466
|
+
"sdks/web/src/crossdeck.ts",
|
|
5467
|
+
"sdks/react-native/src/idempotency-key.ts",
|
|
5468
|
+
"sdks/react-native/src/crossdeck.ts",
|
|
5469
|
+
"sdks/node/src/idempotency-key.ts",
|
|
5470
|
+
"sdks/node/src/crossdeck-server.ts",
|
|
5471
|
+
"sdks/swift/Sources/Crossdeck/IdempotencyKey.swift",
|
|
5472
|
+
"sdks/swift/Sources/Crossdeck/Crossdeck.swift",
|
|
5473
|
+
"sdks/android/crossdeck/src/main/kotlin/com/crossdeck/IdempotencyKey.kt",
|
|
5474
|
+
"sdks/android/crossdeck/src/main/kotlin/com/crossdeck/Crossdeck.kt",
|
|
5475
|
+
"backend/src/lib/idempotency-response-cache.ts",
|
|
5476
|
+
"backend/src/api/v1-purchases.ts"
|
|
5477
|
+
],
|
|
5478
|
+
"testRef": [
|
|
5479
|
+
{
|
|
5480
|
+
"file": "sdks/web/tests/idempotency-key.test.ts",
|
|
5481
|
+
"name": "cross-SDK oracle \u2014 apple JWS pins canonical vector"
|
|
5482
|
+
},
|
|
5483
|
+
{
|
|
5484
|
+
"file": "sdks/web/tests/idempotency-key.test.ts",
|
|
5485
|
+
"name": "is deterministic: same body twice -> identical key"
|
|
5486
|
+
},
|
|
5487
|
+
{
|
|
5488
|
+
"file": "sdks/web/tests/idempotency-key.test.ts",
|
|
5489
|
+
"name": "same identifier under different rails -> different keys"
|
|
5490
|
+
},
|
|
5491
|
+
{
|
|
5492
|
+
"file": "sdks/web/tests/idempotency-key.test.ts",
|
|
5493
|
+
"name": "never silently falls back to a random key on missing identifier"
|
|
5494
|
+
},
|
|
5495
|
+
{
|
|
5496
|
+
"file": "sdks/react-native/tests/idempotency-key.test.ts",
|
|
5497
|
+
"name": "is deterministic"
|
|
5498
|
+
},
|
|
5499
|
+
{
|
|
5500
|
+
"file": "sdks/react-native/tests/idempotency-key.test.ts",
|
|
5501
|
+
"name": "cross-SDK oracle \u2014 apple JWS pins canonical vector"
|
|
5502
|
+
},
|
|
5503
|
+
{
|
|
5504
|
+
"file": "sdks/node/tests/idempotency-key.test.ts",
|
|
5505
|
+
"name": "is deterministic"
|
|
5506
|
+
},
|
|
5507
|
+
{
|
|
5508
|
+
"file": "sdks/node/tests/idempotency-key.test.ts",
|
|
5509
|
+
"name": "rail namespacing prevents cross-rail collisions"
|
|
5510
|
+
},
|
|
5511
|
+
{
|
|
5512
|
+
"file": "sdks/node/tests/idempotency-key.test.ts",
|
|
5513
|
+
"name": "apple JWS produces the canonical pinned UUID across all 5 SDKs"
|
|
5514
|
+
},
|
|
5515
|
+
{
|
|
5516
|
+
"file": "backend/tests/unit/idempotency-response-cache.test.ts",
|
|
5517
|
+
"name": "is deterministic for the same input"
|
|
5518
|
+
},
|
|
5519
|
+
{
|
|
5520
|
+
"file": "backend/tests/unit/idempotency-response-cache.test.ts",
|
|
5521
|
+
"name": "injects idempotent_replay: true into a JSON object body"
|
|
5522
|
+
},
|
|
5523
|
+
{
|
|
5524
|
+
"file": "backend/tests/unit/idempotency-response-cache.test.ts",
|
|
5525
|
+
"name": "matches Stripe's 24-hour idempotency window"
|
|
5526
|
+
},
|
|
5527
|
+
{
|
|
5528
|
+
"file": "sdks/swift/Tests/CrossdeckTests/IdempotencyKeyTests.swift",
|
|
5529
|
+
"name": "test_crossSdkOracle_appleJWS"
|
|
5530
|
+
},
|
|
5531
|
+
{
|
|
5532
|
+
"file": "sdks/swift/Tests/CrossdeckTests/IdempotencyKeyTests.swift",
|
|
5533
|
+
"name": "test_railNamespacing_preventsCrossRailCollisions"
|
|
5534
|
+
},
|
|
5535
|
+
{
|
|
5536
|
+
"file": "sdks/swift/Tests/CrossdeckTests/IdempotencyKeyTests.swift",
|
|
5537
|
+
"name": "test_missingIdentifier_returnsNil"
|
|
5538
|
+
},
|
|
5539
|
+
{
|
|
5540
|
+
"file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/IdempotencyKeyTest.kt",
|
|
5541
|
+
"name": "cross-SDK oracle for apple JWS"
|
|
5542
|
+
},
|
|
5543
|
+
{
|
|
5544
|
+
"file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/IdempotencyKeyTest.kt",
|
|
5545
|
+
"name": "rail namespacing prevents cross-rail collisions"
|
|
5546
|
+
},
|
|
5547
|
+
{
|
|
5548
|
+
"file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/IdempotencyKeyTest.kt",
|
|
5549
|
+
"name": "missing identifier returns null - never silent random fallback"
|
|
5550
|
+
}
|
|
5551
|
+
],
|
|
5552
|
+
"registeredAt": "2026-05-26",
|
|
5553
|
+
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 2.2.a + 2.2.b + 2.2.c",
|
|
5554
|
+
"bundledIn": "@cross-deck/web@1.5.3"
|
|
5555
|
+
},
|
|
5556
|
+
{
|
|
5557
|
+
"id": "init-reentry-drains-prior-queue",
|
|
5558
|
+
"pillar": "lifecycle",
|
|
5559
|
+
"status": "enforced",
|
|
5560
|
+
"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.",
|
|
5561
|
+
"appliesTo": [
|
|
5562
|
+
"web",
|
|
5563
|
+
"react-native"
|
|
5564
|
+
],
|
|
5565
|
+
"codeRef": [
|
|
5566
|
+
"sdks/web/src/crossdeck.ts",
|
|
5567
|
+
"sdks/react-native/src/crossdeck.ts"
|
|
5568
|
+
],
|
|
5569
|
+
"testRef": [
|
|
5570
|
+
{
|
|
5571
|
+
"file": "sdks/web/tests/init-reentry.test.ts",
|
|
5572
|
+
"name": "re-init drains the prior queue's pending timer before swapping state"
|
|
5573
|
+
},
|
|
5574
|
+
{
|
|
5575
|
+
"file": "sdks/web/tests/init-reentry.test.ts",
|
|
5576
|
+
"name": "re-init does NOT wipe the durable event store"
|
|
5577
|
+
}
|
|
5578
|
+
],
|
|
5579
|
+
"registeredAt": "2026-05-26",
|
|
5580
|
+
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 5.5",
|
|
5581
|
+
"bundledIn": "@cross-deck/web@1.5.3"
|
|
5582
|
+
},
|
|
5583
|
+
{
|
|
5584
|
+
"id": "per-user-cache-isolation",
|
|
5585
|
+
"pillar": "entitlements",
|
|
5586
|
+
"status": "enforced",
|
|
5587
|
+
"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.",
|
|
5588
|
+
"appliesTo": [
|
|
5589
|
+
"web",
|
|
5590
|
+
"react-native",
|
|
5591
|
+
"swift",
|
|
5592
|
+
"android"
|
|
5593
|
+
],
|
|
5594
|
+
"codeRef": [
|
|
5595
|
+
"sdks/web/src/entitlement-cache.ts",
|
|
5596
|
+
"sdks/web/src/hash.ts",
|
|
5597
|
+
"sdks/web/src/crossdeck.ts",
|
|
5598
|
+
"sdks/react-native/src/entitlement-cache.ts",
|
|
5599
|
+
"sdks/react-native/src/hash.ts",
|
|
5600
|
+
"sdks/react-native/src/crossdeck.ts",
|
|
5601
|
+
"sdks/swift/Sources/Crossdeck/EntitlementCache.swift",
|
|
5602
|
+
"sdks/swift/Sources/Crossdeck/IdempotencyKey.swift",
|
|
5603
|
+
"sdks/swift/Sources/Crossdeck/Crossdeck.swift",
|
|
5604
|
+
"sdks/android/crossdeck/src/main/kotlin/com/crossdeck/EntitlementCache.kt",
|
|
5605
|
+
"sdks/android/crossdeck/src/main/kotlin/com/crossdeck/IdempotencyKey.kt",
|
|
5606
|
+
"sdks/android/crossdeck/src/main/kotlin/com/crossdeck/Crossdeck.kt"
|
|
5607
|
+
],
|
|
5608
|
+
"testRef": [
|
|
5609
|
+
{
|
|
5610
|
+
"file": "sdks/web/tests/entitlement-cache-isolation.test.ts",
|
|
5611
|
+
"name": "identify(B) makes A's entitlements unreachable from in-memory"
|
|
5612
|
+
},
|
|
5613
|
+
{
|
|
5614
|
+
"file": "sdks/web/tests/entitlement-cache-isolation.test.ts",
|
|
5615
|
+
"name": "clearAll() removes every per-user storage key plus the index"
|
|
5616
|
+
},
|
|
5617
|
+
{
|
|
5618
|
+
"file": "sdks/web/tests/entitlement-cache-isolation.test.ts",
|
|
5619
|
+
"name": "a second cache instance reading A's storage suffix CANNOT see B's data"
|
|
5620
|
+
},
|
|
5621
|
+
{
|
|
5622
|
+
"file": "sdks/react-native/tests/entitlement-cache-isolation.test.ts",
|
|
5623
|
+
"name": "identify(B) makes A's entitlements unreachable from in-memory"
|
|
5624
|
+
},
|
|
5625
|
+
{
|
|
5626
|
+
"file": "sdks/react-native/tests/entitlement-cache-isolation.test.ts",
|
|
5627
|
+
"name": "removes every per-user storage key plus the index"
|
|
5628
|
+
},
|
|
5629
|
+
{
|
|
5630
|
+
"file": "sdks/swift/Tests/CrossdeckTests/EntitlementCacheIsolationTests.swift",
|
|
5631
|
+
"name": "test_identifyB_makesAEntitlementsUnreachable"
|
|
5632
|
+
},
|
|
5633
|
+
{
|
|
5634
|
+
"file": "sdks/swift/Tests/CrossdeckTests/EntitlementCacheIsolationTests.swift",
|
|
5635
|
+
"name": "test_identifiedWritesLandUnderPerUserSha256Key"
|
|
5636
|
+
},
|
|
5637
|
+
{
|
|
5638
|
+
"file": "sdks/swift/Tests/CrossdeckTests/EntitlementCacheIsolationTests.swift",
|
|
5639
|
+
"name": "test_clearAll_removesEveryPerUserStorageKeyPlusIndex"
|
|
5640
|
+
},
|
|
5641
|
+
{
|
|
5642
|
+
"file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/EntitlementCacheIsolationTest.kt",
|
|
5643
|
+
"name": "identified writes land under per-user sha256 key"
|
|
5644
|
+
},
|
|
5645
|
+
{
|
|
5646
|
+
"file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/EntitlementCacheIsolationTest.kt",
|
|
5647
|
+
"name": "identify B makes A entitlements unreachable from in-memory"
|
|
5648
|
+
},
|
|
5649
|
+
{
|
|
5650
|
+
"file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/EntitlementCacheIsolationTest.kt",
|
|
5651
|
+
"name": "clearAll removes every per-user storage key plus the index"
|
|
5652
|
+
},
|
|
5653
|
+
{
|
|
5654
|
+
"file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/EntitlementCacheIsolationTest.kt",
|
|
5655
|
+
"name": "a fresh cache bound to A's key CANNOT read B's blob"
|
|
5656
|
+
}
|
|
5657
|
+
],
|
|
5658
|
+
"registeredAt": "2026-05-26",
|
|
5659
|
+
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 1.3 (web/RN) + dogfood-gap fix (swift + android)",
|
|
5660
|
+
"bundledIn": "@cross-deck/web@1.5.3"
|
|
5661
|
+
},
|
|
5662
|
+
{
|
|
5663
|
+
"id": "sdk-error-codes-catalogue",
|
|
5664
|
+
"pillar": "errors",
|
|
5665
|
+
"status": "enforced",
|
|
5666
|
+
"claim": "Web + Node SDK error-codes catalogues include EVERY backend-emitted ApiErrorCode with a description + resolution. Pre-v1.4.0 the catalogues documented codes the SDK threw ITSELF but ZERO backend codes \u2014 a developer hitting `invalid_api_key` / `origin_not_allowed` / `bundle_id_not_allowed` / `env_mismatch` / `idempotency_key_in_use` etc. got `undefined` from getErrorCode() and had to hunt for guidance. v1.4.0 backfills the catalogue from backend/src/api/v1-errors.ts so every wire code has a canonical 'what does this mean / what should I do' answer Stripe-style.",
|
|
5667
|
+
"appliesTo": [
|
|
5668
|
+
"web",
|
|
5669
|
+
"node"
|
|
5670
|
+
],
|
|
5671
|
+
"codeRef": [
|
|
5672
|
+
"sdks/web/src/error-codes.ts",
|
|
5673
|
+
"sdks/node/src/error-codes.ts",
|
|
5674
|
+
"backend/src/api/v1-errors.ts"
|
|
5675
|
+
],
|
|
5676
|
+
"testRef": [
|
|
5677
|
+
{
|
|
5678
|
+
"file": "sdks/web/tests/error-codes-backfill.test.ts",
|
|
5679
|
+
"name": "includes backend code"
|
|
5680
|
+
},
|
|
5681
|
+
{
|
|
5682
|
+
"file": "sdks/web/tests/error-codes-backfill.test.ts",
|
|
5683
|
+
"name": "invalid_api_key resolution points at the dashboard"
|
|
5684
|
+
},
|
|
5685
|
+
{
|
|
5686
|
+
"file": "sdks/web/tests/error-codes-backfill.test.ts",
|
|
5687
|
+
"name": "idempotency_key_in_use resolution mentions Stripe-grade contract"
|
|
5688
|
+
},
|
|
5689
|
+
{
|
|
5690
|
+
"file": "sdks/web/tests/error-codes-backfill.test.ts",
|
|
5691
|
+
"name": "identity-lock codes carry permission_error type"
|
|
5692
|
+
},
|
|
5693
|
+
{
|
|
5694
|
+
"file": "sdks/web/tests/error-codes-backfill.test.ts",
|
|
5695
|
+
"name": "no entry has an empty description or resolution"
|
|
5696
|
+
}
|
|
5697
|
+
],
|
|
5698
|
+
"registeredAt": "2026-05-26",
|
|
5699
|
+
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 6.2",
|
|
5700
|
+
"bundledIn": "@cross-deck/web@1.5.3"
|
|
5701
|
+
},
|
|
5702
|
+
{
|
|
5703
|
+
"id": "sync-purchases-funnel-parity",
|
|
5704
|
+
"pillar": "analytics",
|
|
5705
|
+
"status": "enforced",
|
|
5706
|
+
"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`.",
|
|
5707
|
+
"appliesTo": [
|
|
5708
|
+
"web",
|
|
5709
|
+
"node",
|
|
5710
|
+
"react-native",
|
|
5711
|
+
"swift",
|
|
5712
|
+
"android"
|
|
5713
|
+
],
|
|
5714
|
+
"codeRef": [
|
|
5715
|
+
"sdks/web/src/crossdeck.ts",
|
|
5716
|
+
"sdks/node/src/crossdeck-server.ts",
|
|
5717
|
+
"sdks/react-native/src/crossdeck.ts",
|
|
5718
|
+
"sdks/swift/Sources/Crossdeck/Crossdeck.swift",
|
|
5719
|
+
"sdks/android/crossdeck/src/main/kotlin/com/crossdeck/Crossdeck.kt"
|
|
5720
|
+
],
|
|
5721
|
+
"testRef": [
|
|
5722
|
+
{
|
|
5723
|
+
"file": "sdks/web/tests/sync-purchases-funnel.test.ts",
|
|
5724
|
+
"name": "emits purchase.completed after a successful sync"
|
|
5725
|
+
},
|
|
5726
|
+
{
|
|
5727
|
+
"file": "sdks/web/tests/sync-purchases-funnel.test.ts",
|
|
5728
|
+
"name": "carries idempotent_replay=true when backend replied from cache"
|
|
5729
|
+
}
|
|
5730
|
+
],
|
|
5731
|
+
"registeredAt": "2026-05-26",
|
|
5732
|
+
"firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 3.5",
|
|
5733
|
+
"bundledIn": "@cross-deck/web@1.5.3"
|
|
5734
|
+
}
|
|
5735
|
+
]);
|
|
5736
|
+
|
|
5737
|
+
// src/contracts.ts
|
|
5738
|
+
var CrossdeckContracts = {
|
|
5739
|
+
/** Every contract that applies to this SDK and is currently enforced. */
|
|
5740
|
+
all() {
|
|
5741
|
+
return BUNDLED_CONTRACTS.filter((c) => c.status === "enforced");
|
|
5742
|
+
},
|
|
5743
|
+
/**
|
|
5744
|
+
* Every contract bundled with this SDK release, including
|
|
5745
|
+
* `proposed` and `retired` entries. Use `all()` for the
|
|
5746
|
+
* enforced-only view.
|
|
5747
|
+
*/
|
|
5748
|
+
allIncludingHistorical() {
|
|
5749
|
+
return BUNDLED_CONTRACTS;
|
|
5750
|
+
},
|
|
5751
|
+
/** Look up a contract by its stable `id`. */
|
|
5752
|
+
byId(id) {
|
|
5753
|
+
return BUNDLED_CONTRACTS.find((c) => c.id === id);
|
|
5754
|
+
},
|
|
5755
|
+
/** Every enforced contract within a pillar. */
|
|
5756
|
+
byPillar(pillar) {
|
|
5757
|
+
return BUNDLED_CONTRACTS.filter(
|
|
5758
|
+
(c) => c.pillar === pillar && c.status === "enforced"
|
|
5759
|
+
);
|
|
5760
|
+
},
|
|
5761
|
+
/** Filter by lifecycle status. */
|
|
5762
|
+
withStatus(status) {
|
|
5763
|
+
return BUNDLED_CONTRACTS.filter((c) => c.status === status);
|
|
5764
|
+
},
|
|
5765
|
+
/** Semver of the SDK release these contracts were bundled with. */
|
|
5766
|
+
sdkVersion: SDK_VERSION2,
|
|
5767
|
+
/** Fully-qualified bundle identifier — e.g. `@cross-deck/web@1.4.2`. */
|
|
5768
|
+
bundledIn: BUNDLED_IN,
|
|
5769
|
+
/**
|
|
5770
|
+
* Resolve a failing test back to the contract it exercises.
|
|
5771
|
+
* Used by test-framework hooks (Vitest `afterEach`, XCTest
|
|
5772
|
+
* observation, JUnit `TestWatcher`) to find the contract id of
|
|
5773
|
+
* a failed contract test so `reportContractFailure(...)` can
|
|
5774
|
+
* stamp the right `contract_id` on the emitted event.
|
|
5775
|
+
*
|
|
5776
|
+
* Match is on `testRef.name` (case-sensitive, exact). Returns
|
|
5777
|
+
* the first contract whose `testRef` list contains a matching
|
|
5778
|
+
* entry, regardless of pillar or status.
|
|
5779
|
+
*/
|
|
5780
|
+
findByTestName(name) {
|
|
5781
|
+
return BUNDLED_CONTRACTS.find(
|
|
5782
|
+
(c) => c.testRef.some((ref) => ref.name === name)
|
|
5783
|
+
);
|
|
5784
|
+
}
|
|
5785
|
+
};
|
|
3814
5786
|
export {
|
|
3815
5787
|
CROSSDECK_ERROR_CODES,
|
|
3816
5788
|
Crossdeck,
|
|
3817
5789
|
CrossdeckClient,
|
|
5790
|
+
CrossdeckContracts,
|
|
3818
5791
|
CrossdeckError,
|
|
3819
5792
|
DEFAULT_BASE_URL,
|
|
3820
5793
|
MemoryStorage,
|