@cross-deck/web 1.3.1 → 1.4.2

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.cjs CHANGED
@@ -93,7 +93,7 @@ function typeMapForStatus(status) {
93
93
  }
94
94
 
95
95
  // src/_version.ts
96
- var SDK_VERSION = "1.3.0";
96
+ var SDK_VERSION = "1.4.2";
97
97
  var SDK_NAME = "@cross-deck/web";
98
98
 
99
99
  // src/http.ts
@@ -343,29 +343,258 @@ function randomChars(count) {
343
343
  return out.join("");
344
344
  }
345
345
 
346
+ // src/hash.ts
347
+ var K = new Uint32Array([
348
+ 1116352408,
349
+ 1899447441,
350
+ 3049323471,
351
+ 3921009573,
352
+ 961987163,
353
+ 1508970993,
354
+ 2453635748,
355
+ 2870763221,
356
+ 3624381080,
357
+ 310598401,
358
+ 607225278,
359
+ 1426881987,
360
+ 1925078388,
361
+ 2162078206,
362
+ 2614888103,
363
+ 3248222580,
364
+ 3835390401,
365
+ 4022224774,
366
+ 264347078,
367
+ 604807628,
368
+ 770255983,
369
+ 1249150122,
370
+ 1555081692,
371
+ 1996064986,
372
+ 2554220882,
373
+ 2821834349,
374
+ 2952996808,
375
+ 3210313671,
376
+ 3336571891,
377
+ 3584528711,
378
+ 113926993,
379
+ 338241895,
380
+ 666307205,
381
+ 773529912,
382
+ 1294757372,
383
+ 1396182291,
384
+ 1695183700,
385
+ 1986661051,
386
+ 2177026350,
387
+ 2456956037,
388
+ 2730485921,
389
+ 2820302411,
390
+ 3259730800,
391
+ 3345764771,
392
+ 3516065817,
393
+ 3600352804,
394
+ 4094571909,
395
+ 275423344,
396
+ 430227734,
397
+ 506948616,
398
+ 659060556,
399
+ 883997877,
400
+ 958139571,
401
+ 1322822218,
402
+ 1537002063,
403
+ 1747873779,
404
+ 1955562222,
405
+ 2024104815,
406
+ 2227730452,
407
+ 2361852424,
408
+ 2428436474,
409
+ 2756734187,
410
+ 3204031479,
411
+ 3329325298
412
+ ]);
413
+ function utf8Bytes(input) {
414
+ if (typeof TextEncoder !== "undefined") {
415
+ return new TextEncoder().encode(input);
416
+ }
417
+ const out = [];
418
+ for (let i = 0; i < input.length; i++) {
419
+ let codePoint = input.charCodeAt(i);
420
+ if (codePoint >= 55296 && codePoint <= 56319 && i + 1 < input.length) {
421
+ const next = input.charCodeAt(i + 1);
422
+ if (next >= 56320 && next <= 57343) {
423
+ codePoint = 65536 + (codePoint - 55296 << 10) + (next - 56320);
424
+ i++;
425
+ }
426
+ }
427
+ if (codePoint < 128) {
428
+ out.push(codePoint);
429
+ } else if (codePoint < 2048) {
430
+ out.push(192 | codePoint >> 6);
431
+ out.push(128 | codePoint & 63);
432
+ } else if (codePoint < 65536) {
433
+ out.push(224 | codePoint >> 12);
434
+ out.push(128 | codePoint >> 6 & 63);
435
+ out.push(128 | codePoint & 63);
436
+ } else {
437
+ out.push(240 | codePoint >> 18);
438
+ out.push(128 | codePoint >> 12 & 63);
439
+ out.push(128 | codePoint >> 6 & 63);
440
+ out.push(128 | codePoint & 63);
441
+ }
442
+ }
443
+ return new Uint8Array(out);
444
+ }
445
+ function sha256Hex(input) {
446
+ const bytes = utf8Bytes(input);
447
+ const bitLength = bytes.length * 8;
448
+ const blockCount = Math.floor((bytes.length + 9 + 63) / 64);
449
+ const padded = new Uint8Array(blockCount * 64);
450
+ padded.set(bytes);
451
+ padded[bytes.length] = 128;
452
+ const high = Math.floor(bitLength / 4294967296);
453
+ const low = bitLength >>> 0;
454
+ const lenOffset = padded.length - 8;
455
+ padded[lenOffset + 0] = high >>> 24 & 255;
456
+ padded[lenOffset + 1] = high >>> 16 & 255;
457
+ padded[lenOffset + 2] = high >>> 8 & 255;
458
+ padded[lenOffset + 3] = high & 255;
459
+ padded[lenOffset + 4] = low >>> 24 & 255;
460
+ padded[lenOffset + 5] = low >>> 16 & 255;
461
+ padded[lenOffset + 6] = low >>> 8 & 255;
462
+ padded[lenOffset + 7] = low & 255;
463
+ const H = new Uint32Array([
464
+ 1779033703,
465
+ 3144134277,
466
+ 1013904242,
467
+ 2773480762,
468
+ 1359893119,
469
+ 2600822924,
470
+ 528734635,
471
+ 1541459225
472
+ ]);
473
+ const W = new Uint32Array(64);
474
+ for (let block = 0; block < blockCount; block++) {
475
+ const offset = block * 64;
476
+ for (let t = 0; t < 16; t++) {
477
+ 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;
478
+ }
479
+ for (let t = 16; t < 64; t++) {
480
+ const w15 = W[t - 15];
481
+ const w2 = W[t - 2];
482
+ const s0 = (w15 >>> 7 | w15 << 25) ^ (w15 >>> 18 | w15 << 14) ^ w15 >>> 3;
483
+ const s1 = (w2 >>> 17 | w2 << 15) ^ (w2 >>> 19 | w2 << 13) ^ w2 >>> 10;
484
+ W[t] = W[t - 16] + s0 + W[t - 7] + s1 >>> 0;
485
+ }
486
+ let a = H[0], b = H[1], c = H[2], d = H[3];
487
+ let e = H[4], f = H[5], g = H[6], h = H[7];
488
+ for (let t = 0; t < 64; t++) {
489
+ const S1 = (e >>> 6 | e << 26) ^ (e >>> 11 | e << 21) ^ (e >>> 25 | e << 7);
490
+ const ch = e & f ^ ~e & g;
491
+ const temp1 = h + S1 + ch + K[t] + W[t] >>> 0;
492
+ const S0 = (a >>> 2 | a << 30) ^ (a >>> 13 | a << 19) ^ (a >>> 22 | a << 10);
493
+ const maj = a & b ^ a & c ^ b & c;
494
+ const temp2 = S0 + maj >>> 0;
495
+ h = g;
496
+ g = f;
497
+ f = e;
498
+ e = d + temp1 >>> 0;
499
+ d = c;
500
+ c = b;
501
+ b = a;
502
+ a = temp1 + temp2 >>> 0;
503
+ }
504
+ H[0] = H[0] + a >>> 0;
505
+ H[1] = H[1] + b >>> 0;
506
+ H[2] = H[2] + c >>> 0;
507
+ H[3] = H[3] + d >>> 0;
508
+ H[4] = H[4] + e >>> 0;
509
+ H[5] = H[5] + f >>> 0;
510
+ H[6] = H[6] + g >>> 0;
511
+ H[7] = H[7] + h >>> 0;
512
+ }
513
+ let hex = "";
514
+ for (let i = 0; i < 8; i++) {
515
+ hex += H[i].toString(16).padStart(8, "0");
516
+ }
517
+ return hex;
518
+ }
519
+
346
520
  // src/entitlement-cache.ts
347
521
  var DEFAULT_STALE_AFTER_MS = 24 * 60 * 60 * 1e3;
348
- var EntitlementCache = class {
522
+ var ANON_SUFFIX = "_anon";
523
+ var INDEX_SUFFIX = "_index";
524
+ var EntitlementCache = class _EntitlementCache {
349
525
  /**
350
- * @param storage Device storage adapter. When omitted (tests) or
351
- * a MemoryStorage (strict-consent / no-persistence
352
- * mode) the cache is session-only — durability is
353
- * simply absent, never wrong.
354
- * @param storageKey Full key the persisted blob lives under.
355
- * @param staleAfterMs Age past which last-known-good is flagged stale
356
- * even without a failed refresh. Default 24h.
526
+ * @param storage Device storage adapter. When omitted (tests) or
527
+ * a MemoryStorage (strict-consent / no-persistence
528
+ * mode) the cache is session-only — durability is
529
+ * simply absent, never wrong.
530
+ * @param storageKeyPrefix Prefix used to derive per-user storage keys
531
+ * (`<prefix>:<sha256(userId)>`). Default
532
+ * `crossdeck:entitlements`. The trailing user
533
+ * suffix is filled at identify() / reset()
534
+ * time — see [[setUserKey]].
535
+ * @param staleAfterMs Age past which last-known-good is flagged stale
536
+ * even without a failed refresh. Default 24h.
357
537
  */
358
- constructor(storage, storageKey = "crossdeck:entitlements", staleAfterMs = DEFAULT_STALE_AFTER_MS) {
538
+ constructor(storage, storageKeyPrefix = "crossdeck:entitlements", staleAfterMs = DEFAULT_STALE_AFTER_MS) {
359
539
  this.all = [];
360
540
  this.lastUpdated = 0;
361
541
  this.lastRefreshFailedAt = 0;
362
542
  this.listeners = /* @__PURE__ */ new Set();
363
543
  this.listenerErrorCount = 0;
544
+ this.currentSuffix = ANON_SUFFIX;
364
545
  this.storage = storage;
365
- this.storageKey = storageKey;
546
+ this.storageKeyPrefix = storageKeyPrefix;
366
547
  this.staleAfterMs = staleAfterMs;
367
548
  this.hydrate();
368
549
  }
550
+ /** The full storage key the current-user blob is persisted under. */
551
+ get storageKey() {
552
+ return `${this.storageKeyPrefix}:${this.currentSuffix}`;
553
+ }
554
+ /** Key of the index blob — a JSON array of every suffix we've
555
+ * written. Used by clearAll() to scope a logout-wipe. */
556
+ get indexKey() {
557
+ return `${this.storageKeyPrefix}:${INDEX_SUFFIX}`;
558
+ }
559
+ /** Derive a stable suffix for a developerUserId via SHA-256. The
560
+ * raw userId never lands in the storage key — protects against
561
+ * accidentally leaking email-style identifiers through DevTools
562
+ * inspection. Pass `null` to switch back to the anonymous slot. */
563
+ static suffixForUserId(userId) {
564
+ if (userId == null || userId === "") return ANON_SUFFIX;
565
+ return sha256Hex(userId);
566
+ }
567
+ /**
568
+ * Switch the cache to a different user's storage slot. Bank-grade
569
+ * three-layer isolation:
570
+ * (a) Physical key separation — `<prefix>:<sha256(userId)>` so
571
+ * a user-switch can't physically read prior user's data
572
+ * even if the in-memory clear was skipped.
573
+ * (b) Unconditional in-memory clear — invoked whenever the
574
+ * active suffix changes, even on same-id re-identify.
575
+ * (c) Re-hydrate from the new slot — a returning user observes
576
+ * their last-known-good cache from storage immediately.
577
+ *
578
+ * Caller (identify() / reset()) MUST invoke this BEFORE the next
579
+ * setFromList() so the write lands under the right key.
580
+ */
581
+ setUserKey(userId) {
582
+ const nextSuffix = _EntitlementCache.suffixForUserId(userId);
583
+ if (nextSuffix === this.currentSuffix) {
584
+ this.all = [];
585
+ this.lastUpdated = 0;
586
+ this.lastRefreshFailedAt = 0;
587
+ this.notify();
588
+ this.hydrate();
589
+ return;
590
+ }
591
+ this.currentSuffix = nextSuffix;
592
+ this.all = [];
593
+ this.lastUpdated = 0;
594
+ this.lastRefreshFailedAt = 0;
595
+ this.hydrate();
596
+ this.notify();
597
+ }
369
598
  /**
370
599
  * Sync read — true iff the entitlement is currently granting access.
371
600
  *
@@ -435,12 +664,13 @@ var EntitlementCache = class {
435
664
  this.lastUpdated = Date.now();
436
665
  this.lastRefreshFailedAt = 0;
437
666
  this.persist();
667
+ this.recordSuffixInIndex(this.currentSuffix);
438
668
  this.notify();
439
669
  }
440
670
  /**
441
- * Wipe used on reset() (logout) and on an identity switch. Clears
442
- * BOTH memory and durable storage so a prior user's entitlements can
443
- * never leak to the next person on this device.
671
+ * Wipe the CURRENT user's slot. Used internally when a single
672
+ * user's cache needs to be invalidated without affecting other
673
+ * persisted slots. The full-logout path is [[clearAll]].
444
674
  */
445
675
  clear() {
446
676
  this.all = [];
@@ -452,6 +682,40 @@ var EntitlementCache = class {
452
682
  } catch {
453
683
  }
454
684
  }
685
+ this.removeSuffixFromIndex(this.currentSuffix);
686
+ this.notify();
687
+ }
688
+ /**
689
+ * Logout-grade wipe — bank-grade contract: removes EVERY per-user
690
+ * entitlement slot the SDK has ever written on this device, then
691
+ * clears the index. Used by `Crossdeck.reset()` so a logout on a
692
+ * shared device can never leave another user's entitlements
693
+ * readable (layer (c) of the v1.4.0 isolation fix).
694
+ *
695
+ * After clearAll(), the cache is back to anonymous + empty.
696
+ */
697
+ clearAll() {
698
+ this.all = [];
699
+ this.lastUpdated = 0;
700
+ this.lastRefreshFailedAt = 0;
701
+ this.currentSuffix = ANON_SUFFIX;
702
+ if (this.storage) {
703
+ const suffixes = this.readIndex();
704
+ for (const suffix of suffixes) {
705
+ try {
706
+ this.storage.removeItem(`${this.storageKeyPrefix}:${suffix}`);
707
+ } catch {
708
+ }
709
+ }
710
+ try {
711
+ this.storage.removeItem(`${this.storageKeyPrefix}:${ANON_SUFFIX}`);
712
+ } catch {
713
+ }
714
+ try {
715
+ this.storage.removeItem(this.indexKey);
716
+ } catch {
717
+ }
718
+ }
455
719
  this.notify();
456
720
  }
457
721
  /**
@@ -501,6 +765,47 @@ var EntitlementCache = class {
501
765
  } catch {
502
766
  }
503
767
  }
768
+ /** Read the index of all per-user suffixes the SDK has written. */
769
+ readIndex() {
770
+ if (!this.storage) return [];
771
+ try {
772
+ const raw = this.storage.getItem(this.indexKey);
773
+ if (!raw) return [];
774
+ const parsed = JSON.parse(raw);
775
+ if (Array.isArray(parsed)) {
776
+ return parsed.filter((x) => typeof x === "string");
777
+ }
778
+ return [];
779
+ } catch {
780
+ return [];
781
+ }
782
+ }
783
+ /** Add a suffix to the persisted index. Idempotent. */
784
+ recordSuffixInIndex(suffix) {
785
+ if (!this.storage) return;
786
+ const existing = this.readIndex();
787
+ if (existing.includes(suffix)) return;
788
+ existing.push(suffix);
789
+ try {
790
+ this.storage.setItem(this.indexKey, JSON.stringify(existing));
791
+ } catch {
792
+ }
793
+ }
794
+ /** Remove a suffix from the persisted index. No-op if absent. */
795
+ removeSuffixFromIndex(suffix) {
796
+ if (!this.storage) return;
797
+ const existing = this.readIndex();
798
+ const next = existing.filter((s) => s !== suffix);
799
+ if (next.length === existing.length) return;
800
+ try {
801
+ if (next.length === 0) {
802
+ this.storage.removeItem(this.indexKey);
803
+ } else {
804
+ this.storage.setItem(this.indexKey, JSON.stringify(next));
805
+ }
806
+ } catch {
807
+ }
808
+ }
504
809
  notify() {
505
810
  if (this.listeners.size === 0) return;
506
811
  const snapshot = this.all.slice();
@@ -515,6 +820,34 @@ var EntitlementCache = class {
515
820
  }
516
821
  };
517
822
 
823
+ // src/idempotency-key.ts
824
+ function formatAsUuid(hex) {
825
+ return [
826
+ hex.slice(0, 8),
827
+ hex.slice(8, 12),
828
+ hex.slice(12, 16),
829
+ hex.slice(16, 20),
830
+ hex.slice(20, 32)
831
+ ].join("-");
832
+ }
833
+ function deriveIdempotencyKeyForPurchase(body) {
834
+ let identifier;
835
+ if (body.rail === "apple") {
836
+ identifier = body.signedTransactionInfo ?? "";
837
+ } else if (body.rail === "google") {
838
+ identifier = body.purchaseToken ?? "";
839
+ } else {
840
+ identifier = "";
841
+ }
842
+ if (!identifier) {
843
+ throw new Error(
844
+ `deriveIdempotencyKeyForPurchase: no stable identifier in body (rail=${body.rail}). Apple needs signedTransactionInfo; Google needs purchaseToken.`
845
+ );
846
+ }
847
+ const namespaced = `crossdeck:purchases/sync:${body.rail}:${identifier}`;
848
+ return formatAsUuid(sha256Hex(namespaced));
849
+ }
850
+
518
851
  // src/retry-policy.ts
519
852
  var DEFAULT_BASE = 1e3;
520
853
  var DEFAULT_MAX = 6e4;
@@ -2777,6 +3110,10 @@ var CrossdeckClient = class {
2777
3110
  this.state.errors?.uninstall();
2778
3111
  } catch {
2779
3112
  }
3113
+ try {
3114
+ void this.state.events.flush({ keepalive: true });
3115
+ } catch {
3116
+ }
2780
3117
  }
2781
3118
  if (!options.publicKey || !options.publicKey.startsWith("cd_pub_")) {
2782
3119
  throw new CrossdeckError({
@@ -2824,7 +3161,12 @@ var CrossdeckClient = class {
2824
3161
  // load still flushes if the user leaves quickly (the keepalive
2825
3162
  // pagehide handler picks up anything that doesn't); long enough
2826
3163
  // that bursts of clicks coalesce into one network round-trip.
2827
- eventFlushIntervalMs: options.eventFlushIntervalMs ?? 1500,
3164
+ // v1.4.0 Phase 3.3 — flush interval default parity. Pre-
3165
+ // v1.4.0: Web/Node 1500ms, RN/Swift/Android 5000ms. All
3166
+ // converged on 2000ms (the Stripe-adjacent industry norm)
3167
+ // so cross-platform funnels show events landing at the
3168
+ // same cadence on every SDK. Per-instance override stays.
3169
+ eventFlushIntervalMs: options.eventFlushIntervalMs ?? 2e3,
2828
3170
  sdkVersion: options.sdkVersion ?? SDK_VERSION,
2829
3171
  autoTrack,
2830
3172
  appVersion: options.appVersion ?? null
@@ -3052,14 +3394,10 @@ var CrossdeckClient = class {
3052
3394
  };
3053
3395
  if (options?.email) body.email = options.email;
3054
3396
  if (traits) body.traits = traits;
3397
+ s.entitlements.setUserKey(userId);
3055
3398
  const result = await s.http.request("POST", "/identity/alias", {
3056
3399
  body
3057
3400
  });
3058
- const priorCdcust = s.identity.crossdeckCustomerId;
3059
- const cacheHasEntries = s.entitlements.list().length > 0;
3060
- if (priorCdcust && result.crossdeckCustomerId && priorCdcust !== result.crossdeckCustomerId || !priorCdcust && cacheHasEntries) {
3061
- s.entitlements.clear();
3062
- }
3063
3401
  s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
3064
3402
  s.developerUserId = userId;
3065
3403
  return result;
@@ -3500,11 +3838,24 @@ var CrossdeckClient = class {
3500
3838
  });
3501
3839
  }
3502
3840
  const rail = input.rail ?? "apple";
3841
+ const body = { ...input, rail };
3842
+ const idempotencyKey = deriveIdempotencyKeyForPurchase(body);
3503
3843
  const result = await s.http.request("POST", "/purchases/sync", {
3504
- body: { ...input, rail }
3844
+ body,
3845
+ idempotencyKey
3505
3846
  });
3506
3847
  s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
3507
3848
  s.entitlements.setFromList(result.entitlements);
3849
+ try {
3850
+ const sourceProductId = result.entitlements[0]?.source.productId;
3851
+ const sourceSubscriptionId = result.entitlements[0]?.source.subscriptionId;
3852
+ const props = { rail };
3853
+ if (sourceProductId) props.productId = sourceProductId;
3854
+ if (sourceSubscriptionId) props.subscriptionId = sourceSubscriptionId;
3855
+ if (result.idempotent_replay) props.idempotent_replay = true;
3856
+ this.track("purchase.completed", props);
3857
+ } catch {
3858
+ }
3508
3859
  s.debug.emit(
3509
3860
  "sdk.purchase_evidence_sent",
3510
3861
  "StoreKit transaction forwarded. Waiting for backend verification.",
@@ -3560,7 +3911,7 @@ var CrossdeckClient = class {
3560
3911
  }
3561
3912
  this.state.autoTracker?.uninstall();
3562
3913
  this.state.identity.reset();
3563
- this.state.entitlements.clear();
3914
+ this.state.entitlements.clearAll();
3564
3915
  this.state.events.reset();
3565
3916
  this.state.superProps.clear();
3566
3917
  this.state.breadcrumbs.clear();