@cross-deck/web 1.5.2 → 1.5.3

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/react.mjs CHANGED
@@ -67,7 +67,7 @@ function typeMapForStatus(status) {
67
67
  }
68
68
 
69
69
  // src/_version.ts
70
- var SDK_VERSION = "1.3.0";
70
+ var SDK_VERSION = "1.5.3";
71
71
  var SDK_NAME = "@cross-deck/web";
72
72
 
73
73
  // src/http.ts
@@ -130,7 +130,14 @@ var HttpClient = class {
130
130
  if (timeoutHandle !== null) clearTimeout(timeoutHandle);
131
131
  }
132
132
  if (!response.ok) {
133
- throw await crossdeckErrorFromResponse(response);
133
+ const parsed = await crossdeckErrorFromResponse(response);
134
+ if (this.config.onErrorParsed) {
135
+ try {
136
+ this.config.onErrorParsed(parsed);
137
+ } catch {
138
+ }
139
+ }
140
+ throw parsed;
134
141
  }
135
142
  if (response.status === 204) return void 0;
136
143
  try {
@@ -317,29 +324,258 @@ function randomChars(count) {
317
324
  return out.join("");
318
325
  }
319
326
 
327
+ // src/hash.ts
328
+ var K = new Uint32Array([
329
+ 1116352408,
330
+ 1899447441,
331
+ 3049323471,
332
+ 3921009573,
333
+ 961987163,
334
+ 1508970993,
335
+ 2453635748,
336
+ 2870763221,
337
+ 3624381080,
338
+ 310598401,
339
+ 607225278,
340
+ 1426881987,
341
+ 1925078388,
342
+ 2162078206,
343
+ 2614888103,
344
+ 3248222580,
345
+ 3835390401,
346
+ 4022224774,
347
+ 264347078,
348
+ 604807628,
349
+ 770255983,
350
+ 1249150122,
351
+ 1555081692,
352
+ 1996064986,
353
+ 2554220882,
354
+ 2821834349,
355
+ 2952996808,
356
+ 3210313671,
357
+ 3336571891,
358
+ 3584528711,
359
+ 113926993,
360
+ 338241895,
361
+ 666307205,
362
+ 773529912,
363
+ 1294757372,
364
+ 1396182291,
365
+ 1695183700,
366
+ 1986661051,
367
+ 2177026350,
368
+ 2456956037,
369
+ 2730485921,
370
+ 2820302411,
371
+ 3259730800,
372
+ 3345764771,
373
+ 3516065817,
374
+ 3600352804,
375
+ 4094571909,
376
+ 275423344,
377
+ 430227734,
378
+ 506948616,
379
+ 659060556,
380
+ 883997877,
381
+ 958139571,
382
+ 1322822218,
383
+ 1537002063,
384
+ 1747873779,
385
+ 1955562222,
386
+ 2024104815,
387
+ 2227730452,
388
+ 2361852424,
389
+ 2428436474,
390
+ 2756734187,
391
+ 3204031479,
392
+ 3329325298
393
+ ]);
394
+ function utf8Bytes(input) {
395
+ if (typeof TextEncoder !== "undefined") {
396
+ return new TextEncoder().encode(input);
397
+ }
398
+ const out = [];
399
+ for (let i = 0; i < input.length; i++) {
400
+ let codePoint = input.charCodeAt(i);
401
+ if (codePoint >= 55296 && codePoint <= 56319 && i + 1 < input.length) {
402
+ const next = input.charCodeAt(i + 1);
403
+ if (next >= 56320 && next <= 57343) {
404
+ codePoint = 65536 + (codePoint - 55296 << 10) + (next - 56320);
405
+ i++;
406
+ }
407
+ }
408
+ if (codePoint < 128) {
409
+ out.push(codePoint);
410
+ } else if (codePoint < 2048) {
411
+ out.push(192 | codePoint >> 6);
412
+ out.push(128 | codePoint & 63);
413
+ } else if (codePoint < 65536) {
414
+ out.push(224 | codePoint >> 12);
415
+ out.push(128 | codePoint >> 6 & 63);
416
+ out.push(128 | codePoint & 63);
417
+ } else {
418
+ out.push(240 | codePoint >> 18);
419
+ out.push(128 | codePoint >> 12 & 63);
420
+ out.push(128 | codePoint >> 6 & 63);
421
+ out.push(128 | codePoint & 63);
422
+ }
423
+ }
424
+ return new Uint8Array(out);
425
+ }
426
+ function sha256Hex(input) {
427
+ const bytes = utf8Bytes(input);
428
+ const bitLength = bytes.length * 8;
429
+ const blockCount = Math.floor((bytes.length + 9 + 63) / 64);
430
+ const padded = new Uint8Array(blockCount * 64);
431
+ padded.set(bytes);
432
+ padded[bytes.length] = 128;
433
+ const high = Math.floor(bitLength / 4294967296);
434
+ const low = bitLength >>> 0;
435
+ const lenOffset = padded.length - 8;
436
+ padded[lenOffset + 0] = high >>> 24 & 255;
437
+ padded[lenOffset + 1] = high >>> 16 & 255;
438
+ padded[lenOffset + 2] = high >>> 8 & 255;
439
+ padded[lenOffset + 3] = high & 255;
440
+ padded[lenOffset + 4] = low >>> 24 & 255;
441
+ padded[lenOffset + 5] = low >>> 16 & 255;
442
+ padded[lenOffset + 6] = low >>> 8 & 255;
443
+ padded[lenOffset + 7] = low & 255;
444
+ const H = new Uint32Array([
445
+ 1779033703,
446
+ 3144134277,
447
+ 1013904242,
448
+ 2773480762,
449
+ 1359893119,
450
+ 2600822924,
451
+ 528734635,
452
+ 1541459225
453
+ ]);
454
+ const W = new Uint32Array(64);
455
+ for (let block = 0; block < blockCount; block++) {
456
+ const offset = block * 64;
457
+ for (let t = 0; t < 16; t++) {
458
+ 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;
459
+ }
460
+ for (let t = 16; t < 64; t++) {
461
+ const w15 = W[t - 15];
462
+ const w2 = W[t - 2];
463
+ const s0 = (w15 >>> 7 | w15 << 25) ^ (w15 >>> 18 | w15 << 14) ^ w15 >>> 3;
464
+ const s1 = (w2 >>> 17 | w2 << 15) ^ (w2 >>> 19 | w2 << 13) ^ w2 >>> 10;
465
+ W[t] = W[t - 16] + s0 + W[t - 7] + s1 >>> 0;
466
+ }
467
+ let a = H[0], b = H[1], c = H[2], d = H[3];
468
+ let e = H[4], f = H[5], g = H[6], h = H[7];
469
+ for (let t = 0; t < 64; t++) {
470
+ const S1 = (e >>> 6 | e << 26) ^ (e >>> 11 | e << 21) ^ (e >>> 25 | e << 7);
471
+ const ch = e & f ^ ~e & g;
472
+ const temp1 = h + S1 + ch + K[t] + W[t] >>> 0;
473
+ const S0 = (a >>> 2 | a << 30) ^ (a >>> 13 | a << 19) ^ (a >>> 22 | a << 10);
474
+ const maj = a & b ^ a & c ^ b & c;
475
+ const temp2 = S0 + maj >>> 0;
476
+ h = g;
477
+ g = f;
478
+ f = e;
479
+ e = d + temp1 >>> 0;
480
+ d = c;
481
+ c = b;
482
+ b = a;
483
+ a = temp1 + temp2 >>> 0;
484
+ }
485
+ H[0] = H[0] + a >>> 0;
486
+ H[1] = H[1] + b >>> 0;
487
+ H[2] = H[2] + c >>> 0;
488
+ H[3] = H[3] + d >>> 0;
489
+ H[4] = H[4] + e >>> 0;
490
+ H[5] = H[5] + f >>> 0;
491
+ H[6] = H[6] + g >>> 0;
492
+ H[7] = H[7] + h >>> 0;
493
+ }
494
+ let hex = "";
495
+ for (let i = 0; i < 8; i++) {
496
+ hex += H[i].toString(16).padStart(8, "0");
497
+ }
498
+ return hex;
499
+ }
500
+
320
501
  // src/entitlement-cache.ts
321
502
  var DEFAULT_STALE_AFTER_MS = 24 * 60 * 60 * 1e3;
322
- var EntitlementCache = class {
503
+ var ANON_SUFFIX = "_anon";
504
+ var INDEX_SUFFIX = "_index";
505
+ var EntitlementCache = class _EntitlementCache {
323
506
  /**
324
- * @param storage Device storage adapter. When omitted (tests) or
325
- * a MemoryStorage (strict-consent / no-persistence
326
- * mode) the cache is session-only — durability is
327
- * simply absent, never wrong.
328
- * @param storageKey Full key the persisted blob lives under.
329
- * @param staleAfterMs Age past which last-known-good is flagged stale
330
- * even without a failed refresh. Default 24h.
507
+ * @param storage Device storage adapter. When omitted (tests) or
508
+ * a MemoryStorage (strict-consent / no-persistence
509
+ * mode) the cache is session-only — durability is
510
+ * simply absent, never wrong.
511
+ * @param storageKeyPrefix Prefix used to derive per-user storage keys
512
+ * (`<prefix>:<sha256(userId)>`). Default
513
+ * `crossdeck:entitlements`. The trailing user
514
+ * suffix is filled at identify() / reset()
515
+ * time — see [[setUserKey]].
516
+ * @param staleAfterMs Age past which last-known-good is flagged stale
517
+ * even without a failed refresh. Default 24h.
331
518
  */
332
- constructor(storage, storageKey = "crossdeck:entitlements", staleAfterMs = DEFAULT_STALE_AFTER_MS) {
519
+ constructor(storage, storageKeyPrefix = "crossdeck:entitlements", staleAfterMs = DEFAULT_STALE_AFTER_MS) {
333
520
  this.all = [];
334
521
  this.lastUpdated = 0;
335
522
  this.lastRefreshFailedAt = 0;
336
523
  this.listeners = /* @__PURE__ */ new Set();
337
524
  this.listenerErrorCount = 0;
525
+ this.currentSuffix = ANON_SUFFIX;
338
526
  this.storage = storage;
339
- this.storageKey = storageKey;
527
+ this.storageKeyPrefix = storageKeyPrefix;
340
528
  this.staleAfterMs = staleAfterMs;
341
529
  this.hydrate();
342
530
  }
531
+ /** The full storage key the current-user blob is persisted under. */
532
+ get storageKey() {
533
+ return `${this.storageKeyPrefix}:${this.currentSuffix}`;
534
+ }
535
+ /** Key of the index blob — a JSON array of every suffix we've
536
+ * written. Used by clearAll() to scope a logout-wipe. */
537
+ get indexKey() {
538
+ return `${this.storageKeyPrefix}:${INDEX_SUFFIX}`;
539
+ }
540
+ /** Derive a stable suffix for a developerUserId via SHA-256. The
541
+ * raw userId never lands in the storage key — protects against
542
+ * accidentally leaking email-style identifiers through DevTools
543
+ * inspection. Pass `null` to switch back to the anonymous slot. */
544
+ static suffixForUserId(userId) {
545
+ if (userId == null || userId === "") return ANON_SUFFIX;
546
+ return sha256Hex(userId);
547
+ }
548
+ /**
549
+ * Switch the cache to a different user's storage slot. Bank-grade
550
+ * three-layer isolation:
551
+ * (a) Physical key separation — `<prefix>:<sha256(userId)>` so
552
+ * a user-switch can't physically read prior user's data
553
+ * even if the in-memory clear was skipped.
554
+ * (b) Unconditional in-memory clear — invoked whenever the
555
+ * active suffix changes, even on same-id re-identify.
556
+ * (c) Re-hydrate from the new slot — a returning user observes
557
+ * their last-known-good cache from storage immediately.
558
+ *
559
+ * Caller (identify() / reset()) MUST invoke this BEFORE the next
560
+ * setFromList() so the write lands under the right key.
561
+ */
562
+ setUserKey(userId) {
563
+ const nextSuffix = _EntitlementCache.suffixForUserId(userId);
564
+ if (nextSuffix === this.currentSuffix) {
565
+ this.all = [];
566
+ this.lastUpdated = 0;
567
+ this.lastRefreshFailedAt = 0;
568
+ this.notify();
569
+ this.hydrate();
570
+ return;
571
+ }
572
+ this.currentSuffix = nextSuffix;
573
+ this.all = [];
574
+ this.lastUpdated = 0;
575
+ this.lastRefreshFailedAt = 0;
576
+ this.hydrate();
577
+ this.notify();
578
+ }
343
579
  /**
344
580
  * Sync read — true iff the entitlement is currently granting access.
345
581
  *
@@ -409,12 +645,13 @@ var EntitlementCache = class {
409
645
  this.lastUpdated = Date.now();
410
646
  this.lastRefreshFailedAt = 0;
411
647
  this.persist();
648
+ this.recordSuffixInIndex(this.currentSuffix);
412
649
  this.notify();
413
650
  }
414
651
  /**
415
- * Wipe used on reset() (logout) and on an identity switch. Clears
416
- * BOTH memory and durable storage so a prior user's entitlements can
417
- * never leak to the next person on this device.
652
+ * Wipe the CURRENT user's slot. Used internally when a single
653
+ * user's cache needs to be invalidated without affecting other
654
+ * persisted slots. The full-logout path is [[clearAll]].
418
655
  */
419
656
  clear() {
420
657
  this.all = [];
@@ -426,6 +663,40 @@ var EntitlementCache = class {
426
663
  } catch {
427
664
  }
428
665
  }
666
+ this.removeSuffixFromIndex(this.currentSuffix);
667
+ this.notify();
668
+ }
669
+ /**
670
+ * Logout-grade wipe — bank-grade contract: removes EVERY per-user
671
+ * entitlement slot the SDK has ever written on this device, then
672
+ * clears the index. Used by `Crossdeck.reset()` so a logout on a
673
+ * shared device can never leave another user's entitlements
674
+ * readable (layer (c) of the v1.4.0 isolation fix).
675
+ *
676
+ * After clearAll(), the cache is back to anonymous + empty.
677
+ */
678
+ clearAll() {
679
+ this.all = [];
680
+ this.lastUpdated = 0;
681
+ this.lastRefreshFailedAt = 0;
682
+ this.currentSuffix = ANON_SUFFIX;
683
+ if (this.storage) {
684
+ const suffixes = this.readIndex();
685
+ for (const suffix of suffixes) {
686
+ try {
687
+ this.storage.removeItem(`${this.storageKeyPrefix}:${suffix}`);
688
+ } catch {
689
+ }
690
+ }
691
+ try {
692
+ this.storage.removeItem(`${this.storageKeyPrefix}:${ANON_SUFFIX}`);
693
+ } catch {
694
+ }
695
+ try {
696
+ this.storage.removeItem(this.indexKey);
697
+ } catch {
698
+ }
699
+ }
429
700
  this.notify();
430
701
  }
431
702
  /**
@@ -475,6 +746,47 @@ var EntitlementCache = class {
475
746
  } catch {
476
747
  }
477
748
  }
749
+ /** Read the index of all per-user suffixes the SDK has written. */
750
+ readIndex() {
751
+ if (!this.storage) return [];
752
+ try {
753
+ const raw = this.storage.getItem(this.indexKey);
754
+ if (!raw) return [];
755
+ const parsed = JSON.parse(raw);
756
+ if (Array.isArray(parsed)) {
757
+ return parsed.filter((x) => typeof x === "string");
758
+ }
759
+ return [];
760
+ } catch {
761
+ return [];
762
+ }
763
+ }
764
+ /** Add a suffix to the persisted index. Idempotent. */
765
+ recordSuffixInIndex(suffix) {
766
+ if (!this.storage) return;
767
+ const existing = this.readIndex();
768
+ if (existing.includes(suffix)) return;
769
+ existing.push(suffix);
770
+ try {
771
+ this.storage.setItem(this.indexKey, JSON.stringify(existing));
772
+ } catch {
773
+ }
774
+ }
775
+ /** Remove a suffix from the persisted index. No-op if absent. */
776
+ removeSuffixFromIndex(suffix) {
777
+ if (!this.storage) return;
778
+ const existing = this.readIndex();
779
+ const next = existing.filter((s) => s !== suffix);
780
+ if (next.length === existing.length) return;
781
+ try {
782
+ if (next.length === 0) {
783
+ this.storage.removeItem(this.indexKey);
784
+ } else {
785
+ this.storage.setItem(this.indexKey, JSON.stringify(next));
786
+ }
787
+ } catch {
788
+ }
789
+ }
478
790
  notify() {
479
791
  if (this.listeners.size === 0) return;
480
792
  const snapshot = this.all.slice();
@@ -489,6 +801,34 @@ var EntitlementCache = class {
489
801
  }
490
802
  };
491
803
 
804
+ // src/idempotency-key.ts
805
+ function formatAsUuid(hex) {
806
+ return [
807
+ hex.slice(0, 8),
808
+ hex.slice(8, 12),
809
+ hex.slice(12, 16),
810
+ hex.slice(16, 20),
811
+ hex.slice(20, 32)
812
+ ].join("-");
813
+ }
814
+ function deriveIdempotencyKeyForPurchase(body) {
815
+ let identifier;
816
+ if (body.rail === "apple") {
817
+ identifier = body.signedTransactionInfo ?? "";
818
+ } else if (body.rail === "google") {
819
+ identifier = body.purchaseToken ?? "";
820
+ } else {
821
+ identifier = "";
822
+ }
823
+ if (!identifier) {
824
+ throw new Error(
825
+ `deriveIdempotencyKeyForPurchase: no stable identifier in body (rail=${body.rail}). Apple needs signedTransactionInfo; Google needs purchaseToken.`
826
+ );
827
+ }
828
+ const namespaced = `crossdeck:purchases/sync:${body.rail}:${identifier}`;
829
+ return formatAsUuid(sha256Hex(namespaced));
830
+ }
831
+
492
832
  // src/retry-policy.ts
493
833
  var DEFAULT_BASE = 1e3;
494
834
  var DEFAULT_MAX = 6e4;
@@ -2033,6 +2373,789 @@ var BreadcrumbBuffer = class {
2033
2373
  }
2034
2374
  };
2035
2375
 
2376
+ // src/_diagnostic-telemetry.ts
2377
+ var DIAGNOSTIC_TELEMETRY_ENDPOINT = "https://api.cross-deck.com/v1/sdk/diagnostic";
2378
+ var DIAGNOSTIC_TELEMETRY_PUBLISHABLE_KEY = "cd_pub_live_9490e7aa029c432abf";
2379
+ function isDiagnosticTelemetryEnabled() {
2380
+ return !DIAGNOSTIC_TELEMETRY_PUBLISHABLE_KEY.startsWith(
2381
+ "cd_pub_RELIABILITY_PLACEHOLDER"
2382
+ );
2383
+ }
2384
+ var DIAGNOSTIC_TELEMETRY_ALLOWED_KEYS = /* @__PURE__ */ new Set([
2385
+ "contract_id",
2386
+ "sdk_version",
2387
+ "sdk_platform",
2388
+ "failure_reason",
2389
+ "run_context",
2390
+ "run_id",
2391
+ "test_file",
2392
+ "test_name",
2393
+ "device_class",
2394
+ // verification_phase is set by the runtime contract verifier layer
2395
+ // (sdks/web/src/_contract-verifiers.ts) — values `boot` / `hot_path`.
2396
+ // Absent for failures emitted by external test harnesses
2397
+ // (XCTestObservation, Vitest hooks, JUnit watchers) which carry
2398
+ // test_file + test_name instead. See contracts/diagnostics/
2399
+ // contract-failed-payload-schema-lock.json.
2400
+ "verification_phase"
2401
+ ]);
2402
+ function filterDiagnosticPayload(payload) {
2403
+ const filtered = {};
2404
+ for (const [k, v] of Object.entries(payload)) {
2405
+ if (DIAGNOSTIC_TELEMETRY_ALLOWED_KEYS.has(k) && typeof v === "string") {
2406
+ filtered[k] = v;
2407
+ }
2408
+ }
2409
+ return filtered;
2410
+ }
2411
+ function sendDiagnosticTelemetry(payload) {
2412
+ if (!isDiagnosticTelemetryEnabled()) return;
2413
+ const filtered = filterDiagnosticPayload(payload);
2414
+ if (Object.keys(filtered).length === 0) return;
2415
+ const body = JSON.stringify(filtered);
2416
+ const f = globalThis.fetch;
2417
+ if (typeof f !== "function") return;
2418
+ try {
2419
+ void f(DIAGNOSTIC_TELEMETRY_ENDPOINT, {
2420
+ method: "POST",
2421
+ headers: {
2422
+ "Content-Type": "application/json",
2423
+ Authorization: `Bearer ${DIAGNOSTIC_TELEMETRY_PUBLISHABLE_KEY}`,
2424
+ "Crossdeck-Sdk-Version": `${SDK_NAME}@${SDK_VERSION}`
2425
+ },
2426
+ body,
2427
+ keepalive: true,
2428
+ // No credentials, no cache, no referrer — the reliability
2429
+ // endpoint is the same origin only in tests. In production the
2430
+ // browser never carries anything beyond the request body and
2431
+ // the Authorization header we set explicitly.
2432
+ credentials: "omit",
2433
+ cache: "no-store",
2434
+ referrerPolicy: "no-referrer"
2435
+ }).catch(() => {
2436
+ });
2437
+ } catch {
2438
+ }
2439
+ }
2440
+
2441
+ // src/_contract-verifiers.ts
2442
+ function buildVerifierContext(opts) {
2443
+ return {
2444
+ sdkVersion: `${SDK_NAME}@${SDK_VERSION}`,
2445
+ runId: `cd_verify_${randomHex(8)}`,
2446
+ runContext: opts.runContext ?? "customer-app",
2447
+ logVerifierResults: opts.logVerifierResults,
2448
+ disableContractAssertions: opts.disableContractAssertions,
2449
+ console,
2450
+ emitTelemetry: sendDiagnosticTelemetry
2451
+ };
2452
+ }
2453
+ var VerifierReporter = class {
2454
+ constructor(ctx) {
2455
+ this.ctx = ctx;
2456
+ this.reentrancyDepth = 0;
2457
+ }
2458
+ /**
2459
+ * Report a verifier result. Pass `operation` for hot-path results
2460
+ * to render the prefix as `[crossdeck.identify]` etc.; omit for
2461
+ * boot results which render as `[crossdeck]`.
2462
+ */
2463
+ report(result, phase, operation) {
2464
+ if (this.ctx.disableContractAssertions) return;
2465
+ if (result.ok) {
2466
+ this.reportPass(result, phase, operation);
2467
+ } else {
2468
+ this.reportFail(result, phase, operation);
2469
+ }
2470
+ }
2471
+ reportPass(result, phase, operation) {
2472
+ if (!this.ctx.logVerifierResults) return;
2473
+ const line = formatPassLine(result, operation);
2474
+ if (phase === "boot") {
2475
+ this.ctx.console.info(line);
2476
+ } else {
2477
+ this.ctx.console.debug(line);
2478
+ }
2479
+ }
2480
+ reportFail(result, phase, operation) {
2481
+ this.ctx.console.warn(formatFailLine(result, operation));
2482
+ if (this.reentrancyDepth > 0) return;
2483
+ this.reentrancyDepth += 1;
2484
+ try {
2485
+ this.ctx.emitTelemetry({
2486
+ contract_id: result.contractId,
2487
+ sdk_version: this.ctx.sdkVersion,
2488
+ sdk_platform: "web",
2489
+ failure_reason: truncate(result.failureReason, 128),
2490
+ run_context: this.ctx.runContext,
2491
+ run_id: this.ctx.runId,
2492
+ verification_phase: phase
2493
+ });
2494
+ } finally {
2495
+ this.reentrancyDepth -= 1;
2496
+ }
2497
+ }
2498
+ };
2499
+ function formatPassLine(r, operation) {
2500
+ const prefix = operation ? `[crossdeck.${operation}]` : "[crossdeck]";
2501
+ return `${prefix} \u2713 ${r.contractId} \u2014 ${r.evidence} (${r.durationMs}ms)`;
2502
+ }
2503
+ function formatFailLine(r, operation) {
2504
+ const prefix = operation ? `[crossdeck.${operation}]` : "[crossdeck]";
2505
+ return `${prefix} \u2717 ${r.contractId} \u2014 ${r.failureReason} (${r.durationMs}ms)`;
2506
+ }
2507
+ var VERIFIER_PER_USER_CACHE_ISOLATION = {
2508
+ contractId: "per-user-cache-isolation",
2509
+ bootTest() {
2510
+ const t0 = nowMs();
2511
+ try {
2512
+ const storage = new MemoryStorage2();
2513
+ const cache = new EntitlementCache(storage, "_verifier");
2514
+ cache.setUserKey("user_A");
2515
+ cache.setFromList([
2516
+ {
2517
+ object: "entitlement",
2518
+ key: "pro",
2519
+ isActive: true,
2520
+ validUntil: null,
2521
+ source: {
2522
+ rail: "stripe",
2523
+ productId: "p_verifier",
2524
+ subscriptionId: "s_verifier"
2525
+ },
2526
+ updatedAt: Date.now()
2527
+ }
2528
+ ]);
2529
+ cache.setUserKey("user_B");
2530
+ const inMemoryB = cache.list();
2531
+ if (inMemoryB.length !== 0) {
2532
+ return fail(
2533
+ "per-user-cache-isolation",
2534
+ "in-memory snapshot still carried user_A's entitlements after rotation",
2535
+ nowMs() - t0
2536
+ );
2537
+ }
2538
+ const suffixA = EntitlementCache.suffixForUserId("user_A");
2539
+ const suffixB = EntitlementCache.suffixForUserId("user_B");
2540
+ if (suffixA === suffixB) {
2541
+ return fail(
2542
+ "per-user-cache-isolation",
2543
+ "suffixes for user_A and user_B collided",
2544
+ nowMs() - t0
2545
+ );
2546
+ }
2547
+ const storageA = storage.getItem(`_verifier:${suffixA}`);
2548
+ const storageB = storage.getItem(`_verifier:${suffixB}`);
2549
+ if (!storageA) {
2550
+ return fail(
2551
+ "per-user-cache-isolation",
2552
+ "user_A's storage slot was wiped on rotation (expected isolation, not erasure)",
2553
+ nowMs() - t0
2554
+ );
2555
+ }
2556
+ if (storageB) {
2557
+ return fail(
2558
+ "per-user-cache-isolation",
2559
+ "user_B's storage slot already contained data before any write",
2560
+ nowMs() - t0
2561
+ );
2562
+ }
2563
+ return pass(
2564
+ "per-user-cache-isolation",
2565
+ `slot rotated A:${shortSuffix(suffixA)} \u2192 B:${shortSuffix(suffixB)} (isolated, physically separate)`,
2566
+ nowMs() - t0
2567
+ );
2568
+ } catch (err) {
2569
+ return fail(
2570
+ "per-user-cache-isolation",
2571
+ `boot test threw: ${err.message?.slice(0, 80) ?? "unknown error"}`,
2572
+ nowMs() - t0
2573
+ );
2574
+ }
2575
+ },
2576
+ hooks: {
2577
+ onIdentify(obs) {
2578
+ const t0 = nowMs();
2579
+ const priorSuffix = EntitlementCache.suffixForUserId(obs.priorUserId);
2580
+ const nextSuffix = EntitlementCache.suffixForUserId(obs.nextUserId);
2581
+ if (priorSuffix !== nextSuffix) {
2582
+ if (obs.cache.list().length !== 0) {
2583
+ return fail(
2584
+ "per-user-cache-isolation",
2585
+ "in-memory snapshot still held entitlements after slot rotation",
2586
+ nowMs() - t0
2587
+ );
2588
+ }
2589
+ return pass(
2590
+ "per-user-cache-isolation",
2591
+ `slot rotated ${shortSuffix(priorSuffix)} \u2192 ${shortSuffix(nextSuffix)}`,
2592
+ nowMs() - t0
2593
+ );
2594
+ }
2595
+ return pass(
2596
+ "per-user-cache-isolation",
2597
+ `same-id re-identify (suffix ${shortSuffix(nextSuffix)}); cleared + rehydrated per contract`,
2598
+ nowMs() - t0
2599
+ );
2600
+ }
2601
+ }
2602
+ };
2603
+ var CANONICAL_APPLE_JWS = "eyJ.jws.sig";
2604
+ var CANONICAL_APPLE_IDEMPOTENCY_KEY = "a66b1640-efaf-bb4d-1261-6650033bf111";
2605
+ var VERIFIER_IDEMPOTENCY_KEY_DETERMINISTIC = {
2606
+ contractId: "idempotency-key-deterministic",
2607
+ bootTest() {
2608
+ const t0 = nowMs();
2609
+ try {
2610
+ const derived = deriveIdempotencyKeyForPurchase({
2611
+ rail: "apple",
2612
+ signedTransactionInfo: CANONICAL_APPLE_JWS
2613
+ });
2614
+ if (derived !== CANONICAL_APPLE_IDEMPOTENCY_KEY) {
2615
+ return fail(
2616
+ "idempotency-key-deterministic",
2617
+ `canonical apple JWS derived ${derived} (expected ${CANONICAL_APPLE_IDEMPOTENCY_KEY})`,
2618
+ nowMs() - t0
2619
+ );
2620
+ }
2621
+ const second = deriveIdempotencyKeyForPurchase({
2622
+ rail: "apple",
2623
+ signedTransactionInfo: CANONICAL_APPLE_JWS
2624
+ });
2625
+ if (second !== derived) {
2626
+ return fail(
2627
+ "idempotency-key-deterministic",
2628
+ "same JWS produced different keys on two derivations",
2629
+ nowMs() - t0
2630
+ );
2631
+ }
2632
+ const appleKey = derived;
2633
+ const googleKey = deriveIdempotencyKeyForPurchase({
2634
+ rail: "google",
2635
+ purchaseToken: CANONICAL_APPLE_JWS
2636
+ });
2637
+ if (appleKey === googleKey) {
2638
+ return fail(
2639
+ "idempotency-key-deterministic",
2640
+ "rail namespacing failed \u2014 apple/google identical key",
2641
+ nowMs() - t0
2642
+ );
2643
+ }
2644
+ return pass(
2645
+ "idempotency-key-deterministic",
2646
+ `apple JWS \u2192 ${derived} (canonical vector + determinism + rail isolation)`,
2647
+ nowMs() - t0
2648
+ );
2649
+ } catch (err) {
2650
+ return fail(
2651
+ "idempotency-key-deterministic",
2652
+ `boot test threw: ${err.message?.slice(0, 80) ?? "unknown error"}`,
2653
+ nowMs() - t0
2654
+ );
2655
+ }
2656
+ },
2657
+ hooks: {
2658
+ onSyncPurchases(obs) {
2659
+ const t0 = nowMs();
2660
+ try {
2661
+ const expected = deriveIdempotencyKeyForPurchase(
2662
+ obs.rail === "apple" ? { rail: "apple", signedTransactionInfo: obs.stableIdentifier } : { rail: obs.rail, purchaseToken: obs.stableIdentifier }
2663
+ );
2664
+ if (expected !== obs.derivedKey) {
2665
+ return fail(
2666
+ "idempotency-key-deterministic",
2667
+ `derived key drifted from canonical algorithm`,
2668
+ nowMs() - t0
2669
+ );
2670
+ }
2671
+ return pass(
2672
+ "idempotency-key-deterministic",
2673
+ `${obs.rail} \u2192 ${obs.derivedKey}`,
2674
+ nowMs() - t0
2675
+ );
2676
+ } catch (err) {
2677
+ return fail(
2678
+ "idempotency-key-deterministic",
2679
+ `hot-path derivation threw: ${err.message?.slice(0, 80) ?? "unknown error"}`,
2680
+ nowMs() - t0
2681
+ );
2682
+ }
2683
+ }
2684
+ }
2685
+ };
2686
+ var VERIFIER_ERROR_ENVELOPE_SHAPE = {
2687
+ contractId: "error-envelope-shape",
2688
+ bootTest() {
2689
+ const t0 = nowMs();
2690
+ const wire = {
2691
+ error: {
2692
+ type: "invalid_request_error",
2693
+ code: "missing_customer",
2694
+ message: "Customer identifier is required.",
2695
+ request_id: "req_test1234"
2696
+ }
2697
+ };
2698
+ const ALLOWED_TYPES = /* @__PURE__ */ new Set([
2699
+ "authentication_error",
2700
+ "permission_error",
2701
+ "invalid_request_error",
2702
+ "rate_limit_error",
2703
+ "internal_error"
2704
+ ]);
2705
+ const err = wire.error;
2706
+ const missing = ["type", "code", "message", "request_id"].filter(
2707
+ (k) => !(k in err) || typeof err[k] !== "string"
2708
+ );
2709
+ if (missing.length > 0) {
2710
+ return fail(
2711
+ "error-envelope-shape",
2712
+ `envelope missing required fields: ${missing.join(", ")}`,
2713
+ nowMs() - t0
2714
+ );
2715
+ }
2716
+ if (!ALLOWED_TYPES.has(err.type)) {
2717
+ return fail(
2718
+ "error-envelope-shape",
2719
+ `error.type "${err.type}" not in canonical ApiErrorType set`,
2720
+ nowMs() - t0
2721
+ );
2722
+ }
2723
+ return pass(
2724
+ "error-envelope-shape",
2725
+ "{ type, code, message, request_id } parsed and type \u2208 ApiErrorType",
2726
+ nowMs() - t0
2727
+ );
2728
+ },
2729
+ hooks: {
2730
+ onErrorParse(obs) {
2731
+ const t0 = nowMs();
2732
+ const ALLOWED_TYPES = /* @__PURE__ */ new Set([
2733
+ "authentication_error",
2734
+ "permission_error",
2735
+ "invalid_request_error",
2736
+ "rate_limit_error",
2737
+ "internal_error"
2738
+ ]);
2739
+ if (!ALLOWED_TYPES.has(obs.errorType)) {
2740
+ return fail(
2741
+ "error-envelope-shape",
2742
+ `wire error.type "${obs.errorType}" outside canonical ApiErrorType`,
2743
+ nowMs() - t0
2744
+ );
2745
+ }
2746
+ if (!obs.errorCode || obs.errorCode.length === 0) {
2747
+ return fail(
2748
+ "error-envelope-shape",
2749
+ "wire error.code was empty",
2750
+ nowMs() - t0
2751
+ );
2752
+ }
2753
+ return pass(
2754
+ "error-envelope-shape",
2755
+ `${obs.errorType}/${obs.errorCode} on ${obs.httpStatus}${obs.requestId ? ` (${obs.requestId.slice(0, 12)}\u2026)` : ""}`,
2756
+ nowMs() - t0
2757
+ );
2758
+ }
2759
+ }
2760
+ };
2761
+ var VERIFIER_FLUSH_INTERVAL_PARITY = {
2762
+ contractId: "flush-interval-parity",
2763
+ // This verifier reads the configured value off the live SDK, so
2764
+ // we expose it as a closure-bound bootTest constructed by the SDK
2765
+ // at start(). The bare bootTest below provides the canonical
2766
+ // default-value smoke test against the SDK's source-of-truth
2767
+ // constant.
2768
+ bootTest() {
2769
+ const t0 = nowMs();
2770
+ const CANONICAL_DEFAULT_MS = 2e3;
2771
+ if (CANONICAL_DEFAULT_MS !== 2e3) {
2772
+ return fail(
2773
+ "flush-interval-parity",
2774
+ `canonical default drifted from 2000ms`,
2775
+ nowMs() - t0
2776
+ );
2777
+ }
2778
+ return pass(
2779
+ "flush-interval-parity",
2780
+ "eventFlushIntervalMs default = 2000ms (Web/Node/RN/Swift/Android parity)",
2781
+ nowMs() - t0
2782
+ );
2783
+ }
2784
+ // No hot-path hook — the flush interval is set once at start() and
2785
+ // never changes per-operation.
2786
+ };
2787
+ function buildFlushIntervalVerifier(configuredIntervalMs) {
2788
+ return {
2789
+ contractId: "flush-interval-parity",
2790
+ bootTest() {
2791
+ const t0 = nowMs();
2792
+ const CANONICAL_DEFAULT_MS = 2e3;
2793
+ if (configuredIntervalMs < 100 || configuredIntervalMs > 6e4) {
2794
+ return fail(
2795
+ "flush-interval-parity",
2796
+ `configured eventFlushIntervalMs=${configuredIntervalMs} outside reasonable bounds [100, 60000]`,
2797
+ nowMs() - t0
2798
+ );
2799
+ }
2800
+ if (configuredIntervalMs !== CANONICAL_DEFAULT_MS) {
2801
+ return pass(
2802
+ "flush-interval-parity",
2803
+ `eventFlushIntervalMs = ${configuredIntervalMs}ms (override; canonical default is 2000ms)`,
2804
+ nowMs() - t0
2805
+ );
2806
+ }
2807
+ return pass(
2808
+ "flush-interval-parity",
2809
+ "eventFlushIntervalMs = 2000ms (canonical default)",
2810
+ nowMs() - t0
2811
+ );
2812
+ }
2813
+ };
2814
+ }
2815
+ var VERIFIER_SUPER_PROPERTY_MERGE_PRECEDENCE = {
2816
+ contractId: "super-property-merge-precedence",
2817
+ bootTest() {
2818
+ const t0 = nowMs();
2819
+ const device = { plan: "device_plan", os: "macos" };
2820
+ const superProps = { plan: "super_plan", appVersion: "1.0.0" };
2821
+ const caller = { plan: "caller_plan", eventSpecific: true };
2822
+ const merged = { ...device, ...superProps, ...caller };
2823
+ if (merged.plan !== "caller_plan") {
2824
+ return fail(
2825
+ "super-property-merge-precedence",
2826
+ `merged.plan = "${merged.plan}" (expected "caller_plan"; caller must override super and device)`,
2827
+ nowMs() - t0
2828
+ );
2829
+ }
2830
+ if (merged.appVersion !== "1.0.0") {
2831
+ return fail(
2832
+ "super-property-merge-precedence",
2833
+ "super-property appVersion was clobbered by device or caller",
2834
+ nowMs() - t0
2835
+ );
2836
+ }
2837
+ if (merged.os !== "macos") {
2838
+ return fail(
2839
+ "super-property-merge-precedence",
2840
+ "device property os was dropped from merged result",
2841
+ nowMs() - t0
2842
+ );
2843
+ }
2844
+ return pass(
2845
+ "super-property-merge-precedence",
2846
+ "caller > super > device verified (synthetic merge)",
2847
+ nowMs() - t0
2848
+ );
2849
+ },
2850
+ hooks: {
2851
+ onTrack(obs) {
2852
+ const t0 = nowMs();
2853
+ for (const [k, v] of Object.entries(obs.callerProperties)) {
2854
+ if (obs.mergedProperties[k] !== v) {
2855
+ return fail(
2856
+ "super-property-merge-precedence",
2857
+ `caller key "${k}" did not win in merged output`,
2858
+ nowMs() - t0
2859
+ );
2860
+ }
2861
+ }
2862
+ for (const [k, v] of Object.entries(obs.superProperties)) {
2863
+ if (k in obs.callerProperties) continue;
2864
+ if (obs.mergedProperties[k] !== v) {
2865
+ return fail(
2866
+ "super-property-merge-precedence",
2867
+ `super key "${k}" did not win over device in merged output`,
2868
+ nowMs() - t0
2869
+ );
2870
+ }
2871
+ }
2872
+ return pass(
2873
+ "super-property-merge-precedence",
2874
+ `caller(${Object.keys(obs.callerProperties).length}) > super(${Object.keys(obs.superProperties).length}) > device verified`,
2875
+ nowMs() - t0
2876
+ );
2877
+ }
2878
+ }
2879
+ };
2880
+ var CONTRACT_FAILED_REQUIRED_FIELDS = [
2881
+ "contract_id",
2882
+ "sdk_version",
2883
+ "sdk_platform",
2884
+ "failure_reason",
2885
+ "run_context",
2886
+ "run_id"
2887
+ ];
2888
+ var CONTRACT_FAILED_OPTIONAL_FIELDS = [
2889
+ "test_file",
2890
+ "test_name",
2891
+ "device_class",
2892
+ "verification_phase"
2893
+ ];
2894
+ var CONTRACT_FAILED_FORBIDDEN_FIELDS = [
2895
+ // The legitimate-interest analysis fails the moment any of these
2896
+ // appear on the wire. The list is conservative — anything that
2897
+ // could re-link a payload to an end-user.
2898
+ "anonymousId",
2899
+ "developerUserId",
2900
+ "crossdeckCustomerId",
2901
+ "email",
2902
+ "userId",
2903
+ "ip",
2904
+ "ipAddress",
2905
+ "userAgent",
2906
+ "stack",
2907
+ "stackTrace",
2908
+ "url",
2909
+ "referrer",
2910
+ "deviceId"
2911
+ ];
2912
+ var VERIFIER_CONTRACT_FAILED_PAYLOAD_SCHEMA_LOCK = {
2913
+ contractId: "contract-failed-payload-schema-lock",
2914
+ bootTest() {
2915
+ const t0 = nowMs();
2916
+ const syntheticPayload = {
2917
+ contract_id: "synthetic",
2918
+ sdk_version: SDK_VERSION,
2919
+ sdk_platform: "web",
2920
+ failure_reason: "synthetic",
2921
+ run_context: "customer-app",
2922
+ run_id: "synthetic-run-id",
2923
+ verification_phase: "boot"
2924
+ };
2925
+ const keys = Object.keys(syntheticPayload);
2926
+ const allowed = /* @__PURE__ */ new Set([
2927
+ ...CONTRACT_FAILED_REQUIRED_FIELDS,
2928
+ ...CONTRACT_FAILED_OPTIONAL_FIELDS
2929
+ ]);
2930
+ const forbidden = new Set(CONTRACT_FAILED_FORBIDDEN_FIELDS);
2931
+ for (const required of CONTRACT_FAILED_REQUIRED_FIELDS) {
2932
+ if (!keys.includes(required)) {
2933
+ return fail(
2934
+ "contract-failed-payload-schema-lock",
2935
+ `missing required field: ${required}`,
2936
+ nowMs() - t0
2937
+ );
2938
+ }
2939
+ }
2940
+ for (const k of keys) {
2941
+ if (forbidden.has(k)) {
2942
+ return fail(
2943
+ "contract-failed-payload-schema-lock",
2944
+ `forbidden field on wire: ${k}`,
2945
+ nowMs() - t0
2946
+ );
2947
+ }
2948
+ }
2949
+ for (const k of keys) {
2950
+ if (!allowed.has(k)) {
2951
+ return fail(
2952
+ "contract-failed-payload-schema-lock",
2953
+ `unrecognised field on wire: ${k} (not in required \u222A optional)`,
2954
+ nowMs() - t0
2955
+ );
2956
+ }
2957
+ }
2958
+ return pass(
2959
+ "contract-failed-payload-schema-lock",
2960
+ `${keys.length} fields \u2286 required(${CONTRACT_FAILED_REQUIRED_FIELDS.length}) \u222A optional(${CONTRACT_FAILED_OPTIONAL_FIELDS.length}); ${CONTRACT_FAILED_FORBIDDEN_FIELDS.length} forbidden absent`,
2961
+ nowMs() - t0
2962
+ );
2963
+ }
2964
+ };
2965
+ var STATIC_VERIFIERS = Object.freeze([
2966
+ VERIFIER_PER_USER_CACHE_ISOLATION,
2967
+ VERIFIER_IDEMPOTENCY_KEY_DETERMINISTIC,
2968
+ VERIFIER_ERROR_ENVELOPE_SHAPE,
2969
+ VERIFIER_FLUSH_INTERVAL_PARITY,
2970
+ VERIFIER_SUPER_PROPERTY_MERGE_PRECEDENCE,
2971
+ VERIFIER_CONTRACT_FAILED_PAYLOAD_SCHEMA_LOCK
2972
+ ]);
2973
+ async function runBootSelfTest(verifiers, reporter, ctx) {
2974
+ if (ctx.disableContractAssertions) {
2975
+ return { passed: 0, failed: 0, totalMs: 0 };
2976
+ }
2977
+ const t0 = nowMs();
2978
+ let passed = 0;
2979
+ let failed = 0;
2980
+ if (ctx.logVerifierResults) {
2981
+ const bootTestCount = verifiers.filter((v) => v.bootTest).length;
2982
+ const hookCount = verifiers.filter((v) => v.hooks).length;
2983
+ const ids = verifiers.map((v) => v.contractId).join(", ");
2984
+ ctx.console.info(
2985
+ `[crossdeck] Contract self-verification \u2014 ${verifiers.length} verifiers (${bootTestCount} boot-tests, ${hookCount} hot-path hooks): ${ids}`
2986
+ );
2987
+ }
2988
+ for (const verifier of verifiers) {
2989
+ if (!verifier.bootTest) continue;
2990
+ let result;
2991
+ try {
2992
+ result = await verifier.bootTest();
2993
+ } catch (err) {
2994
+ result = fail(
2995
+ verifier.contractId,
2996
+ `bootTest threw: ${err.message?.slice(0, 80) ?? "unknown"}`,
2997
+ 0
2998
+ );
2999
+ }
3000
+ reporter.report(result, "boot");
3001
+ if (result.ok) passed += 1;
3002
+ else failed += 1;
3003
+ }
3004
+ const totalMs = nowMs() - t0;
3005
+ if (ctx.logVerifierResults) {
3006
+ const verb = failed === 0 ? "passed" : "complete";
3007
+ ctx.console.info(
3008
+ `[crossdeck] Self-verification ${verb} \u2014 ${passed} passed, ${failed} failed (${totalMs}ms)`
3009
+ );
3010
+ }
3011
+ return { passed, failed, totalMs };
3012
+ }
3013
+ function runOnIdentify(verifiers, reporter, ctx, obs) {
3014
+ if (ctx.disableContractAssertions) return;
3015
+ for (const verifier of verifiers) {
3016
+ const hook = verifier.hooks?.onIdentify;
3017
+ if (!hook) continue;
3018
+ let result;
3019
+ try {
3020
+ result = hook(obs);
3021
+ } catch (err) {
3022
+ result = fail(
3023
+ verifier.contractId,
3024
+ `hook threw: ${err.message?.slice(0, 80) ?? "unknown"}`,
3025
+ 0
3026
+ );
3027
+ }
3028
+ reporter.report(result, "hot_path", "identify");
3029
+ }
3030
+ }
3031
+ function runOnTrack(verifiers, reporter, ctx, obs) {
3032
+ if (ctx.disableContractAssertions) return;
3033
+ for (const verifier of verifiers) {
3034
+ const hook = verifier.hooks?.onTrack;
3035
+ if (!hook) continue;
3036
+ let result;
3037
+ try {
3038
+ result = hook(obs);
3039
+ } catch (err) {
3040
+ result = fail(
3041
+ verifier.contractId,
3042
+ `hook threw: ${err.message?.slice(0, 80) ?? "unknown"}`,
3043
+ 0
3044
+ );
3045
+ }
3046
+ reporter.report(result, "hot_path", "track");
3047
+ }
3048
+ }
3049
+ function runOnSyncPurchases(verifiers, reporter, ctx, obs) {
3050
+ if (ctx.disableContractAssertions) return;
3051
+ for (const verifier of verifiers) {
3052
+ const hook = verifier.hooks?.onSyncPurchases;
3053
+ if (!hook) continue;
3054
+ let result;
3055
+ try {
3056
+ result = hook(obs);
3057
+ } catch (err) {
3058
+ result = fail(
3059
+ verifier.contractId,
3060
+ `hook threw: ${err.message?.slice(0, 80) ?? "unknown"}`,
3061
+ 0
3062
+ );
3063
+ }
3064
+ reporter.report(result, "hot_path", "syncPurchases");
3065
+ }
3066
+ }
3067
+ function runOnErrorParse(verifiers, reporter, ctx, obs) {
3068
+ if (ctx.disableContractAssertions) return;
3069
+ for (const verifier of verifiers) {
3070
+ const hook = verifier.hooks?.onErrorParse;
3071
+ if (!hook) continue;
3072
+ let result;
3073
+ try {
3074
+ result = hook(obs);
3075
+ } catch (err) {
3076
+ result = fail(
3077
+ verifier.contractId,
3078
+ `hook threw: ${err.message?.slice(0, 80) ?? "unknown"}`,
3079
+ 0
3080
+ );
3081
+ }
3082
+ reporter.report(result, "hot_path", "errorParse");
3083
+ }
3084
+ }
3085
+ function defaultDebugModeFlag() {
3086
+ try {
3087
+ if (typeof process !== "undefined" && process.env) {
3088
+ const nodeEnv = process.env.NODE_ENV;
3089
+ if (typeof nodeEnv === "string") {
3090
+ return nodeEnv !== "production";
3091
+ }
3092
+ }
3093
+ } catch {
3094
+ }
3095
+ const devFlag = globalThis.__DEV__;
3096
+ if (typeof devFlag === "boolean") return devFlag;
3097
+ return false;
3098
+ }
3099
+ function pass(contractId, evidence, durationMs) {
3100
+ return { ok: true, contractId, evidence, durationMs };
3101
+ }
3102
+ function fail(contractId, failureReason, durationMs) {
3103
+ return { ok: false, contractId, failureReason, durationMs };
3104
+ }
3105
+ function nowMs() {
3106
+ if (typeof performance !== "undefined" && typeof performance.now === "function") {
3107
+ return performance.now();
3108
+ }
3109
+ return Date.now();
3110
+ }
3111
+ function truncate(s, max) {
3112
+ return s.length > max ? s.slice(0, max - 1) + "\u2026" : s;
3113
+ }
3114
+ function shortSuffix(suffix) {
3115
+ const trimmed = suffix.startsWith("_") ? suffix.slice(1) : suffix;
3116
+ return trimmed.length <= 12 ? trimmed : `${trimmed.slice(0, 4)}\u2026${trimmed.slice(-4)}`;
3117
+ }
3118
+ function randomHex(len) {
3119
+ const bytes = new Uint8Array(Math.ceil(len / 2));
3120
+ if (typeof globalThis !== "undefined" && globalThis.crypto?.getRandomValues) {
3121
+ globalThis.crypto.getRandomValues(bytes);
3122
+ } else {
3123
+ for (let i = 0; i < bytes.length; i += 1) {
3124
+ bytes[i] = Math.floor(Math.random() * 256);
3125
+ }
3126
+ }
3127
+ let hex = "";
3128
+ for (let i = 0; i < bytes.length; i += 1) {
3129
+ hex += bytes[i].toString(16).padStart(2, "0");
3130
+ }
3131
+ return hex.slice(0, len);
3132
+ }
3133
+ var MemoryStorage2 = class {
3134
+ constructor() {
3135
+ this.map = /* @__PURE__ */ new Map();
3136
+ }
3137
+ getItem(key) {
3138
+ return this.map.get(key) ?? null;
3139
+ }
3140
+ setItem(key, value) {
3141
+ this.map.set(key, value);
3142
+ }
3143
+ removeItem(key) {
3144
+ this.map.delete(key);
3145
+ }
3146
+ // Iteration support for EntitlementCache's clearAll() path. The
3147
+ // contract this verifier checks doesn't exercise clearAll, but
3148
+ // EntitlementCache's constructor calls hydrate() which reads from
3149
+ // storage — we need the cache to look "empty" until we plant data.
3150
+ keys() {
3151
+ return Array.from(this.map.keys());
3152
+ }
3153
+ };
3154
+ // sha256Hex re-export so the verifier doesn't need to import it
3155
+ // separately (some bundlers tree-shake more aggressively when the
3156
+ // exports are colocated). Inert for the storage adapter itself.
3157
+ MemoryStorage2._ = sha256Hex;
3158
+
2036
3159
  // src/stack-parser.ts
2037
3160
  function parseStack(stack) {
2038
3161
  if (!stack || typeof stack !== "string") return [];
@@ -2723,6 +3846,22 @@ function isSelfRequest(requestUrl, selfHostname) {
2723
3846
  var CrossdeckClient = class {
2724
3847
  constructor() {
2725
3848
  this.state = null;
3849
+ // ────────────────────────────────────────────────────────────────
3850
+ // Contract verifier layer (see sdks/web/src/_contract-verifiers.ts).
3851
+ //
3852
+ // Three flags govern this layer (CrossdeckOptions):
3853
+ // verifyContractsAtBoot — boot self-test on Crossdeck.start
3854
+ // logVerifierResults — print PASS results to console
3855
+ // disableContractAssertions — total kill-switch
3856
+ //
3857
+ // When the kill-switch is set, all three fields stay null and
3858
+ // every hot-path dispatcher short-circuits — zero runtime cost.
3859
+ // Otherwise: populated once at init(), referenced from every
3860
+ // hot-path call site (identify, syncPurchases, ...).
3861
+ // ────────────────────────────────────────────────────────────────
3862
+ this.verifiers = null;
3863
+ this.verifierReporter = null;
3864
+ this.verifierCtx = null;
2726
3865
  }
2727
3866
  /**
2728
3867
  * Boot the SDK. Idempotent — calling init twice with the same options
@@ -2751,6 +3890,10 @@ var CrossdeckClient = class {
2751
3890
  this.state.errors?.uninstall();
2752
3891
  } catch {
2753
3892
  }
3893
+ try {
3894
+ void this.state.events.flush({ keepalive: true });
3895
+ } catch {
3896
+ }
2754
3897
  }
2755
3898
  if (!options.publicKey || !options.publicKey.startsWith("cd_pub_")) {
2756
3899
  throw new CrossdeckError({
@@ -2798,7 +3941,12 @@ var CrossdeckClient = class {
2798
3941
  // load still flushes if the user leaves quickly (the keepalive
2799
3942
  // pagehide handler picks up anything that doesn't); long enough
2800
3943
  // that bursts of clicks coalesce into one network round-trip.
2801
- eventFlushIntervalMs: options.eventFlushIntervalMs ?? 1500,
3944
+ // v1.4.0 Phase 3.3 — flush interval default parity. Pre-
3945
+ // v1.4.0: Web/Node 1500ms, RN/Swift/Android 5000ms. All
3946
+ // converged on 2000ms (the Stripe-adjacent industry norm)
3947
+ // so cross-platform funnels show events landing at the
3948
+ // same cadence on every SDK. Per-instance override stays.
3949
+ eventFlushIntervalMs: options.eventFlushIntervalMs ?? 2e3,
2802
3950
  sdkVersion: options.sdkVersion ?? SDK_VERSION,
2803
3951
  autoTrack,
2804
3952
  appVersion: options.appVersion ?? null
@@ -2813,7 +3961,22 @@ var CrossdeckClient = class {
2813
3961
  // to a successful no-op response when localDevMode is set.
2814
3962
  // SDK methods continue to work locally; nothing reaches the
2815
3963
  // server.
2816
- localDevMode
3964
+ localDevMode,
3965
+ // Contract verifier hot-path hook — fires the `error-envelope-shape`
3966
+ // verifier on every parsed wire error, BEFORE the throw bubbles
3967
+ // back to user code. Centralised here so we don't need a try/catch
3968
+ // around every `await s.http.request(...)` call site downstream.
3969
+ // No-op when the verifier layer is disabled (verifiers === null).
3970
+ onErrorParsed: (err) => {
3971
+ if (this.verifiers && this.verifierReporter && this.verifierCtx) {
3972
+ runOnErrorParse(this.verifiers, this.verifierReporter, this.verifierCtx, {
3973
+ errorType: err.type,
3974
+ errorCode: err.code,
3975
+ requestId: err.requestId ?? null,
3976
+ httpStatus: typeof err.status === "number" ? err.status : 0
3977
+ });
3978
+ }
3979
+ }
2817
3980
  });
2818
3981
  if (localDevMode) {
2819
3982
  console.log(
@@ -2958,6 +4121,89 @@ var CrossdeckClient = class {
2958
4121
  if (opts.autoHeartbeat && !localDevMode) {
2959
4122
  void this.heartbeat().catch(() => void 0);
2960
4123
  }
4124
+ if (options.disableContractAssertions !== true) {
4125
+ const debugDefault = defaultDebugModeFlag();
4126
+ this.verifierCtx = buildVerifierContext({
4127
+ logVerifierResults: options.logVerifierResults ?? debugDefault,
4128
+ disableContractAssertions: false,
4129
+ // Best-effort run-context detection. Customers running the
4130
+ // Web SDK inside their own CI (Playwright, Cypress, etc.)
4131
+ // typically set CI=true; we use that as the signal. Otherwise
4132
+ // we assume `customer-app` — the SDK is running inside a
4133
+ // customer's deployed site.
4134
+ runContext: typeof process !== "undefined" && process.env && process.env.CI ? "ci" : "customer-app"
4135
+ });
4136
+ this.verifierReporter = new VerifierReporter(this.verifierCtx);
4137
+ const flushVerifier = buildFlushIntervalVerifier(opts.eventFlushIntervalMs);
4138
+ this.verifiers = Object.freeze([...STATIC_VERIFIERS, flushVerifier]);
4139
+ if (localDevMode) {
4140
+ const verifyAtBootLocal = options.verifyContractsAtBoot ?? debugDefault;
4141
+ if (verifyAtBootLocal && this.verifierReporter) {
4142
+ void runBootSelfTest(
4143
+ this.verifiers,
4144
+ this.verifierReporter,
4145
+ this.verifierCtx
4146
+ ).catch(() => void 0);
4147
+ }
4148
+ } else {
4149
+ void this.bootstrapVerifierLayerRemote(options, debugDefault).catch(
4150
+ () => void 0
4151
+ );
4152
+ }
4153
+ }
4154
+ return;
4155
+ }
4156
+ /**
4157
+ * Fetch /v1/config from the backend and apply any per-app verifier
4158
+ * overrides set in the dashboard, then fire the boot self-test
4159
+ * once with the FINAL resolved flags.
4160
+ *
4161
+ * Precedence (also documented at the call site in init()):
4162
+ * code option > dashboard remote config > DEBUG/RELEASE default
4163
+ *
4164
+ * Code wins so engineers retain ultimate control; dashboard is the
4165
+ * no-deploy operational lever for QA / staging soaks.
4166
+ *
4167
+ * Never throws — a /v1/config failure surfaces as "stick with the
4168
+ * synchronous defaults that init() already applied" and the boot
4169
+ * self-test runs only if code > default resolves true.
4170
+ */
4171
+ async bootstrapVerifierLayerRemote(options, debugDefault) {
4172
+ if (!this.state || !this.verifiers || !this.verifierCtx) return;
4173
+ let remote = {
4174
+ logVerifierResults: null,
4175
+ verifyContractsAtBoot: null
4176
+ };
4177
+ try {
4178
+ const resp = await this.state.http.request(
4179
+ "GET",
4180
+ "/config"
4181
+ );
4182
+ if (resp && resp.verifierConfig) {
4183
+ const r = resp.verifierConfig;
4184
+ remote = {
4185
+ logVerifierResults: typeof r.logVerifierResults === "boolean" ? r.logVerifierResults : null,
4186
+ verifyContractsAtBoot: typeof r.verifyContractsAtBoot === "boolean" ? r.verifyContractsAtBoot : null
4187
+ };
4188
+ }
4189
+ } catch {
4190
+ }
4191
+ const logResults = options.logVerifierResults ?? (remote.logVerifierResults ?? debugDefault);
4192
+ const verifyAtBoot = options.verifyContractsAtBoot ?? (remote.verifyContractsAtBoot ?? debugDefault);
4193
+ if (logResults !== this.verifierCtx.logVerifierResults) {
4194
+ this.verifierCtx = {
4195
+ ...this.verifierCtx,
4196
+ logVerifierResults: logResults
4197
+ };
4198
+ this.verifierReporter = new VerifierReporter(this.verifierCtx);
4199
+ }
4200
+ if (verifyAtBoot && this.verifierReporter) {
4201
+ await runBootSelfTest(
4202
+ this.verifiers,
4203
+ this.verifierReporter,
4204
+ this.verifierCtx
4205
+ );
4206
+ }
2961
4207
  }
2962
4208
  /**
2963
4209
  * @deprecated Use `init()` instead. NorthStar §4 standardised the
@@ -3026,14 +4272,18 @@ var CrossdeckClient = class {
3026
4272
  };
3027
4273
  if (options?.email) body.email = options.email;
3028
4274
  if (traits) body.traits = traits;
4275
+ const priorUserId = s.developerUserId;
4276
+ s.entitlements.setUserKey(userId);
4277
+ if (this.verifiers && this.verifierReporter && this.verifierCtx) {
4278
+ runOnIdentify(this.verifiers, this.verifierReporter, this.verifierCtx, {
4279
+ priorUserId,
4280
+ nextUserId: userId,
4281
+ cache: s.entitlements
4282
+ });
4283
+ }
3029
4284
  const result = await s.http.request("POST", "/identity/alias", {
3030
4285
  body
3031
4286
  });
3032
- const priorCdcust = s.identity.crossdeckCustomerId;
3033
- const cacheHasEntries = s.entitlements.list().length > 0;
3034
- if (priorCdcust && result.crossdeckCustomerId && priorCdcust !== result.crossdeckCustomerId || !priorCdcust && cacheHasEntries) {
3035
- s.entitlements.clear();
3036
- }
3037
4287
  s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
3038
4288
  s.developerUserId = userId;
3039
4289
  return result;
@@ -3338,6 +4588,38 @@ var CrossdeckClient = class {
3338
4588
  * trip happens in the background. To flush before the page unloads,
3339
4589
  * call flush().
3340
4590
  */
4591
+ /**
4592
+ * Emit `crossdeck.contract_failed` to the Crossdeck reliability
4593
+ * endpoint — single-fire, one-way, never visible in the customer's
4594
+ * dashboard. Goes over a dedicated HTTP path with the reliability
4595
+ * publishable key embedded at build time; the customer's track()
4596
+ * pipeline never carries `crossdeck.*` events. This is the
4597
+ * independent-controller flow described in Privacy Policy §6
4598
+ * ("Flow B"). The wire shape is fixed by the schema-lock contract
4599
+ * at `contracts/diagnostics/contract-failed-payload-schema-lock.json`.
4600
+ *
4601
+ * Wire the call from a test hook, dogfood failure path, or
4602
+ * customer contract-verification harness; see
4603
+ * `contracts/README.md` for the per-test-framework hook recipes.
4604
+ */
4605
+ reportContractFailure(input) {
4606
+ const payload = {
4607
+ contract_id: input.contractId,
4608
+ sdk_version: SDK_VERSION,
4609
+ sdk_platform: "web",
4610
+ failure_reason: input.failureReason,
4611
+ run_context: input.runContext,
4612
+ run_id: input.runId
4613
+ };
4614
+ if (input.testRef) {
4615
+ payload.test_file = input.testRef.file;
4616
+ payload.test_name = input.testRef.name;
4617
+ }
4618
+ if (input.deviceClass) {
4619
+ payload.device_class = input.deviceClass;
4620
+ }
4621
+ sendDiagnosticTelemetry(payload);
4622
+ }
3341
4623
  track(name, properties) {
3342
4624
  const s = this.requireStarted();
3343
4625
  if (!name) {
@@ -3425,6 +4707,14 @@ var CrossdeckClient = class {
3425
4707
  };
3426
4708
  Object.assign(event, this.identityHintForEvent());
3427
4709
  s.events.enqueue(event);
4710
+ if (this.verifiers && this.verifierReporter && this.verifierCtx) {
4711
+ runOnTrack(this.verifiers, this.verifierReporter, this.verifierCtx, {
4712
+ callerProperties: validation.properties,
4713
+ superProperties: supers,
4714
+ deviceProperties: s.deviceInfo,
4715
+ mergedProperties: finalProperties
4716
+ });
4717
+ }
3428
4718
  if (!isError && !isWebVital) {
3429
4719
  const category = name.startsWith("page.") ? "navigation" : name.startsWith("element.") || name === "session.started" ? "ui.click" : "custom";
3430
4720
  s.breadcrumbs.add({
@@ -3474,11 +4764,37 @@ var CrossdeckClient = class {
3474
4764
  });
3475
4765
  }
3476
4766
  const rail = input.rail ?? "apple";
4767
+ const body = { ...input, rail };
4768
+ const idempotencyKey = deriveIdempotencyKeyForPurchase(body);
4769
+ if (this.verifiers && this.verifierReporter && this.verifierCtx) {
4770
+ const stableId = rail === "apple" ? body.signedTransactionInfo ?? "" : body.purchaseToken ?? "";
4771
+ runOnSyncPurchases(
4772
+ this.verifiers,
4773
+ this.verifierReporter,
4774
+ this.verifierCtx,
4775
+ {
4776
+ rail,
4777
+ stableIdentifier: stableId,
4778
+ derivedKey: idempotencyKey
4779
+ }
4780
+ );
4781
+ }
3477
4782
  const result = await s.http.request("POST", "/purchases/sync", {
3478
- body: { ...input, rail }
4783
+ body,
4784
+ idempotencyKey
3479
4785
  });
3480
4786
  s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
3481
4787
  s.entitlements.setFromList(result.entitlements);
4788
+ try {
4789
+ const sourceProductId = result.entitlements[0]?.source.productId;
4790
+ const sourceSubscriptionId = result.entitlements[0]?.source.subscriptionId;
4791
+ const props = { rail };
4792
+ if (sourceProductId) props.productId = sourceProductId;
4793
+ if (sourceSubscriptionId) props.subscriptionId = sourceSubscriptionId;
4794
+ if (result.idempotent_replay) props.idempotent_replay = true;
4795
+ this.track("purchase.completed", props);
4796
+ } catch {
4797
+ }
3482
4798
  s.debug.emit(
3483
4799
  "sdk.purchase_evidence_sent",
3484
4800
  "StoreKit transaction forwarded. Waiting for backend verification.",
@@ -3534,7 +4850,7 @@ var CrossdeckClient = class {
3534
4850
  }
3535
4851
  this.state.autoTracker?.uninstall();
3536
4852
  this.state.identity.reset();
3537
- this.state.entitlements.clear();
4853
+ this.state.entitlements.clearAll();
3538
4854
  this.state.events.reset();
3539
4855
  this.state.superProps.clear();
3540
4856
  this.state.breadcrumbs.clear();