@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/CHANGELOG.md ADDED
@@ -0,0 +1,62 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@cross-deck/web` will be documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
4
+
5
+ ## [0.4.0] — 2026-05-09
6
+
7
+ Reactive entitlements. Pre-0.4.0, calling `Crossdeck.isEntitled("pro")` directly inside a React render path showed the empty-cache result forever — React had no way to know the cache had populated asynchronously after `init()`. This release closes that gap with a first-class subscribe API on the SDK and a React subpackage that uses it.
8
+
9
+ ### Added
10
+
11
+ - **`Crossdeck.onEntitlementsChange(listener)`** — synchronous subscribe API. Returns an idempotent unsubscribe function. Listeners fire AFTER each cache mutation (`getEntitlements`, `syncPurchases`, `reset`). Listener errors are swallowed. NOT fired on subscribe — read state inline if you need the initial value. See `sdks/SDK_TRUTH.md` for the full contract.
12
+ - **`@cross-deck/web/react` subpath export** — first-class React hooks built on top of the subscribe API:
13
+ - `useEntitlement(key): boolean` — re-renders the component the moment the cache mutates so a JSX snippet like `useEntitlement("pro") && <ProBadge />` actually works.
14
+ - `useEntitlements(): readonly string[]` — reactive list of all active entitlement keys.
15
+ - SSR-safe: hook returns `false` / `[]` on the server and hydrates correctly on the client. Pre-init returns the empty default until `Crossdeck.init()` runs and a cache mutation lands.
16
+ - **`EntitlementCache.subscribe(listener)`** — internal listener API on the cache itself. Powers `onEntitlementsChange`. Iterates over a snapshot of the listener set so listeners that unsubscribe themselves during dispatch don't break the iteration.
17
+ - **Tests** — 7 new cases covering listener semantics: fires on `setFromList`, fires on `clear`, NOT fired on subscribe, idempotent unsubscribe, listener errors are non-fatal, self-unsubscribe-during-dispatch is safe.
18
+
19
+ ### Why this exists
20
+
21
+ Without a subscribe API, every framework binding (React, SwiftUI, Compose, Vue, Solid) had to invent its own re-render trigger by polling or hooking into private SDK internals. The cache is the only place that knows precisely when `isEntitled()` would change its answer; making it the source of the notification is the correct contract. iOS and Android SDKs MUST adopt the same pattern internally before 1.0 and MUST expose framework bindings (`@Observable` / SwiftUI for iOS, `StateFlow<Boolean>` / Compose for Android) that mirror the React hook's semantics. See the SDK NorthStar Addendum §11.4.
22
+
23
+ ### Build
24
+
25
+ - `tsup` now emits two entry points (`dist/index.{cjs,mjs}` and `dist/react.{cjs,mjs}`) with a custom `outExtension` matching the `package.json` exports map.
26
+ - React is now an optional peer dependency (`react >=18`).
27
+
28
+ ### Compatibility
29
+
30
+ Source-compatible with 0.3.0. No breaking changes — `onEntitlementsChange` and the React hooks are purely additive.
31
+
32
+ ## [0.3.0] — 2026-05-08
33
+
34
+ This release reconciles the web SDK with the Crossdeck SDK NorthStar Addendum (§4 Shared Contract, §11.1 Web SDK pattern, §13.1 wire envelope, §15 sensitive properties, §16 debug signal vocabulary). The public surface now matches what the iOS, Android, and Node SDKs will expose — `init`, `flush`, `syncPurchases`, `setDebugMode`.
35
+
36
+ ### Added
37
+
38
+ - **`Crossdeck.init({ appId, publicKey, environment })`** — canonical lifecycle method per NorthStar §4. The trio is required and validated up-front: a publishable-key prefix that disagrees with the declared `environment` throws `CrossdeckError({ code: "environment_mismatch" })` at boot, so a typo can't silently route prod data into sandbox dashboards.
39
+ - **`Crossdeck.flush()`** — alias of the old `flushEvents()`, matching the standardised name.
40
+ - **`Crossdeck.syncPurchases(input)`** — replaces `purchaseApple`. Posts to `/v1/purchases/sync` and accepts an optional `rail` field for future Stripe/Google support.
41
+ - **`Crossdeck.setDebugMode(enabled)`** + `debug` init option — toggle the §16 debug signal vocabulary (`sdk.configured`, `sdk.first_event_sent`, `sdk.no_identity`, `sdk.purchase_evidence_sent`, `sdk.environment_mismatch`, `sdk.sensitive_property_warning`).
42
+ - **Sensitive-property warnings** — when debug mode is on, `track()` warns once per call if any property key matches `email|password|token|secret|card|phone` (NorthStar §15). The event is still sent unmodified; the warning surfaces accidental PII in the dashboard onboarding feed.
43
+ - **NorthStar §13.1 wire envelope** — every `/v1/events` POST now includes `appId`, `environment`, and `sdk: { name, version }` at the batch level. The backend validates these against the API-key-resolved app and rejects mismatches with `permission_error / env_mismatch`.
44
+
45
+ ### Changed
46
+
47
+ - `Crossdeck.start()` is now a deprecated alias of `init()` and emits a `console.warn` once per call. The signature is unchanged, but the new `appId` and `environment` options are still required even when calling `start`.
48
+ - `Crossdeck.purchaseApple()` is now a deprecated alias of `syncPurchases({ rail: "apple", ... })`. The new method posts to `/v1/purchases/sync`; the legacy `/v1/purchases` route is kept on the backend for v0.2.x callers.
49
+ - The `not_started` configuration error code is now `not_initialized` to match the rename.
50
+
51
+ ### Removed
52
+
53
+ Nothing. v0.3.0 is fully source-compatible with v0.2.x callers — the legacy method names log a deprecation but continue to work. Plan to drop them in v0.5.0.
54
+
55
+ ## [0.2.0] — 2026-05-06
56
+
57
+ - Added auto-tracking: sessions, page views, and device-info enrichment are on by default in browsers. See `autoTrack` config to disable individually or wholesale.
58
+ - Stable `Diagnostics` shape regardless of whether `start()` has been called — pre-start values are sensible empties.
59
+
60
+ ## [0.1.0] — 2026-05-05
61
+
62
+ Initial public release.
package/README.md CHANGED
@@ -11,24 +11,66 @@ npm install @cross-deck/web
11
11
  ```ts
12
12
  import { Crossdeck } from "@cross-deck/web";
13
13
 
14
- // 1. Boot once at app start
15
- Crossdeck.start({ publicKey: "cd_pub_live_…" });
14
+ // 1. Boot once at app start. Synchronous and idempotent.
15
+ Crossdeck.init({
16
+ appId: "app_web_xxx", // from the Crossdeck dashboard
17
+ publicKey: "cd_pub_live_…", // publishable key, safe in client code
18
+ environment: "production", // "production" or "sandbox"
19
+ });
20
+
21
+ // 2. Telemetry — fire-and-forget, batched in the background.
22
+ Crossdeck.track("paywall_viewed", { variant: "v3" });
23
+
24
+ // 3. Auth + entitlements happen inside an async boot function (or a
25
+ // React useEffect, or any other async context). Top-level await is
26
+ // not portable across all bundlers.
27
+ async function bootCrossdeck() {
28
+ // Wire identify() to YOUR auth state — never hardcode a placeholder.
29
+ await Crossdeck.identify(currentUser.id);
30
+ await Crossdeck.getEntitlements(); // warm the local cache
31
+
32
+ // 4. Sync access checks (microsecond reads from cache).
33
+ if (Crossdeck.isEntitled("pro")) {
34
+ showProFeatures();
35
+ }
36
+ }
37
+ ```
16
38
 
17
- // 2. After the user logs in, link the device to your user ID
18
- await Crossdeck.identify("user_847");
39
+ ### React quick start
19
40
 
20
- // 3. Read entitlements (warms the local cache)
21
- await Crossdeck.getEntitlements();
41
+ For React apps, install Crossdeck once at the root and use the
42
+ `useEntitlement` hook from `@cross-deck/web/react` so components
43
+ re-render when entitlements arrive:
22
44
 
23
- // 4. Sync access checks (microsecond reads from cache)
24
- if (Crossdeck.isEntitled("pro")) {
25
- showProFeatures();
45
+ ```tsx
46
+ "use client"
47
+ import { useEffect } from "react";
48
+ import { Crossdeck } from "@cross-deck/web";
49
+ import { useEntitlement } from "@cross-deck/web/react";
50
+
51
+ export function CrossdeckProvider({ children }) {
52
+ useEffect(() => {
53
+ Crossdeck.init({
54
+ appId: "app_web_xxx",
55
+ publicKey: "cd_pub_live_…",
56
+ environment: "production",
57
+ });
58
+ Crossdeck.getEntitlements(); // warm the cache (fire-and-forget)
59
+ }, []);
60
+ return children;
26
61
  }
27
62
 
28
- // 5. Telemetry — fire-and-forget, batched in the background
29
- Crossdeck.track("paywall_viewed", { variant: "v3" });
63
+ export function ProBadge() {
64
+ const isPro = useEntitlement("pro");
65
+ return isPro ? <span className="badge">Pro</span> : null;
66
+ }
30
67
  ```
31
68
 
69
+ `useEntitlement` subscribes to the SDK's reactive cache via
70
+ `Crossdeck.onEntitlementsChange()`, so every component using the hook
71
+ re-renders the moment entitlements change. SSR-safe: returns `false`
72
+ on the server and hydrates correctly on the client.
73
+
32
74
  That's the full happy path.
33
75
 
34
76
  ## What it does
@@ -37,7 +79,7 @@ That's the full happy path.
37
79
  - **One identity for every device + user.** Pre-login events get an `anonymousId`. After login, `identify()` links them to your user ID through Crossdeck's identity graph. The SDK persists both so subsequent app launches resume where you left off.
38
80
  - **Synchronous entitlement reads.** `getEntitlements()` populates a local cache. `isEntitled("pro")` is a Set lookup — no network call, no waiting.
39
81
  - **Batched telemetry.** `track()` queues events in memory; the SDK flushes every 5 seconds (configurable) or when the buffer hits 20 events. Network failures re-queue the batch — events aren't lost on a flaky connection.
40
- - **Boot heartbeat.** On `start()` the SDK pings `/v1/sdk/heartbeat` so the dashboard's Apps page can show you "last seen" per install. Disable with `autoHeartbeat: false`.
82
+ - **Boot heartbeat.** On `init()` the SDK pings `/v1/sdk/heartbeat` so the dashboard's Apps page can show you "last seen" per install. Disable with `autoHeartbeat: false`.
41
83
  - **Stripe-style errors.** Every async method throws `CrossdeckError` with `type`, `code`, `requestId`, and `status` — same shape as Stripe's SDKs, so generic error handlers transfer.
42
84
 
43
85
  ## Auto-tracked events
@@ -67,7 +109,7 @@ Every event's `properties` is enriched with whatever the SDK can detect:
67
109
  viewportWidth: 1440,
68
110
  viewportHeight: 900,
69
111
  devicePixelRatio: 2,
70
- appVersion: "1.2.3", // only when you set Crossdeck.start({ appVersion })
112
+ appVersion: "1.2.3", // only when you set Crossdeck.init({ appVersion })
71
113
  }
72
114
  ```
73
115
 
@@ -75,13 +117,15 @@ No fingerprinting, no IP collection on the event document, no canvas hashing. Pr
75
117
 
76
118
  ## API
77
119
 
78
- ### `Crossdeck.start(options)`
120
+ ### `Crossdeck.init(options)`
79
121
 
80
- Boot the client. Idempotent — calling twice with the same options is fine.
122
+ Boot the client. Idempotent — calling twice with the same options is fine. (`Crossdeck.start()` is kept as a deprecated alias for v0.2.x callers.)
81
123
 
82
124
  ```ts
83
- Crossdeck.start({
125
+ Crossdeck.init({
126
+ appId: "app_web_xxx", // required — from the dashboard
84
127
  publicKey: "cd_pub_live_…", // required
128
+ environment: "production", // required — "production" | "sandbox"
85
129
  baseUrl: "https://api.cross-deck.com/v1", // override for self-host or emulator
86
130
  appVersion: "1.2.3", // attached to every event as properties.appVersion
87
131
  autoTrack: true, // default — sessions, page views, device info
@@ -90,9 +134,12 @@ Crossdeck.start({
90
134
  eventFlushBatchSize: 20, // default
91
135
  eventFlushIntervalMs: 5_000, // default
92
136
  storage: customStorage, // override the persistence adapter
137
+ debug: false, // verbose §16 debug signals when true
93
138
  });
94
139
  ```
95
140
 
141
+ Crossdeck checks the key prefix matches `environment`: `cd_pub_test_…` must declare `"sandbox"`, `cd_pub_live_…` must declare `"production"`. Mismatches throw `CrossdeckError({ code: "environment_mismatch" })` at init time so a typo can't silently route prod telemetry into sandbox dashboards.
142
+
96
143
  The publishable key is safe to ship in client code. Crossdeck enforces origin allowlists (web), bundle-ID binding (mobile), and rate limits at the edge — see [docs/api-keys](https://cross-deck.com/docs/api-keys/) for the full security model.
97
144
 
98
145
  ### `await Crossdeck.identify(userId, options?)`
@@ -129,24 +176,26 @@ Synchronous read from the local cache. Returns `false` until you've called `getE
129
176
 
130
177
  ### `Crossdeck.track(name, properties?)`
131
178
 
132
- Queue a telemetry event. Returns immediately. Events flush in batches; force a flush with `flushEvents()`:
179
+ Queue a telemetry event. Returns immediately. Events flush in batches; force a flush with `flush()`:
133
180
 
134
181
  ```ts
135
182
  Crossdeck.track("checkout_started", { product: "annual_pro" });
136
183
  // …later, e.g. before page unload:
137
184
  window.addEventListener("beforeunload", () => {
138
- void Crossdeck.flushEvents();
185
+ void Crossdeck.flush();
139
186
  });
140
187
  ```
141
188
 
142
189
  Event names match `[A-Za-z0-9_.\-:]+`, max 128 chars. Properties are JSON-serialisable, max 8 KB per event after JSON encoding.
143
190
 
144
- ### `await Crossdeck.purchaseApple(input)`
191
+ In `debug: true` mode the SDK warns (one signal per call) when property keys look like PII — `email`, `password`, `token`, `secret`, `card`, `phone`. Crossdeck never strips fields automatically; the warning is so accidental leaks surface during development, not in prod logs.
145
192
 
146
- Forward a StoreKit 2 transaction directly to Crossdeck for verification — closes the purchase-to-entitled latency from seconds to milliseconds (faster than waiting for the App Store webhook).
193
+ ### `await Crossdeck.syncPurchases(input)`
194
+
195
+ Forward purchase evidence (Apple StoreKit 2) directly to Crossdeck for verification — closes the purchase-to-entitled latency from seconds to milliseconds (faster than waiting for the App Store webhook). (`purchaseApple()` is kept as a deprecated alias.)
147
196
 
148
197
  ```ts
149
- await Crossdeck.purchaseApple({
198
+ await Crossdeck.syncPurchases({
150
199
  signedTransactionInfo: transaction.jsonRepresentation, // from StoreKit 2
151
200
  signedRenewalInfo: subscription.signedRenewalInfo, // optional
152
201
  appAccountToken: "uuid-…", // optional
@@ -157,15 +206,19 @@ Stripe and Google purchases are verified via webhooks (Stripe Connect platform e
157
206
 
158
207
  ### `await Crossdeck.heartbeat()`
159
208
 
160
- Manually send a heartbeat. Called automatically by `start()` unless `autoHeartbeat: false`. Returns the readiness summary the dashboard uses to display SDK installation status.
209
+ Manually send a heartbeat. Called automatically by `init()` unless `autoHeartbeat: false`. Returns the readiness summary the dashboard uses to display SDK installation status.
161
210
 
162
211
  ### `Crossdeck.reset()`
163
212
 
164
213
  Wipe persisted identity + entitlement cache + queued events. Call on logout. The next session generates a fresh `anonymousId` and starts a clean identity-graph entry.
165
214
 
166
- ### `Crossdeck.flushEvents()`
215
+ ### `Crossdeck.flush()`
216
+
217
+ Force-flush the in-memory event queue. Useful before page unload or when shutting down a script. (`flushEvents()` is kept as a deprecated alias.)
218
+
219
+ ### `Crossdeck.setDebugMode(enabled)`
167
220
 
168
- Force-flush the in-memory event queue. Useful before page unload or when shutting down a script.
221
+ Toggle the verbose debug-signal vocabulary at runtime (NorthStar §16). When enabled, the SDK emits a fixed set of `console.info` lines tagged `[crossdeck:sdk.<signal>]` for `sdk.configured`, `sdk.first_event_sent`, `sdk.no_identity`, `sdk.purchase_evidence_sent`, `sdk.environment_mismatch`, and `sdk.sensitive_property_warning`.
169
222
 
170
223
  ### `Crossdeck.diagnostics()`
171
224
 
@@ -186,7 +239,7 @@ Diagnostic snapshot — useful for development consoles and bug reports:
186
239
 
187
240
  ## Errors
188
241
 
189
- Every async method can throw `CrossdeckError`. Synchronous methods throw on configuration mistakes (calling before `start()`, invalid key prefix).
242
+ Every async method can throw `CrossdeckError`. Synchronous methods throw on configuration mistakes (calling before `init()`, invalid key prefix, env mismatch).
190
243
 
191
244
  ```ts
192
245
  import { CrossdeckError } from "@cross-deck/web";
@@ -220,9 +273,11 @@ The SDK works the same way in Node 18+:
220
273
  ```ts
221
274
  import { Crossdeck, MemoryStorage } from "@cross-deck/web";
222
275
 
223
- Crossdeck.start({
276
+ Crossdeck.init({
277
+ appId: process.env.CROSSDECK_APP_ID!,
224
278
  publicKey: process.env.CROSSDECK_PUBLIC_KEY!,
225
- storage: new MemoryStorage(), // session-only, no localStorage
279
+ environment: "sandbox", // or "production"
280
+ storage: new MemoryStorage(), // session-only, no localStorage
226
281
  autoHeartbeat: false, // skip the boot ping in scripts
227
282
  });
228
283
  ```