@cross-deck/web 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -46,7 +46,7 @@ function typeMapForStatus(status) {
46
46
 
47
47
  // src/http.ts
48
48
  var SDK_NAME = "@cross-deck/web";
49
- var SDK_VERSION = "0.2.0";
49
+ var SDK_VERSION = "0.3.0";
50
50
  var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
51
51
  var HttpClient = class {
52
52
  constructor(config) {
@@ -200,6 +200,7 @@ var EntitlementCache = class {
200
200
  this.active = /* @__PURE__ */ new Set();
201
201
  this.all = [];
202
202
  this.lastUpdated = 0;
203
+ this.listeners = /* @__PURE__ */ new Set();
203
204
  }
204
205
  /** Sync read — true iff the entitlement key is currently active. */
205
206
  isEntitled(key) {
@@ -217,20 +218,57 @@ var EntitlementCache = class {
217
218
  * Replace the cache with a fresh server response. The backend already
218
219
  * filters to active + env-matching, so we don't re-filter — just trust
219
220
  * what we got.
221
+ *
222
+ * Fires listeners AFTER the mutation so each listener sees the new state.
220
223
  */
221
224
  setFromList(entitlements) {
222
225
  this.all = entitlements.slice();
223
226
  this.active = new Set(entitlements.filter((e) => e.isActive).map((e) => e.key));
224
227
  this.lastUpdated = Date.now();
228
+ this.notify();
225
229
  }
226
230
  /**
227
231
  * Wipe — used on reset() (logout). The SDK forgets everything until
228
232
  * the next identify + read.
233
+ *
234
+ * Fires listeners so React/SwiftUI/etc bindings re-render to the
235
+ * logged-out state immediately.
229
236
  */
230
237
  clear() {
231
238
  this.active.clear();
232
239
  this.all = [];
233
240
  this.lastUpdated = 0;
241
+ this.notify();
242
+ }
243
+ /**
244
+ * Subscribe to cache mutations. Returns an unsubscribe function.
245
+ *
246
+ * The listener is invoked AFTER setFromList() or clear() with the
247
+ * current snapshot. Throwing inside a listener is non-fatal — the
248
+ * error is swallowed and subsequent listeners still run.
249
+ *
250
+ * Used by `@cross-deck/web/react`'s `useEntitlement` hook to
251
+ * trigger re-renders when entitlements change.
252
+ */
253
+ subscribe(listener) {
254
+ this.listeners.add(listener);
255
+ let unsubscribed = false;
256
+ return () => {
257
+ if (unsubscribed) return;
258
+ unsubscribed = true;
259
+ this.listeners.delete(listener);
260
+ };
261
+ }
262
+ notify() {
263
+ if (this.listeners.size === 0) return;
264
+ const snapshot = this.all.slice();
265
+ const listenersSnapshot = [...this.listeners];
266
+ for (const listener of listenersSnapshot) {
267
+ try {
268
+ listener(snapshot);
269
+ } catch {
270
+ }
271
+ }
234
272
  }
235
273
  };
236
274
 
@@ -245,6 +283,7 @@ var EventQueue = class {
245
283
  this.lastFlushAt = 0;
246
284
  this.lastError = null;
247
285
  this.cancelTimer = null;
286
+ this.firstFlushFired = false;
248
287
  }
249
288
  enqueue(event) {
250
289
  this.buffer.push(event);
@@ -271,12 +310,25 @@ var EventQueue = class {
271
310
  const batch = this.buffer.splice(0);
272
311
  this.inFlight += batch.length;
273
312
  try {
313
+ const env = this.cfg.envelope();
274
314
  const result = await this.cfg.http.request("POST", "/events", {
275
- body: { events: batch }
315
+ body: {
316
+ // NorthStar §13.1 batch envelope. The backend validates these
317
+ // against the API-key-resolved app and rejects mismatches loudly
318
+ // (env_mismatch).
319
+ appId: env.appId,
320
+ environment: env.environment,
321
+ sdk: env.sdk,
322
+ events: batch
323
+ }
276
324
  });
277
325
  this.lastFlushAt = Date.now();
278
326
  this.lastError = null;
279
327
  this.inFlight -= batch.length;
328
+ if (!this.firstFlushFired) {
329
+ this.firstFlushFired = true;
330
+ this.cfg.onFirstFlushSuccess?.();
331
+ }
280
332
  return result;
281
333
  } catch (err) {
282
334
  this.buffer.unshift(...batch);
@@ -595,29 +647,104 @@ function mintSessionId() {
595
647
  return `sess_${ts}${randomChars(10)}`;
596
648
  }
597
649
 
650
+ // src/debug.ts
651
+ var SENSITIVE_KEY_PATTERNS = [
652
+ /^email$/i,
653
+ /^password$/i,
654
+ /^token$/i,
655
+ /^secret$/i,
656
+ /^card$/i,
657
+ /^phone$/i,
658
+ /password/i,
659
+ /credit_?card/i
660
+ ];
661
+ function findSensitivePropertyKeys(properties) {
662
+ if (!properties) return [];
663
+ const hits = [];
664
+ for (const k of Object.keys(properties)) {
665
+ if (SENSITIVE_KEY_PATTERNS.some((re) => re.test(k))) hits.push(k);
666
+ }
667
+ return hits;
668
+ }
669
+ var ConsoleDebugLogger = class {
670
+ constructor() {
671
+ this.enabled = false;
672
+ this.seen = /* @__PURE__ */ new Set();
673
+ }
674
+ emit(signal, message, context) {
675
+ if (!this.enabled) return;
676
+ if (ONCE_SIGNALS.has(signal)) {
677
+ if (this.seen.has(signal)) return;
678
+ this.seen.add(signal);
679
+ }
680
+ const ctx = context ? ` ${safeJson(context)}` : "";
681
+ console.info(`[crossdeck:${signal}] ${message}${ctx}`);
682
+ }
683
+ };
684
+ var ONCE_SIGNALS = /* @__PURE__ */ new Set([
685
+ "sdk.configured",
686
+ "sdk.first_event_sent",
687
+ "sdk.environment_mismatch"
688
+ ]);
689
+ function safeJson(obj) {
690
+ try {
691
+ return JSON.stringify(obj);
692
+ } catch {
693
+ return "[unserialisable context]";
694
+ }
695
+ }
696
+
598
697
  // src/crossdeck.ts
599
698
  var CrossdeckClient = class {
600
699
  constructor() {
601
700
  this.state = null;
602
701
  }
603
702
  /**
604
- * Boot the SDK. Idempotent — calling start twice with the same options
703
+ * Boot the SDK. Idempotent — calling init twice with the same options
605
704
  * is a no-op; calling with different options replaces the previous
606
705
  * configuration.
706
+ *
707
+ * NorthStar §11.1: signature is `Crossdeck.init({ appId, publicKey,
708
+ * environment })`. The trio is validated up-front so a typo'd key or a
709
+ * mismatched env fails fast at boot rather than at first event-flush.
607
710
  */
608
- start(options) {
711
+ init(options) {
609
712
  if (!options.publicKey || !options.publicKey.startsWith("cd_pub_")) {
610
713
  throw new CrossdeckError({
611
714
  type: "configuration_error",
612
715
  code: "invalid_public_key",
613
- message: "Crossdeck.start requires a publishable key starting with cd_pub_."
716
+ message: "Crossdeck.init requires a publishable key starting with cd_pub_."
717
+ });
718
+ }
719
+ if (!options.appId) {
720
+ throw new CrossdeckError({
721
+ type: "configuration_error",
722
+ code: "missing_app_id",
723
+ message: "Crossdeck.init requires an appId. Find yours in the Crossdeck dashboard."
724
+ });
725
+ }
726
+ if (options.environment !== "production" && options.environment !== "sandbox") {
727
+ throw new CrossdeckError({
728
+ type: "configuration_error",
729
+ code: "invalid_environment",
730
+ message: 'Crossdeck.init requires environment: "production" | "sandbox".'
731
+ });
732
+ }
733
+ const keyEnv = inferEnvFromKey(options.publicKey);
734
+ if (keyEnv && keyEnv !== options.environment) {
735
+ throw new CrossdeckError({
736
+ type: "configuration_error",
737
+ code: "environment_mismatch",
738
+ message: `Crossdeck.init: environment "${options.environment}" disagrees with key prefix (${keyEnv}). Reconcile the publishable key with the environment declaration.`
614
739
  });
615
740
  }
616
741
  const storage = options.storage ?? detectDefaultStorage();
617
742
  const persistIdentity = options.persistIdentity ?? true;
618
743
  const autoTrack = resolveAutoTrack(options.autoTrack);
619
744
  const opts = {
745
+ appId: options.appId,
620
746
  publicKey: options.publicKey,
747
+ environment: options.environment,
621
748
  baseUrl: options.baseUrl ?? DEFAULT_BASE_URL,
622
749
  persistIdentity,
623
750
  storagePrefix: options.storagePrefix ?? "crossdeck:",
@@ -628,6 +755,8 @@ var CrossdeckClient = class {
628
755
  autoTrack,
629
756
  appVersion: options.appVersion ?? null
630
757
  };
758
+ const debug = new ConsoleDebugLogger();
759
+ debug.enabled = options.debug === true;
631
760
  const http = new HttpClient({
632
761
  publicKey: opts.publicKey,
633
762
  baseUrl: opts.baseUrl,
@@ -639,7 +768,19 @@ var CrossdeckClient = class {
639
768
  const events = new EventQueue({
640
769
  http,
641
770
  batchSize: opts.eventFlushBatchSize,
642
- intervalMs: opts.eventFlushIntervalMs
771
+ intervalMs: opts.eventFlushIntervalMs,
772
+ envelope: () => ({
773
+ appId: opts.appId,
774
+ environment: opts.environment,
775
+ sdk: { name: SDK_NAME, version: opts.sdkVersion }
776
+ }),
777
+ onFirstFlushSuccess: () => {
778
+ debug.emit(
779
+ "sdk.first_event_sent",
780
+ "First telemetry event received. View it in Live Events.",
781
+ { appId: opts.appId, environment: opts.environment }
782
+ );
783
+ }
643
784
  });
644
785
  const deviceInfo = autoTrack.deviceInfo ? collectDeviceInfo({ appVersion: opts.appVersion ?? void 0 }) : opts.appVersion ? { appVersion: opts.appVersion } : {};
645
786
  this.state = {
@@ -650,8 +791,14 @@ var CrossdeckClient = class {
650
791
  autoTracker: null,
651
792
  deviceInfo,
652
793
  options: opts,
794
+ debug,
653
795
  developerUserId: null
654
796
  };
797
+ debug.emit("sdk.configured", `Crossdeck connected to ${opts.appId} in ${opts.environment} mode.`, {
798
+ appId: opts.appId,
799
+ environment: opts.environment,
800
+ sdkVersion: opts.sdkVersion
801
+ });
655
802
  if (autoTrack.sessions || autoTrack.pageViews) {
656
803
  const tracker = new AutoTracker(
657
804
  autoTrack,
@@ -664,6 +811,19 @@ var CrossdeckClient = class {
664
811
  void this.heartbeat().catch(() => void 0);
665
812
  }
666
813
  }
814
+ /**
815
+ * @deprecated Use `init()` instead. NorthStar §4 standardised the
816
+ * lifecycle method name across SDKs as `init` (formerly `start` /
817
+ * `configure`). `start` will be removed in a future major version.
818
+ */
819
+ start(options) {
820
+ if (typeof console !== "undefined") {
821
+ console.warn(
822
+ "[crossdeck] Crossdeck.start() is deprecated \u2014 use Crossdeck.init() instead. The signature is the same."
823
+ );
824
+ }
825
+ this.init(options);
826
+ }
667
827
  /**
668
828
  * Link the anonymous device to a developer-supplied user ID. Cache
669
829
  * the resolved Crossdeck customer for follow-up calls.
@@ -716,10 +876,41 @@ var CrossdeckClient = class {
716
876
  const s = this.requireStarted();
717
877
  return s.entitlements.list();
718
878
  }
879
+ /**
880
+ * Subscribe to entitlement-cache changes. Returns an unsubscribe fn.
881
+ *
882
+ * The listener is invoked AFTER the cache mutates — once after a
883
+ * successful `getEntitlements()` warms it, again after `syncPurchases()`
884
+ * delivers fresh entitlements, and once on `reset()` to fire the
885
+ * empty-cache state for logout flows.
886
+ *
887
+ * It is NOT invoked synchronously on subscribe. Callers that need
888
+ * the current state should read it via `isEntitled()` / `listEntitlements()`
889
+ * inline; the listener fires only on FUTURE changes.
890
+ *
891
+ * This is the foundation of the `useEntitlement` React hook in
892
+ * `@cross-deck/web/react` — without it, React (or SwiftUI / Compose
893
+ * / Vue) would have no way to re-render when entitlements arrive
894
+ * asynchronously after init. The naive pattern of calling
895
+ * `Crossdeck.isEntitled("pro")` directly inside a render path
896
+ * shows the empty-cache result forever; binding the result to
897
+ * component state via `onEntitlementsChange` is the correct
898
+ * pattern.
899
+ *
900
+ * Idempotent unsubscribe — calling the returned function multiple
901
+ * times is safe.
902
+ *
903
+ * Listener errors are swallowed (a buggy listener can't crash the
904
+ * SDK or other listeners).
905
+ */
906
+ onEntitlementsChange(listener) {
907
+ const s = this.requireStarted();
908
+ return s.entitlements.subscribe(listener);
909
+ }
719
910
  /**
720
911
  * Queue a telemetry event. Returns immediately — the network round-
721
912
  * trip happens in the background. To flush before the page unloads,
722
- * call flushEvents().
913
+ * call flush().
723
914
  */
724
915
  track(name, properties) {
725
916
  const s = this.requireStarted();
@@ -730,6 +921,22 @@ var CrossdeckClient = class {
730
921
  message: "track(name) requires a non-empty name."
731
922
  });
732
923
  }
924
+ if (s.debug.enabled && properties) {
925
+ const flagged = findSensitivePropertyKeys(properties);
926
+ if (flagged.length > 0) {
927
+ s.debug.emit(
928
+ "sdk.sensitive_property_warning",
929
+ `Event "${name}" has potentially sensitive property names: ${flagged.join(", ")}. Crossdeck is privacy-first \u2014 avoid sending PII unless intentional.`,
930
+ { eventName: name, flagged }
931
+ );
932
+ }
933
+ }
934
+ if (s.debug.enabled && !s.developerUserId && !s.identity.crossdeckCustomerId) {
935
+ s.debug.emit(
936
+ "sdk.no_identity",
937
+ "Using anonymous user until identify(userId) is called."
938
+ );
939
+ }
733
940
  const enriched = { ...s.deviceInfo };
734
941
  const sessionId = s.autoTracker?.currentSessionId;
735
942
  if (sessionId) enriched.sessionId = sessionId;
@@ -743,28 +950,68 @@ var CrossdeckClient = class {
743
950
  Object.assign(event, this.identityHintForEvent());
744
951
  s.events.enqueue(event);
745
952
  }
746
- /** Force-flush queued events. Useful to call from page-unload handlers. */
747
- async flushEvents() {
953
+ /**
954
+ * Force-flush queued events. Useful to call from page-unload handlers.
955
+ *
956
+ * NorthStar §4: standard method name across all Crossdeck SDKs.
957
+ */
958
+ async flush() {
748
959
  const s = this.requireStarted();
749
960
  await s.events.flush();
750
961
  }
751
- /** Forward an Apple StoreKit 2 transaction for verification + projection. */
752
- async purchaseApple(input) {
962
+ /** @deprecated Use `flush()` instead. NorthStar §4 standardised the name. */
963
+ async flushEvents() {
964
+ return this.flush();
965
+ }
966
+ /**
967
+ * Forward purchase evidence to the backend for verification + entitlement
968
+ * projection. NorthStar §4 + §13 canonical name.
969
+ *
970
+ * Today the web SDK only supports Apple StoreKit 2 forwarding (web apps
971
+ * that sit alongside an iOS app). Stripe doesn't need this method —
972
+ * Stripe webhooks deliver evidence server-side without a client round-trip.
973
+ */
974
+ async syncPurchases(input) {
753
975
  const s = this.requireStarted();
754
976
  if (!input.signedTransactionInfo) {
755
977
  throw new CrossdeckError({
756
978
  type: "invalid_request_error",
757
979
  code: "missing_signed_transaction_info",
758
- message: "purchaseApple requires a signedTransactionInfo string from StoreKit 2."
980
+ message: "syncPurchases requires a signedTransactionInfo string from StoreKit 2."
759
981
  });
760
982
  }
761
- const result = await s.http.request("POST", "/purchases", {
762
- body: { rail: "apple", ...input }
983
+ const result = await s.http.request("POST", "/purchases/sync", {
984
+ body: { rail: input.rail ?? "apple", ...input }
763
985
  });
764
986
  s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
765
987
  s.entitlements.setFromList(result.entitlements);
988
+ s.debug.emit(
989
+ "sdk.purchase_evidence_sent",
990
+ "StoreKit transaction forwarded. Waiting for backend verification.",
991
+ { rail: input.rail ?? "apple" }
992
+ );
766
993
  return result;
767
994
  }
995
+ /** @deprecated Use `syncPurchases()` instead. NorthStar §4 standardised the name. */
996
+ async purchaseApple(input) {
997
+ return this.syncPurchases({ rail: "apple", ...input });
998
+ }
999
+ /**
1000
+ * Toggle verbose diagnostic logging — NorthStar §16. When enabled, the
1001
+ * SDK emits a fixed vocabulary of debug signals to console.info that the
1002
+ * dashboard's onboarding checklist can also surface as live events.
1003
+ */
1004
+ setDebugMode(enabled) {
1005
+ const s = this.requireStarted();
1006
+ s.debug.enabled = enabled;
1007
+ if (enabled) {
1008
+ s.debug.emit(
1009
+ "sdk.configured",
1010
+ `Debug mode enabled for ${s.options.appId} in ${s.options.environment} mode.`,
1011
+ { appId: s.options.appId, environment: s.options.environment }
1012
+ );
1013
+ }
1014
+ }
768
1015
  /**
769
1016
  * Send the boot heartbeat. Called automatically by start() unless
770
1017
  * autoHeartbeat:false. Safe to call manually as a "we're still here" ping.
@@ -841,8 +1088,8 @@ var CrossdeckClient = class {
841
1088
  if (!this.state) {
842
1089
  throw new CrossdeckError({
843
1090
  type: "configuration_error",
844
- code: "not_started",
845
- message: "Call Crossdeck.start({ publicKey }) before any other method."
1091
+ code: "not_initialized",
1092
+ message: "Call Crossdeck.init({ appId, publicKey, environment }) before any other method."
846
1093
  });
847
1094
  }
848
1095
  return this.state;
@@ -875,6 +1122,11 @@ var CrossdeckClient = class {
875
1122
  }
876
1123
  };
877
1124
  var Crossdeck = new CrossdeckClient();
1125
+ function inferEnvFromKey(publicKey) {
1126
+ if (publicKey.startsWith("cd_pub_test_")) return "sandbox";
1127
+ if (publicKey.startsWith("cd_pub_live_")) return "production";
1128
+ return null;
1129
+ }
878
1130
  function resolveAutoTrack(input) {
879
1131
  if (input === false) {
880
1132
  return { sessions: false, pageViews: false, deviceInfo: false };