Crossdeck Docs

Changelog

New features, improvements, and fixes across Crossdeck — the platform, the iOS app, and every SDK. Each entry is permanent and linkable. Machine-readable feed: changelog.json.

iOS app v1.0.0 initial

Initial release. The Crossdeck dashboard in your pocket — read-only by design: every number is read through the same backend the web dashboard uses, never recomputed on the phone.

Added

  • Revenue — the same revenue summary the web dashboard shows, at a glance.
  • Issues — open issues sorted by paying-customer impact, with Resolve / Ignore / Mute verdicts from the phone. Noise never renders.
  • Live & Analytics — who is on your product right now, and how the recent days trended.
  • Pulse — the all-projects live world map.
  • Push notifications for the three moments that matter: an issue touching paying customers, an error-rate spike, and the daily revenue digest. Nothing else pushes.
  • Sign in with your existing Crossdeck account; switch projects from the header.
Node v1.7.0 minor

PARK on version-rejection — events are held, never dropped. A third event-queue outcome for the day the server stops accepting an outdated event format. Purely additive; no public API change.

Added:

  • PARK (HTTP 426 / sdk_version_unsupported). A version-rejection is now recognised as its own outcome — distinct from retry (transient) and drop (invalid): the data is good, only the wire dialect is stale. The queue holds the events (folded to the buffer front, FIFO-capped at 1000), hushes (stops flushing a known-too-old payload), signals once (one console.warn + a typed sdk.parked debug event), and delivers on restart after you upgrade. Node's queue is in-memory, so a process restart before upgrade clears the held events — an opt-in disk-backed queue is on the roadmap; the messaging says exactly this, never more.
  • sdk_version_unsupported added to the error-codes catalogue with remediation, and version_error to CrossdeckErrorType. CrossdeckError carries minVersion / surface from the 426 body. New onParked callback.

Fixed (no public API change):

  • The empty-input contract is now codified cross-SDK as invalid-input-rejected-natively: track("") / aliasIdentity with a missing userId reject at the call site by throwing a typed CrossdeckError (missing_event_name / missing_user_id) and never reach the wire — the Node/JS idiom of the invariant "invalid input never crashes the app." No behaviour change; the guarantee is now documented and bundled.
  • Standalone-build fix: the contract-failed schema-lock test now reads the bundled contract (_contracts-bundled.ts) instead of the monorepo contracts/ path, so the published-mirror release build no longer fails.

See https://cross-deck.com/docs/sdk-event-durability/ for the durability contract.

React Native v1.7.0 minor

PARK on version-rejection — events are held, never dropped. A third event-queue outcome for the day the server stops accepting an outdated event format. Purely additive; no public API change.

Added:

  • PARK (HTTP 426 / sdk_version_unsupported). A version-rejection is now its own outcome — distinct from retry (transient) and drop (invalid): the data is good, only the wire dialect is stale. The queue holds the events (folded to the front of the durable AsyncStorage queue, FIFO-capped at 1000), hushes (stops flushing a known-too-old payload — no wasted device battery/bandwidth), signals once (one console.warn + a typed sdk.parked debug event), and backfills on the next launch after you ship an upgraded build. "Paused, not lost — held on-device, resumes on upgrade."
  • version_error added to CrossdeckErrorType; CrossdeckError carries minVersion / surface from the 426 body so the PARK message names the exact version. New onParked callback.

Fixed (no public API change):

  • The empty-input contract is now codified cross-SDK as invalid-input-rejected-natively and proven with a dedicated test: track("") / identify("") reject at the call site by throwing a typed CrossdeckError (missing_event_name / missing_user_id) and never reach the wire — the React Native idiom of the invariant "invalid input never crashes the app." No behaviour change; the guarantee is now documented and tested.
  • Standalone-build fix: the contract-failed schema-lock test now reads the bundled contract (_contracts-bundled.ts) instead of the monorepo contracts/ path, so the published-mirror release build no longer fails.

See https://cross-deck.com/docs/sdk-event-durability/ for the durability contract.

Swift v1.7.1 patch

Docs-only patch — no code changes.

  • README brought current with the shipped reality: v1.7.0 status header (PARK + first machine-tested release), PARK durability section under the Events contracts, corrected install pins (1.7.x), per-language invalid-input semantics, and the quickstart example's CrossdeckEnvironment type fix. Cut as a release so SPM consumers (Xcode resolves the README at the version tag) see current docs instead of the pre-1.7.0 snapshot.
Xcode → File → Packages → Update to Latest Package Versions ·GitHub release
Swift v1.7.0 minor

PARK on version-rejection — events are held, never dropped. A third event-queue outcome for the day the server stops accepting an outdated event format. Purely additive; no public API change.

Added:

  • PARK (HTTP 426 / sdk_version_unsupported). A version-rejection is now its own HTTPSendOutcome.Kind.parked, distinct from .permanent (drop) and .retryable (transient): the data is good, only the wire dialect is stale. The queue holds the events (folded to the front of the on-disk queue, capped at maxBufferSize), hushes (stops flushing a known-too-old payload), signals once (one print line + a sdk.parked debug event), and backfills on the next launch after upgrade. "Paused, not lost — held on-device, resumes on upgrade."
  • CrossdeckErrorType.versionError ("version_error"); CrossdeckError carries minVersion / surface from the 426 body so the PARK message names the exact version. New onParked queue handler.

Fixed (no public API change):

  • No public API can crash the host app on bad input. track("") and identify("") used assertionFailure (traps in Debug builds) and CrossdeckOptions(breadcrumbCapacity: 0) used precondition (traps in Release builds — the production app). All three are gone: empty input is now dropped with a debug-log signal (track_dropped / identify_dropped), and breadcrumb capacity is clamped to a safe minimum. This is the Swift idiom of the cross-SDK invariant "invalid input is rejected at the call site without crashing the app, and never reaches the wire" — Web/Node/RN signal the same rejection by throwing a typed CrossdeckError; Swift drops + logs to match its fire-and-forget surface (identifyAndWait remains the throwing variant). Codified as the invalid-input-rejected-natively contract.
  • First Swift release verified by CI before publication — a new PublicAPIInputSafetyTests suite proves every public fire-and-forget entry point survives empty/garbage input in both Debug and Release configuration.

See https://cross-deck.com/docs/sdk-event-durability/ for the durability contract.

Xcode → File → Packages → Update to Latest Package Versions ·GitHub release
Web v1.8.0 minor

PARK on version-rejection — events are held, never dropped. A third event-queue outcome for the day the server stops accepting an outdated event format. Purely additive; no public API change.

Added:

  • PARK (HTTP 426 / sdk_version_unsupported). Previously a permanent 4xx dropped the batch. Now a version-rejection is recognised as its own outcome — distinct from retry (transient) and drop (invalid): the data is good, only the wire dialect is stale. The queue holds the events (folded to the front of the durable localStorage queue, FIFO-capped at 1000), hushes (stops flushing a known-too-old payload — no wasted battery/bandwidth), signals once (one console.warn + a typed sdk.parked debug event the dashboard reads), and backfills on the next page load after you upgrade. So "paused, not lost — held on-device, resumes on upgrade" is literally true.
  • sdk_version_unsupported added to the error-codes catalogue with remediation, and version_error to CrossdeckErrorType. CrossdeckError now carries minVersion / surface (parsed from the 426 body) so the PARK message names the exact version to update to.
  • New onParked queue callback (host → dashboard heartbeat channel).
  • Bundle-size budget: UMD min 33 → 35 KB gzipped (~0.5 KB for the PARK branch + onParked + catalogue entry + minVersion/surface). core/react/vue unchanged, under budget.

See https://cross-deck.com/docs/sdk-event-durability/ for the full durability contract.

Node v1.6.0 minor

Event Envelope v1 conformance — server-enforced contract (spec

backend/docs/event-envelope-spec-v1.md).

Added:

  • envelopeVersion: 1 (integer) on every batch POST body. Both the queue-flush path (EventQueue.flush()) and the direct ingest() path now emit this field. The server will reject payloads missing this field once ingest enforcement lands.
  • seq (number) on every wire event — per-session monotonic sequence number. Captured synchronously with the event's timestamp at track() / enqueue time. Counter starts at 0 when the CrossdeckServer instance is constructed (session start) and increments once per event. Matches spec §3: monotonic within a session, never reset between background/foreground (Node has no such lifecycle; the instance lifetime IS the session).
  • context (object) on every wire event — standardized device/platform context (spec §4), promoted out of properties. Common fields: os, osVersion, appVersion, sdkName, sdkVersion, locale, timezone. Node-specific: nodeVersion, host, region (the existing runtime.* props, promoted).

Changed:

  • track() no longer merges runtime.* keys into properties. Those facts now live in the top-level context object on the wire event. Super-properties registered via server.register() continue to appear in properties unchanged (caller-supplied values are unaffected).
React Native v1.6.0 minor

Event Envelope v1 conformance — all three shared gaps from the Phase-0 audit matrix (Q2 sequence, Q3 context drift, Q4 envelope version) are addressed in this release.

Added:

  • envelopeVersion: 1 on the batch envelope (POST body). Integer schema version required by the server's reject-unversioned rule (spec §6.1). Distinct from sdk.version — answers the ingest parsing question, not Version Health.
  • seq on every event (spec §3). Per-session monotonic integer, reset to 0 at setSessionId() (session boundary), incremented synchronously at track() alongside timestamp. Persists across app background/foreground within the same session per the spec's non-negotiable §3 clauses.
  • context object on every event (spec §4). Standardised device/platform fields promoted out of properties: os, osVersion, appVersion, sdkName, sdkVersion, locale, timezone, deviceModel (RN-specific; uses Platform.constants.Model falling back to Brand).

Changed (breaking wire format):

  • Device info fields (os, osVersion, model, brand, etc.) are no longer spread into event properties. They live exclusively in the top-level context object. App-supplied caller properties remain in properties as before.
Swift v1.6.0 minor

Event Envelope v1 conformance. The Swift SDK now emits the server-owned Event Envelope v1 contract (backend/docs/event-envelope-spec-v1.md), closing the three Phase-0 audit gaps for Apple. Purely additive on the wire — the v1 server ignores unknown fields, so this rolls out ahead of, or behind, the other SDKs without coordination. No public API changes.

Added:

  • envelopeVersion: 1 on every batch (§1). The integer schema/wire version the server parses against — "can I parse this?" — kept strictly distinct from sdk.version ("which build is in the wild?"). Two questions, two fields.
  • Per-session seq on every event (§3). A non-negative integer, monotonic within a session, reset to 0 at session.started, assigned synchronously at track() time from a session-scoped counter owned by the AutoTracker (the single owner of session state). It persists across app background/foreground within a session — backgrounding does NOT reset it, so a delayed flush that batches a pre-background and a short-resume event never emits a duplicate seq. An ambiguous session boundary (idle resume past threshold, crash recovery) mints a NEW session, which resets seqseq integrity beats session continuity. seq round-trips through the on-disk persistence layer.
  • Standardized top-level context object (§4) promoted out of event.properties: os, osVersion, appVersion, sdkName, sdkVersion, locale, timezone. App-supplied super-properties and event properties stay in properties as before — only the device/platform facts move.
  • context.deviceModel — the hardware model identifier (iPhone15,2, Mac15,3, …) read from utsname.machine (with the Simulator's SIMULATOR_MODEL_IDENTIFIER env override). Swift previously omitted device model on purpose; the v1 context schema requires it from Apple platforms, so it is now collected. Omitted from the wire when undetectable.
  • $error events are conformant too. Captured errors now carry a real per-session seq (drawn from the same counter as track(), not the default 0 that would collide with session.started) and the same standardized context. track() and the crash path share one context source (DeviceInfo.eventContext) so they cannot drift — spec §2 makes both fields required on every event, not just analytics ones.
Xcode → File → Packages → Update to Latest Package Versions ·GitHub release
Swift v1.5.6 patch

**Existing Apple subscribers now self-heal even in apps that never call

identify().** The 1.5.3 launch sweep of Transaction.currentEntitlements only fired when a developerUserId was already persisted, so an anonymous-only app (no login, every user anonymous for the life of the install) never forwarded its existing Apple subscribers to Crossdeck — the dashboard showed no revenue while the entitlement still worked on-device. Discovery does not need a named owner: each verified transaction is forwarded with the install-stable appAccountToken, so the server attributes the subscription to the install and re-attributes it to the user automatically on the first identify() that carries the token.

Changed:

  • The launch-time currentEntitlements sweep now runs on every launch, identified or not (gated only by automaticAppleEntitlementSync, still on by default). The per-session dedupe key is the developerUserId when identified, else the always-present anonymousId, so an anonymous→identified transition re-sweeps exactly once and a repeated same-id identify() does not.
  • The family-shared skip (1.5.5) is unchanged — a family-shared transaction is still never attributed.

No public API changes; access behaviour is unchanged.

Xcode → File → Packages → Update to Latest Package Versions ·GitHub release
Web v1.7.0 minor

Event Envelope v1 conformance (backend/docs/event-envelope-spec-v1.md). Wire-breaking change (pre-launch; free). No public API change.

  • envelopeVersion: 1 (integer) added to every batch POST body (spec §1). Distinct from sdk.version; the server uses it to gate parsing and will refuse payloads without it once enforcement deploys.
  • seq (number) on every event (spec §3). Per-session monotonic integer, assigned synchronously at track() time alongside timestamp (one sample). Counter lives in AutoTracker, resets to 0 at every session boundary (session.started, resetSession(), 30-min idle rollover), and persists across tab hide/show within a session. The deterministic tiebreak for events sharing a timestamp.
  • context object on every event (spec §4). Device/platform facts (os, osVersion, appVersion, sdkName, sdkVersion, locale, timezone, browser, browserVersion) are promoted OUT of properties into a standardised top-level object. properties retains screen dimensions and all app-supplied properties. This aligns the Web SDK field-set with Swift (which ships os, osVersion, sdkName, sdkVersion, locale, timezone, deviceModel), closing the Q3 drift documented in the spec's Phase-0 audit matrix.
  • Bundle-size budget: core ESM + CJS 58 → 60 KB gzipped (~0.5 KB for the envelope fields + browser/browserVersion detection; CJS landed at 58.26, over the old ceiling). react/vue ESM and UMD unchanged, comfortably under budget.
Swift v1.5.5 patch

Matching discipline: never attribute a family-shared subscription. The 1.5.3 currentEntitlements sweep forwarded every verified entitlement, including family-shared ones — but a family-shared transaction's

originalTransactionId belongs to the family ORGANIZER, not the signed-in user. Binding it (with this user's appAccountToken attached) would hand the organizer's subscription to a family member: a wrong-merge, the one outcome pillar #1 forbids.

Fixed:

  • The auto-sweep now SKIPS transactions whose ownershipType == .familyShared for ATTRIBUTION. Access is unaffected — the family member is still entitled locally (the Phase 3 gate honours family-shared receipts); only the owner-label binding is withheld. The organizer attributes the subscription on their own device, where the transaction is .purchased. The apple.entitlements_resynced summary event gains a skipped_family_shared count for observability.
Xcode → File → Packages → Update to Latest Package Versions ·GitHub release
Swift v1.5.4 patch

Access never waits for attribution. A paying subscriber is now entitled by their own device's signed Apple receipt the instant they open the app — offline, before any backend round-trip — instead of waiting on the backend entitlement cache. This is the ACCESS half of the Apple spine (1.5.3 shipped the attribution half).

Added:

  • entitlementStatus(_:) -> EntitlementStatus — a three-state gate. .entitled / .notEntitled / .resolving. Prefer it over isEntitled for paywalls: .resolving ("checking your subscription…") is the honest answer when the device holds a verified active subscription the SDK can't yet name (fresh install, never synced online, fully offline). Showing a spinner there instead of a paywall is what stops a flash of "not Pro" at a paying user. Self-heals on first connectivity. (That sliver — never online, then offline — is irreducible; RevenueCat carries the identical one.)

Changed:

  • isEntitled(_:) now resolves device-first. Resolution order: a backend grant (persisted, offline-hydrated) wins; else a device-verified Apple receipt mapped to the key wins — *offline, and never revoked against an absent backend (Apple's signature is proof of payment); else .resolving or .notEntitled. Monotonic + back-compatible:* returns true for every case the old cache-only check did, plus device-verified receipts the cache hasn't caught up to. It never flips a genuinely entitled user to false.

Reconciliation rule: protect against backend-absent; yield to backend-present-and-disagrees. The verified receipt is sacrosanct when the network is down, but the product→entitlement MAP is developer config — a reachable backend that re-sources a mapping wins (the map is rebuilt last-snapshot-wins from the freshest backend snapshot, so a retired product→key pairing can't linger on-device).

Non-iOS / pre-iOS-15 targets are unaffected — resolution stays backend-only, exactly as before.

Xcode → File → Packages → Update to Latest Package Versions ·GitHub release
Swift v1.5.3 patch

Existing Apple subscribers now self-heal on return — no migration script. The user↔subscription link for Apple lives per-purchase on the device, not in your backend, so there was never anything to bulk-import; ownership has to be backfilled as each subscriber returns. PurchaseAutoTrack watches

Transaction.updates, but that only fires for NEW/changed transactions — a subscriber who bought before this build never linked until their next renewal.

Added:

  • automaticAppleEntitlementSync (default ON, iOS 15+). On launch (for an already-identified user) and immediately after identify(), the SDK sweeps Transaction.currentEntitlements once and forwards each verified subscription's signed JWS to the same /purchases/sync endpoint, binding originalTransactionId → developerUserId. The existing base self-heals the first time each subscriber opens the migrated build — no backend script, no "Restore Purchases" tap.

This is attribution, not access — it only puts an owner label on a paid subscription. It is deliberately silent (no per-sub purchase.completed funnel events; one apple.entitlements_resynced summary event instead), deduped per developerUserId per session, and idempotent (the backend derives a deterministic key from the JWS), so a repeated sweep never double-counts. A silent no-op for apps without Apple IAP.

Opt out with CrossdeckOptions(automaticAppleEntitlementSync: false). The manual path (syncPurchases) and the Transaction.updates auto-listener (automaticPurchaseTracking) are unchanged; all three now share one /purchases/sync closure so the wire contract never drifts.

Xcode → File → Packages → Update to Latest Package Versions ·GitHub release
Swift v1.5.2 patch

Compile-fix patch on 1.5.1. Every documented call-site — the README, these release notes, and all of the public docs — writes the helper as

Crossdeck.appAccountTokenForCurrentIdentity() (static syntax), but 1.5.1 declared it public func (instance only). Pasting the documented snippet produced Instance member 'appAccountTokenForCurrentIdentity' cannot be used on type 'Crossdeck' on first build. Caught by a dogfood integrator.

Added:

  • Static overload Crossdeck.appAccountTokenForCurrentIdentity() -> UUID. Purely additive — Swift resolves the type-level call to the new static form and client.appAccountTokenForCurrentIdentity() to the original instance method, so both coexist and no existing caller breaks. The static form delegates to the live client (Crossdeck.current), minting and persisting through the started SDK's identity store exactly as the instance method does. Before start() there is no store to own a persisted token, so it returns a fresh anonymous UUID to honour the non-optional "never nil" contract; the first call after start() mints the install-stable token.

``swift let token: UUID = Crossdeck.appAccountTokenForCurrentIdentity() let result = try await product.purchase(options: [ .appAccountToken(token) ]) ``

Xcode → File → Packages → Update to Latest Package Versions ·GitHub release
Platform v1.0.0 initial
  • Initial release.
Web v1.6.3 patch

Patch — error-capture noise reduction. The fetch wrapper raised an

HTTPError(status 0) for every failed request, including the opaque

TypeError an adblocker, an offline device, or an aborted navigation produces. Per the Fetch spec these are indistinguishable from a real outage, so status 0 is the single biggest false-alarm source in browser error tracking — a developer's own adblocker blocking a first-party asset would page as a production error. No public API change.

Status-0 network failures from unambiguous client noise are no longer reported. A new internal predicate suppresses three cases: AbortError (navigation / explicit cancel), offline (navigator.onLine === false), and

same-origin failures — the page itself loaded from that origin, so it's demonstrably reachable; a status-0 there is client-side blocking, not a server fault. Genuine same-origin server failures still surface as 5xx on the success path. Cross-origin outages still report — real signal is kept, only the noise is dropped. The request breadcrumb still fires regardless, so context is preserved on other errors.

Web v1.6.2 patch

Session-boundary correctness, plus the per-platform contract runtime status that had been sitting unreleased. No public API change; session.ended emission timing changes as described, so read before upgrading if you key downstream logic on it.

A tab left open and idle, then used again, no longer stretches one session across the gap. markActivity() now rolls to a new session when an event lands after the 30-minute inactivity window has lapsed — covering the case the page-load and tab-return resume checks miss entirely (a tab kept open and idle, then interacted with, with no visibility transition). A single stored session can no longer contain a >30-minute gap.

Returning to a long-idle tab no longer back-dates session.ended. The visibility-resume path emitted session.ended at the moment of return — more than 30 minutes after the session's last real event — which itself opened an intra-session gap. The prior session now ends implicitly (its end inferred from its last event), consistent with the page-load resume path. If you key downstream logic on session.ended, note it no longer fires on a >30-minute tab-return.

Per-platform contract runtime status + a 7th live verifier. Each bundled

Per-platform contract runtime status + a 7th live verifier. Each bundled contract now carries runtimeVerified — whether this SDK self-verifies it at runtime vs. proving it in CI only. It is derived at build time from the SDK's STATIC_VERIFIERS registry (never hand-set), so the registry can't disagree with what actually runs. CrossdeckContracts consumers can read it to distinguish "watch it pass live" from "CI-proven every release".

  • New runtime verifier sdk-error-codes-catalogue (boot self-test: every backend wire code carries a description + resolution in the shipped catalogue). Web now self-verifies 7 contracts live.
  • Bundle-size budget: UMD min 32 → 33 KB (~0.4 KB gzipped for the flag + the new verifier's frozen 15-code list). Other bundles unchanged, under budget.
Web v1.6.0 minor

Minor — two autocapture fidelity fixes. No public API change; event emission behaviour changes as described, so read before upgrading if you have downstream logic keyed on session.ended timing.

Sessions now survive full-page navigations. Session state was in-memory only, so on a multi-page site (where the SDK re-installs on every navigation) one visit was split into a separate session per page — each session.ended on pagehide landing at the same instant the next page's session.started fired. Sessions are now persisted (id, start, last-activity, first-touch acquisition) to the same storage adapter as identity and resumed across page loads within a rolling 30-minute inactivity window.

  • session.started no longer fires on a resumed page load — only on a genuinely new session (first visit, or first load after >30 min idle).
  • session.ended no longer fires on pagehide / beforeunload (a navigation is not a session end). It fires only on real 30-min inactivity or an explicit Crossdeck.stop().
  • The inactivity window is bumped by every tracked event (auto or custom), not just pageviews/clicks.
  • Honours consent posture: with persistIdentity: false or a MemoryStorage adapter, the session is in-memory only (per-page, the prior behaviour).
  • Continuity is same-origin (localStorage); cross-subdomain stitching is not yet handled.

Click autocapture no longer mashes labels. A click on a control that wraps other controls or a content block (a card around several buttons, a hero <a> around a heading + paragraph) used to collapse the whole subtree into one string — "Log inContinue with GoogleContinue with Apple…", "Tudo que você é,em um só link.Portfolio…". The resolver now returns the control's own label (word boundaries preserved, decorative

svg/style/script skipped), or for a wrapper its direct label / the first heading inside it, falling through to the selector rather than a concatenation. Attribute precedence (data-* → aria → value → text → title → img/svg) is unchanged.

Bundle-size budget for the core bundles raised 55 → 58 KB gzipped (ESM + CJS) to fit the ~1 KB of new code; react/vue/UMD bundles unchanged and still under budget.

Swift v1.5.1 patch

Compile-fix patch on 1.5.0. The 1.5.0 helper signature didn't match StoreKit's contract — Product.PurchaseOption.appAccountToken takes a UUID, not a String, so the snippet in the 1.5.0 release notes would not compile when pasted as-is. Caught by a dogfood integrator before 1.5.0 reached anyone else. No behavioural change beyond the type — the persisted storage representation is unchanged.

Changed:

  • Crossdeck.appAccountTokenForCurrentIdentity() now returns UUID instead of String. The helper still mints a single RFC 4122 random UUID v4 per install, persists it under crossdeck.apple_app_account_token, and returns the same value forever until reset(). The string form is what crosses the wire to the backend on identify(); the UUID form is what StoreKit demands at purchase time. Source-incompatible only if you were storing the return value in a String — replace with UUID and the rest of the call site is unchanged.

Corrected snippet:

``swift let token: UUID = Crossdeck.appAccountTokenForCurrentIdentity() let result = try await product.purchase(options: [ .appAccountToken(token) ]) ``

  • Doc-comment expanded to cover the anonymous-user / pre-identify case. Three properties are now spelled out on the helper itself so integrators don't have to second-guess from release notes:

1. Never returns nil. Even before the first identify() call, the helper lazy-mints a UUID and returns it. Purchases made while the SDK only knows the anonymous identity still get a stable token. 2. Not derived from anonymousId. It is a fresh random UUID, independent of every other Crossdeck identifier. Rotating or resetting anonymousId does not affect it. 3. Persists across identify(). When the developer later calls identify("user_123"), the SDK forwards the existing token alongside the alias request so the backend can bind appAccountToken → developerUserId without minting a new one. Past purchases stay attributed to the same chain.

reset() is the only thing that wipes it, by design — the next user on the same device must not inherit the prior user's token.

Xcode → File → Packages → Update to Latest Package Versions ·GitHub release
Swift v1.5.0 minor

Bank-grade Apple-rail Shape 2 fix. The previous v1.4.x line shipped an appAccountToken derivation that was a deterministic function of developerUserId. That input is mutable across a user's life (anonymous → logged in → account merge → SSO upgrade), and Apple's transaction records are permanent. The instant

developerUserId changed, the SDK could no longer reproduce the token Apple had stored — every renewal in that chain orphaned silently. Bug count grew linearly with auto-tracked purchases.

This release makes the bug impossible by construction on the happy path. The full design rationale is in

Sources/Crossdeck/Identity.swift's reset() doc-comment.

Added:

  • Crossdeck.appAccountTokenForCurrentIdentity() -> String — public helper. First call mints a fresh UUID(), persists it under the storage key crossdeck.apple_app_account_token, and returns the same value forever — independent of any identify() mutation. Wiped only on reset() (sign-out) so the next user on the same device receives a fresh token.

Pass the result to StoreKit at purchase time:

``swift let token = Crossdeck.appAccountTokenForCurrentIdentity() let result = try await product.purchase(options: [ .appAccountToken(token) ]) ``

Server-side, the binding appAccountToken → developerUserId is recorded via identify()'s alias request (the SDK attaches the persisted token automatically). Apple's later ASSN V2 webhook resolves via that binding, not the older implicit assumption that appAccountToken == developerUserId.

Changed:

  • PurchaseAutoTrack and Crossdeck.syncPurchases(...) now read the persisted token via Identity.ensureAppAccountTokenSync() instead of deriving from developerUserId. Existing call sites upgrade automatically — no source changes required for consumers using the auto-track path.
  • AliasIdentityRequest gains appAccountToken: String?. Every identify() and identifyAndWait() now carries the persisted token (when present) so the server records the binding before any later webhook arrives.

Deprecated:

  • AppAccountTokenDerivation.derive(developerUserId:) — kept in the module to preserve the pinned cross-SDK oracle, but no longer called from the auto-track or syncPurchases paths. Header comment documents the failure walk so future contributors don't reintroduce the same trap by "simplifying" the persistence away.

The actionability principle:

Crossdeck's existing design philosophy ("classify, don't silently drop") is right for events the developer can act on — cross-origin

Script error. events get captured with a cross_origin tag and pointed at the CORS fix. It is NOT right for events the developer cannot act on. Browser-extension errors, ad-blocker hits, and Shape 2 orphans created by the prior derivation path all fall in the latter category. This release applies the same principle to the Apple-rail token: drop the deterministic-from-userId derivation, persist a stable per-install UUID, and let the server own the resolution via the recorded binding.

Xcode → File → Packages → Update to Latest Package Versions ·GitHub release
Node v1.5.1 patch

crossdeck.contract_failed is now single-fire to a dedicated reliability endpoint instead of the customer's track() pipeline. Independent-controller flow per Privacy Policy §6; schema-locked by

contracts/diagnostics/contract-failed-payload-schema-lock.json.

ContractFailureInput.extra removed (schema-lock forbids unbounded fields); ContractFailureInput.deviceClass added.

React Native v1.5.1 patch

crossdeck.contract_failed is now single-fire to a dedicated reliability endpoint instead of the customer's track() pipeline. Independent-controller flow per Privacy Policy §6; schema-locked by

contracts/diagnostics/contract-failed-payload-schema-lock.json.

ContractFailureInput.extra removed (schema-lock forbids unbounded fields); ContractFailureInput.deviceClass added.

Swift v1.4.10 patch

Crossdeck.reportContractFailure(_:) is now single-fire to a dedicated reliability endpoint instead of the customer's track(_:) pipeline. Independent-controller flow per Privacy Policy §6; schema-locked by contracts/diagnostics/contract-failed-payload- schema-lock.json. ContractFailureInput.extra removed (schema-lock forbids unbounded fields); ContractFailureInput.deviceClass added.

Xcode → File → Packages → Update to Latest Package Versions ·GitHub release
Swift v1.4.9 patch

.crossdeckTap("Name") — explicit tap-label bolt-on for SwiftUI buttons whose label the auto-track can't reach on iOS 16+.

Added:

  • View.crossdeckTap("Name", properties:) — public SwiftUI modifier that fires element.clicked via .simultaneousGesture (the button's own action still runs). One line per important CTA — matches the .crossdeckScreen pattern.

Why this exists. v1.4.7 added a walk-up-16 + descendant-search to the UIWindow.sendEvent tap-capture path. That fixed SwiftUI button labels on iOS 13–15 where Buttons rendered through UILabel primitives. iOS 16+ SwiftUI uses the Metal text rendering pipeline — Button("Create Image")'s text is drawn straight to a CALayer and never lives on any UILabel.text or UIView.accessibilityLabel the runtime can read. Apple's accessibility merge happens at the SwiftUI virtual-view layer above UIKit; UIView.accessibilityLabel stays nil on the rendered hierarchy. This is an Apple-imposed limit, not a CrossDeck regression — Mixpanel, Amplitude, and PostHog all ship a per-button modifier as the bank-grade answer.

Usage:

Button { generateImage() } label: { Text("Create Image") }
    .crossdeckTap("Create Image")

Composes onto any tappable View (Image with onTapGesture, custom card layouts, list rows) — not just Button. Properties flow through cd.track so coercion / size guard / cross-SDK label resolution on the dashboard apply unchanged.

Xcode → File → Packages → Update to Latest Package Versions ·GitHub release
Swift v1.4.8 patch

Rename enum Environmentenum CrossdeckEnvironment. Structural fix for the SwiftUI symbol collision — closes friction #19 deeper than v1.4.4 caught it.

Why: v1.4.4 added typealias CrossdeckEnvironment = Environment to disambiguate consumer code that imports both SwiftUI and

Crossdeck. That helped consumers but left the module-level public

Environment enum in scope inside the SDK itself. When v1.4.5 shipped View.crossdeckScreen("Name"), the new ViewModifier contained @Environment(\.crossdeck) private var cd — and the compiler resolved Environment to the SDK's enum (closer than SwiftUI's), failing with 'Environment' cannot be used as an attribute. The SDK author shipped a public type that collided with the most-used SwiftUI property wrapper for their own code.

Fix: rename the source enum to CrossdeckEnvironment and remove the typealias entirely. There is no short form — the qualified name is the only public name. Inside the SDK module

@Environment(...) now resolves to SwiftUI's wrapper unambiguously, and consumer code that already follows the docs (let environment: CrossdeckEnvironment = .production) is unchanged.

Breaking: consumer code using Environment.production /

Environment.sandbox must use CrossdeckEnvironment.production /

CrossdeckEnvironment.sandbox. Pre-launch, no shipping consumer relies on the short name — all install snippets and dogfood code already use the qualified form.

Xcode → File → Packages → Update to Latest Package Versions ·GitHub release
Web v1.5.1 patch

crossdeck.contract_failed is now single-fire to a dedicated reliability endpoint instead of the customer's track() pipeline. Independent-controller flow per Privacy Policy §6; schema-locked by

contracts/diagnostics/contract-failed-payload-schema-lock.json.

ContractFailureInput.extra removed (schema-lock forbids unbounded fields); ContractFailureInput.deviceClass added.

Runtime contract verifier layer. The SDK now self-tests its own structural contracts at runtime — per-user cache isolation, idempotency-key determinism, error-envelope shape, flush-interval parity, super-property merge precedence. Verifiers run on every relevant SDK operation; PASS results stream to the developer's console when logVerifierResults: true; FAIL results fire

reportContractFailure(...) to the reliability channel.

Three new CrossdeckOptions flags:

  • verifyContractsAtBoot — default dev=true, prod=false
  • logVerifierResults — default dev=true, prod=false (cosmetic only)
  • disableContractAssertions — sovereignty kill-switch, default false

Bundle-size budget bumped 45 → 55 KB gzipped (core) and 26 → 32 KB (UMD) to accommodate the ~6 KB verifier framework + verifier implementations. The platform-hardening signal — every install in the field tests its own structural contracts as it operates and reports failures to Crossdeck's reliability workspace in real time — is the trade-off.

Node v1.5.0 minor

Minor — CrossdeckContracts + reportContractFailure(...) ship as a new public surface on every SDK simultaneously. Additive only; no behavioural change to existing APIs.

Added:

  • CrossdeckContracts namespace — typed access to the bank-grade contract registry. Methods: all(), allIncludingHistorical(), byId(id), byPillar(pillar), withStatus(status), findByTestName(name). Properties: sdkVersion, bundledIn (e.g. "@cross-deck/[email protected]").
  • Contract type + ContractPillar / ContractStatus / ContractAppliesTo unions + ContractTestRef + ContractFailureInput interfaces exported from the top-level entry. Treated as binary-stable.
  • CrossdeckServer.reportContractFailure(input) method — fires a typed crossdeck.contract_failed server event through the standard track() pipeline. Wire properties: contract_id, sdk_version (auto-stamped), sdk_platform (auto-stamped to "node"), failure_reason, run_context (ci | dogfood | customer-app), run_id, plus optional test_file / test_name from input.testRef.

Fixed:

  • shutdownSync() now emits the sdk.shutdown EventEmitter signal with the correct reason — previously only the async shutdown() path emitted, leaving consumers of Symbol.dispose / shutdownSync() direct-callers blind. Async path is unchanged thanks to a private dedup gate so listeners still fire exactly once per teardown.
  • Test infrastructure: shutdown-flush + track-PII-scrub tests were reading body.data from captured fetch payloads but the wire shape uses body.events (matching backend + Web/RN SDKs). Tests fixed to read the correct field; behaviour was already correct.

Changed:

  • Contract registry source files migrated to camelCase keys (appliesTo, codeRef, testRef, registeredAt, firstRegisteredIn). The bundled contracts.json sidecar uses the new keys; bundledIn is build-stamped, never in source.
Node v1.4.2 patch

Patch — fix tests/shutdown-flush.test.ts compile error under strict tsc. The five s.track("name", { props }) calls used the web/RN positional-args shape; Node SDK's track takes a single

ServerEvent object. Switched to s.track({ name, properties }). Plus a non-null assertion on sent[0].length for

noUncheckedIndexedAccess. v1.4.1 was tagged on the public crossdeck-node repo but its publish workflow aborted on these errors. v1.4.2 is the first 1.4.x line to land on the npm registry. No SDK code changes vs v1.4.0 / v1.4.1.

Node v1.4.1 patch

Patch — add automated npm publish workflow to the public

crossdeck-node repo so future vX.Y.Z tag pushes auto-publish to npm via OIDC Trusted Publishing (matches the existing

crossdeck-web pattern). Also strips test:e2e from

prepublishOnly — the publish workflow runs lint + unit tests + build which covers the release gate. No SDK code changes vs v1.4.0.

Operator note: npmjs.com Trusted Publisher rule must be configured for crossdeck-node (owner: VistaApps-za, workflow: publish.yml) before the OIDC publish succeeds. First publish after this lands will fail with an auth error if the rule is missing — that's the prompt to configure it.

Node v1.4.0 minor

Bank-grade reconciliation release. 6-pillar KPMG-style audit closed across SDK + backend. Every behavioural guarantee registered in the monorepo's contracts/ directory with a CI-enforced audit job.

Added

  • PII scrubber applied on track() enqueue path — parity with Web/RN/Swift. Pre-1.4.0 Node was the ONLY SDK that skipped this, shipping payloads UNREDACTED. New scrubPii?: boolean option (default true); explicit false opt-out preserves raw payloads for regulator-required audit trails.
  • Deterministic Idempotency-Key on syncPurchases() — same JWS/purchaseToken → same key. New options.idempotencyKey override for outer orchestrators.
  • PurchaseResult.idempotent_replay?: boolean — true when the backend replayed a cached response.
  • purchase.completed event on every successful syncPurchases() — funnel parity with Swift/Android auto-track.
  • Distinguishable webhook verifier error codes — pre-1.4.0 collapsed everything into webhook_invalid_signature. New: webhook_signature_mismatch (wrong-secret signal), webhook_timestamp_outside_tolerance (replay-attack signal — alert separately), webhook_timestamp_missing, webhook_payload_not_json, webhook_invalid_tolerance. Legacy codes deprecated with migration notes.
  • Webhook verifier rejects footgun tolerancesInfinity / NaN / negative / above-24h-cap now throw webhook_invalid_tolerance instead of silently disabling replay protection.
  • 15 backend-emitted error codes added to the crossdeck-error-codes.json catalogue with Stripe-style remediation guidance.

Changed (breaking)

  • shutdown() signature changed from (reason) => void to (reason) => Promise<void>. Awaits flush() before tearing down the queue. Pre-1.4.0 it called eventQueue.reset() synchronously — every event between the last flush and shutdown was silently dropped. New shutdownSync() for callers that genuinely cannot await (signal handlers); it logs console.warn with the dropped-event count if the buffer is non-empty.
  • Default event-queue flush interval is now 2000ms (was 1500ms) — cross-SDK parity.
  • [Symbol.dispose] now warns when dropping queued events. Use await using + [Symbol.asyncDispose] (or await server.shutdown()) for proper drainage.
React Native v1.5.0 minor

Minor — CrossdeckContracts + reportContractFailure(...) ship as a new public surface on every SDK simultaneously. Additive only; no behavioural change to existing APIs.

Added:

  • CrossdeckContracts namespace — typed access to the bank-grade contract registry. Methods: all(), allIncludingHistorical(), byId(id), byPillar(pillar), withStatus(status), findByTestName(name). Properties: sdkVersion, bundledIn (e.g. "@cross-deck/[email protected]").
  • Contract type + ContractPillar / ContractStatus / ContractAppliesTo unions + ContractTestRef + ContractFailureInput interfaces exported from the top-level entry. Treated as binary-stable.
  • Crossdeck.reportContractFailure(input) method — fires a typed crossdeck.contract_failed event through the standard track() pipeline when a contract test asserts and fails. Wire properties: contract_id, sdk_version (auto-stamped), sdk_platform (auto-stamped to "react-native"), failure_reason, run_context (ci | dogfood | customer-app), run_id, plus optional test_file / test_name.

Fixed:

  • Pre-hydration track() calls now correctly snapshot the call-time sessionId and thread it through the deferred enrichment body. Previously, two track() calls separated by setSessionId(...) BEFORE hydration completed would both pick up whatever sessionId was current at hydration resolution — silently rewriting the first event with the second event's state. This contract is RN-specific (Web/Node have no hydration window).

Changed:

  • Contract registry source files migrated to camelCase keys (appliesTo, codeRef, testRef, registeredAt, firstRegisteredIn). The bundled contracts.json sidecar uses the new keys; bundledIn is build-stamped, never in source.
React Native v1.4.2 patch

Patch — wire bundleId + packageName (per-platform identity- lock fields declared on CrossdeckOptions since v1.3.0) into the InternalState opts merge. tsc accepted the missing fields in monorepo CI because the monorepo test workflow doesn't lint the RN SDK — only the Web SDK gets type-checked. The public crossdeck-react-native publish workflow DOES run npm run lint and aborted with TS2322. Fix: default both to empty string in the opts initialiser (HTTP layer skips the header when empty; backend rejects with bundle_id_not_allowed / package_name_not_allowed at first request if the project requires the lock — intentional fail-closed). v1.4.1 was tagged on crossdeck-react-native but never reached npm.

No SDK code changes vs v1.4.0 / v1.4.1.

React Native v1.4.1 patch

Patch — add automated npm publish workflow to the public

crossdeck-react-native repo so future vX.Y.Z tag pushes auto-publish to npm via OIDC Trusted Publishing (matches the existing crossdeck-web pattern). No SDK code changes vs v1.4.0.

Operator note: npmjs.com Trusted Publisher rule must be configured for crossdeck-react-native (owner: VistaApps-za, workflow: publish.yml) before the OIDC publish succeeds. First publish after this lands will fail with an auth error if the rule is missing — that's the prompt to configure it.

React Native v1.4.0 minor

Bank-grade reconciliation release. Joined the v1.4.0 release line with the rest of the Crossdeck SDK suite. 6-pillar KPMG-style audit closed; every behavioural guarantee registered in the monorepo's contracts/ directory with a CI-enforced audit job.

Added

  • Per-user entitlement cache isolation. Storage key is now crossdeck:entitlements:<sha256(userId)> — a user-switch on a shared device cannot physically read prior user's cached entitlements even if the in-memory clear is somehow skipped. reset() wipes EVERY per-user slot via the persisted index. New pure-JS SHA-256 helper.
  • Deterministic Idempotency-Key on syncPurchases() — same JWS/purchaseToken → same key. Cross-SDK parity oracle CI-pinned.
  • PurchaseResult.idempotent_replay?: boolean — true when the backend replayed a cached response.
  • purchase.completed event on every successful syncPurchases() — funnel parity with native auto-track.
  • setSessionId(sessionId: string | null) — host-driven session lifecycle. Call from your AppState change listener so every track() event carries the sessionId property — funnel parity with the web SDK.

Changed

  • init() re-entry now drains the prior EventQueue's pending timer before swapping this.state. Pre-1.4.0 the timer fired AFTER the state swap, sending old-init events under new-init identity.
  • Default event-queue flush interval is now 2000ms (was 5000ms) — cross-SDK parity.
Swift v1.4.7 patch

Auto-track tap labels now resolve on SwiftUI buttons — closes the "Clicked an element" gap that buried real CTAs on the dashboard.

Fixed:

  • The UIWindow.sendEvent tap-capture walked up only 4 ancestors looking for an accessibilityLabel. SwiftUI's button hosting tree is much deeper — Button("Create Image") { … } puts the merged accessibility label on a view 8–12 hops above the touched Text / Image. Bumped the walk-up to 16 ancestors so the label is reachable.
  • New descendant-search fallback. SwiftUI's accessibility-merge model commonly puts the human-readable label on a SIBLING or a descendant of the hit-test target rather than an ancestor. When the ancestor walk-up returns nothing, the SDK now descends up to 6 levels into the touched view's subtree looking for a UILabel with text or a view carrying an accessibilityLabel. First match wins — closest, shallowest descendant.
  • New textIndicatesPII helper applies the same password / card number / ssn substring filter to descendant-found text as to ancestor accessibilityLabel — so a password field's visible text never lands on the wire.

Result: a SwiftUI Button("Create Image") { … } tap now ships

element.clicked with accessibilityLabel = "Create Image", and the Pages dashboard / live feed / per-person journey all render

"Clicked 'Create Image'" instead of "Clicked an element."

Xcode → File → Packages → Update to Latest Package Versions ·GitHub release
Swift v1.4.6 patch

SwiftUI hosting-controller denylist — the Pages dashboard no longer fills up with framework noise.

Fixed:

  • The auto-track UIViewController-appearance swizzle was leaking SwiftUI's internal hosting controllers (PresentationHostingController<AnyView>, NavigationStackHostingController<AnyView>, UIKitNavigationController) as page.viewed events. The existing denylist caught UIHostingController (top-level SwiftUI host) but missed the newer hosting machinery SwiftUI 5 / iOS 16+ added for sheets and NavigationStack. Result on a pure-SwiftUI app: the Pages dashboard filled with framework class names instead of the developer's real screens.
  • New screenViewClassSubstringDenylist skips any class containing HostingController — catches all current and future SwiftUI host variants (including any prefixed by SwiftUI's mangled namespace).
  • UIKitNavigationController (Apple's private UINavigationController subclass that backs NavigationStack) added to the exact-name denylist. It was firing on every navigation push and burying the developer's real screens with 11+ noise events per nav.

The right path for SwiftUI screen names remains .crossdeckScreen("Name") (shipped in v1.4.5) — these denylist additions just stop the fallback from producing noise when the modifier isn't applied yet.

Xcode → File → Packages → Update to Latest Package Versions ·GitHub release
Swift v1.4.5 patch

SwiftUI screen tracking — the iOS half of "log in tomorrow morning and see what users tapped on yesterday."

Added:

  • View.crossdeckScreen("Name") — public SwiftUI modifier that fires page.viewed with { screen, title } properties when the view appears. SwiftUI's view tree hides class names from the iOS SDK's swizzle-based auto-track (the host is always UIHostingController<…>, denylisted for the right reason), so pure-SwiftUI apps emitted zero page.viewed events. One line per screen and the Pages dashboard populates the same way it does for a web app's URL list. Matches the pattern Mixpanel / Amplitude / PostHog ship on iOS; pairs with the Pages-backend change that groups by screen when url / path are absent. Properties are also forwarded through cd.track(...) so the standard coercion + size guard apply.

No behavioural change to existing UIKit auto-track — UIKit screens keep firing page.viewed automatically via the

UIViewController.viewDidAppear swizzle (unchanged).

Xcode → File → Packages → Update to Latest Package Versions ·GitHub release
Swift v1.4.4 patch

Bank-grade SwiftUI integration polish. The Quickstart snippet now pastes and runs end-to-end without the developer having to hand-roll any wiring.

Added:

  • EnvironmentValues.crossdeck — public SwiftUI environment key shipped by the SDK. Consumers can now write ContentView().environment(\.crossdeck, cd) at the App root and @Environment(\.crossdeck) private var cd inside any view, with zero EnvironmentKey boilerplate on the consumer side. Returns Crossdeck? so the optional-chain pattern at every call site keeps the host app crash-proof when the SDK didn't start. Lives under #if canImport(SwiftUI) so non-Apple-platform resolves still compile.
  • CrossdeckEnvironment — public typealias for the SDK's Environment enum. Avoids the symbol collision every SwiftUI consumer hits the moment they import Crossdeck alongside import SwiftUI (SwiftUI's own Environment property wrapper). The bare Environment name stays exported for back-compat and keeps working unqualified in files that don't import SwiftUI.

Changed:

  • Quickstart snippet (dashboard SDKs page → iOS) now uses CrossdeckEnvironment in place of bare Environment, so the copy-pasted boot code compiles cleanly inside a @main App file that also imports SwiftUI.

No behavioural change; both additions are purely ergonomic surface that closes the "first dogfood paste-and-run" friction loop.

Xcode → File → Packages → Update to Latest Package Versions ·GitHub release
Swift v1.4.3 patch

Patch — second consumer compile fix surfaced by the same dogfood project as v1.4.2. With v1.4.2's idempotentReplay field in place, Swift's type-checker progressed further and pinned a separate issue in Crossdeck.init(options:):

The auto-tracked purchases/sync failure path's debug-logger call passed typed.statusCode as Any into a [String: String] dict literal. debugLogger is typed (DebugSignal, [String: String]) -> Void (Stripe-style structured-payload contract — no Any smuggled through the diagnostic surface). The dict literal rejected the

Any cast and emitted two cascading errors.

Fixed:

  • Crossdeck.swift:614 — replace typed.statusCode as Any with typed.statusCode.map(String.init) ?? "n/a". statusCode is Int? — when present we stringify, when absent we emit "n/a" so the structured key is always populated.

No behavioural change; the debug payload now strictly conforms to the typed shape the rest of the SDK already uses. Bundled contracts (Resources/contracts.json) regenerated so bundledIn stamps @cross-deck/[email protected].

Xcode → File → Packages → Update to Latest Package Versions ·GitHub release
Swift v1.4.2 patch

Patch — fix consumer compile error caused by a missing field on

PurchaseResult. Surfaced when the first iOS dogfood project pulled v1.4.1 via SwiftPM and Xcode rejected the SDK source with two cascading errors in syncPurchases(...):

  • Value of type 'PurchaseResult' has no member 'idempotent_replay'
  • Cannot convert value of type 'Any' to expected dictionary value type 'String' (cascade — the Swift type-checker gives up on [String: Any] inference once a member resolves unknown)

Fixed:

  • PurchaseResult gains public let idempotentReplay: Bool? with a CodingKey mapping the Swift-idiomatic camelCase property to the wire's idempotent_replay (snake_case, set by the backend's idempotency-response-cache middleware on cache-hit retries per the idempotency-key-deterministic contract).
  • syncPurchases(...) now reads result.idempotentReplay. The analytics event property key stays idempotent_replay on the wire so dashboards joining purchase.completed across SDKs see the same key Web/Node/RN/Android already emit.

No new API surface; no behavioural change beyond the typed access unlocking what the runtime was already receiving. Bundled contracts (Resources/contracts.json) regenerated so bundledIn stamps @cross-deck/[email protected].

Xcode → File → Packages → Update to Latest Package Versions ·GitHub release
Swift v1.4.1 patch

Patch — close the dogfood-surfaced gap on the

per-user-cache-isolation contract. v1.4.0 registered the contract with applies_to: ["web", "react-native"] because Swift + Android only shipped the in-memory wipe layer of the three-layer bank-grade isolation — physical per-user storage keys + the clearAll-via-index logout wipe were missing.

Implemented in v1.4.1 (now in the contract's applies_to list):

  • EntitlementCache.setUserKey(userId) / setUserKeySync(userId) flip the persistent storage suffix to sha256(userId) so each user's blob lives under crossdeck:entitlements:<hash> — a user-switch on a shared device CANNOT cross-read prior user's data even if the in-memory wipe is somehow skipped.
  • EntitlementCache.clearAll() reads the persisted suffix index and wipes every per-user slot — used by Crossdeck.reset() so a logout on a shared device cannot leave another user's entitlements readable.
  • Crossdeck.identify(userId) calls setUserKeySync(userId) instead of clearSync().
  • Crossdeck.reset() (async) calls clearAll() instead of clear().

No public API breakage; existing identify() / reset() semantics upgrade from "in-memory only" to the full three-layer contract.

Xcode → File → Packages → Update to Latest Package Versions ·GitHub release
Swift v1.4.0 minor

Bank-grade reconciliation release. 6-pillar KPMG-style audit closed across SDK + backend. Every behavioural guarantee registered in the monorepo's contracts/ directory with a CI-enforced audit job.

Added

  • PurchaseAutoTrack purchase durability. transaction.finish() is now called STRICTLY inside the success branch of the backend sync. Pre-1.4.0 it fired regardless of outcome — a 5xx mid-process- death silently lost the purchase. Failed syncs persist to a new PendingPurchaseQueue (max 5 in-process retries, exp backoff 30s/1m/5m/30m/2h).
  • Proper appAccountToken UUID conformance. Derived from developerUserId via AppAccountTokenDerivation (UUID passthrough, else UUID v5 from URL namespace + crossdeck:<id>, else omit). Numeric StoreKit originalTransactionId now rides in its own dedicated wire field — pre-1.4.0 it was stuffed into the UUID-shaped appAccountToken, violating Apple's StoreKit contract.
  • Deterministic Idempotency-Key on syncPurchases() — same JWS → same key. Cross-SDK parity oracle CI-pinned.
  • PurchaseResult.idempotent_replay?: Bool — true when the backend replayed a cached response.
  • purchase.completed on every successful manual syncPurchases() — funnel parity with auto-track.

Changed (breaking)

  • reset() is now async. Awaits identity / entitlements / super-properties / breadcrumbs clear before returning. New isResetting tombstone flips synchronously at entry; isEntitled honours it and returns false during the clear window — closes the race between a logout button firing reset() and the actor- internal clear completing. resetSync() exists for callers that cannot await.
  • stop() is now async. Awaits queue.persistAll() and cancels stored boot + heartbeat Tasks. Pre-1.4.0 the Tasks ran fire-and-forget against actors of stopped clients. stopSync() exists for tests / deinit paths.
  • CrossdeckErrorType.internalError / .configurationError added; .apiError / .unknown deprecated with @available(*, deprecated, renamed:). Backend's ApiErrorType never emitted "api_error" or "unknown_error" on the wire — native pattern- matching on the deprecated cases only matched the SDK-synthesised fallback, never a real backend envelope. Use .internalError for 5xx responses.

Added (continued)

  • NSNotificationCenter observer cleanup in stop(). Pre-1.4.0 every start→stop→start cycle leaked N orphan observers; each subsequent didEnterBackground fired N stacked queue.flush() against dead Crossdecks. Stored tokens, removed via uninstallLifecycleObservers().
  • ErrorCapture.shared.uninstall() called in stop(). Pre-1.4.0 the global exception handler retained queue/identity/consent/ breadcrumb actors of the stopped client; next uncaught exception shipped through dead actors.
  • Super-property merge order matches Web/Node/RN — device < super < caller. Pre-1.4.0 Swift had it inverted (super < device < caller, so device clobbered super-properties).
  • Default event-queue flush interval is now 2000ms (was 5000ms) — cross-SDK parity.
Xcode → File → Packages → Update to Latest Package Versions ·GitHub release
Web v1.5.0 minor

Minor — CrossdeckContracts + reportContractFailure(...) ship as a new public surface on every SDK simultaneously. Additive only; no behavioural change to existing APIs.

Added:

  • CrossdeckContracts namespace — typed, tree-shakeable access to the bank-grade contract registry the SDK was already shipping in dist/contracts.json. Methods: all() (enforced only), allIncludingHistorical(), byId(id), byPillar(pillar), withStatus(status), findByTestName(name). Properties: sdkVersion, bundledIn (e.g. "@cross-deck/[email protected]").
  • Contract type + ContractPillar / ContractStatus / ContractAppliesTo unions + ContractTestRef interface exported from the top-level entry. Treated as binary-stable — fields may be added in any minor release but never removed/repurposed except in a major bump.
  • Crossdeck.reportContractFailure(input) method — fires a typed crossdeck.contract_failed custom event through the standard track() pipeline when a contract test asserts and fails (in CI, dogfood, or a customer integration test). Wire properties: contract_id, sdk_version (auto-stamped), sdk_platform (auto-stamped to "web"), failure_reason, run_context (ci | dogfood | customer-app), run_id, and optional test_file / test_name from input.testRef.
  • Bundle size: core ESM/CJS/react/vue budgets raised to 45 KB gzipped (from 41 KB), UMD min to 26 KB (from 23 KB) to accommodate the inlined contracts dataset (~3 KB gzipped) + the query helpers + the new public types. Still well below every single-pillar competitor's ceiling (Mixpanel 55, Sentry 30 errors-only, PostHog 40 analytics-only) for a one-bundle three-pillar SDK that now also ships its own verification dataset.

Changed:

  • Contract registry source files migrated from snake_case to camelCase keys (appliesTo, codeRef, testRef, registeredAt, firstRegisteredIn). The bundled contracts.json sidecar shipped with this release uses the new keys. bundledIn is added at build time, never present in source. See contracts/README.md for the schema rationale and firstRegisteredIn (immutable) vs bundledIn (build-stamped) split.
Web v1.4.2 patch

Patch — second npm publish pipeline fix. v1.4.1 fixed the Node 24

navigator test mutation, but the prepublishOnly hook still ran the Playwright e2e suite at npm publish time even though the publish workflow doesn't install Chromium. Removed test:e2e from

prepublishOnly — the publish workflow runs lint + unit tests + build + size budget which covers everything except the browser-bound e2e (which requires Playwright setup the publish workflow doesn't provide; e2e still runs in monorepo CI). v1.4.2 is the first 1.4.x line to actually land on the npm registry.

No SDK code changes vs v1.4.0 / v1.4.1.

Web v1.4.1 patch

Patch — Node 24 compatibility fix for the npm publish pipeline. The

consent.test.ts DNT cases mutated globalThis.navigator via direct assignment; Node 24 (the public crossdeck-web repo's npm publish workflow Node version) made navigator a read-only getter, so the test threw TypeError: Cannot set property navigator and aborted the publish. Pattern switched to Object.defineProperty. v1.4.0 was tagged on the public GitHub repo but never reached npm — v1.4.1 is the first 1.4.x line to land on the npm registry. No SDK code changes vs v1.4.0; the entire bank-grade reconciliation surface documented below ships unchanged.

Web v1.4.0 minor

Bank-grade reconciliation release. 6-pillar KPMG-style audit closed across SDK + backend. Every behavioural guarantee registered in the monorepo's contracts/ directory with a CI-enforced audit job — drift is now a PR-time error.

Added

  • Deterministic Idempotency-Key on syncPurchases(). Derived from the request body (SHA-256 of crossdeck:purchases/sync:<rail>:<jws>, formatted as UUID). Same purchase → same key → backend short-circuits with idempotent_replay: true. Cross-SDK parity oracle CI-pinned: every SDK produces a66b1640-efaf-bb4d-1261-6650033bf111 for the canonical test vector.
  • Per-user entitlement cache isolation. Storage key is now crossdeck:entitlements:<sha256(userId)> — a user-switch on a shared device cannot physically read prior user's cached entitlements even if the in-memory clear is somehow skipped. reset() wipes EVERY per-user slot via the persisted index. New pure-JS SHA-256 helper (no SubtleCrypto async cascade through hot-path reads).
  • PurchaseResult.idempotent_replay?: boolean — true when the response came from the backend's idempotency cache instead of fresh processing.
  • purchase.completed event on every successful syncPurchases() — schema matches the auto-track event so cross-platform funnels reconcile.
  • 15 backend-emitted error codes added to crossdeck-error-codes.json catalogue (invalid_api_key, origin_not_allowed, bundle_id_not_allowed, package_name_not_allowed, env_mismatch, idempotency_key_in_use, rate_limited, internal_error, google_not_supported, stripe_not_supported, etc.) — getErrorCode() now returns Stripe-style remediation for every wire code instead of undefined.

Changed

  • init() re-entry now drains the prior EventQueue's pending timer before swapping this.state. Pre-1.4.0 the timer fired AFTER the state swap, sending old-init events under new-init identity — cross-identity leak during HMR / config swap / multi-tenant SDK shells.
  • Default event-queue flush interval is now 2000ms (was 1500ms) — parity with every other Crossdeck SDK on the Stripe-adjacent industry norm.
  • reset() now wipes every per-user entitlement slot on the device via the persisted index, not just the active user's slot.

Patch fix for the 1.3.0 dist-load contract. 1.3.0 introduced

import { version } from "../package.json" to keep the runtime

Crossdeck-Sdk-Version header in lockstep with the published bundle. Esbuild inlined the JSON correctly so the published bundle still shipped the right version on the wire, but the dist-loading test that dynamic-imports the built .mjs files was hitting Vitest's 5s default test timeout while Node evaluated the bundle.

Fixed

  • Removed the runtime JSON import. SDK_VERSION is now sourced from a generated src/_version.ts file (produced by scripts/sync-sdk-versions.mjs from package.json). The wire contract is unchanged; the build artefact no longer carries a JSON-module dependency that Node ESM requires with { type: "json" } to load from a .mjs file.
  • dist-loading test timeout bumped to 60s. The dynamic-imports of 100KB+ bundles are genuinely slow on cold Node (~45s measured for vue.mjs); the assertions themselves are sub-millisecond.

1.3.0 was never published to npm; the only consumers are the public GitHub repo's v1.3.0 tag (left in place for traceability). 1.3.1 is the first 1.3.x line to reach npm.

Swift v1.3.0 minor

Bank-grade identity lock — the Apple Bundle ID is now sent on every request and enforced server-side, mirroring the Origin lock the Web SDK has always had.

Added — Apple Bundle ID identity claim

Every HTTP request the SDK fires now carries an

X-Crossdeck-Bundle-Id header sourced from

Bundle.main.bundleIdentifier — the OS-canonical ID Apple itself uses for App Store identity.

The Crossdeck backend's isBundleIdAllowed() validator enforces this against the bundleId stored on the iOS app key. Requests without the header, or with a mismatched value, are rejected with 403 / bundle_id_not_allowed.

Bank-grade contract — same shape as the Web SDK's Origin lock:

  • empty stored bundleId on the key → request rejected
  • missing header on the request → request rejected
  • exact-match required (case-sensitive — Apple's own convention)

Migration

Customers must: 1. Bump SPM Dependency Rule to v1.3.0. 2. Rebuild + resubmit to App Store Connect. 3. Confirm apps.ios.bundleId is set on the project's iOS app in the Crossdeck dashboard (Apps → Bundle ID editor).

Apps shipped with v1.2.0 or earlier will start receiving 403s once the backend enforcement deploys, because they don't send the new header.

Xcode → File → Packages → Update to Latest Package Versions ·GitHub release
Swift v1.2.0 minor

Full bank-grade parity with the Web/Node/RN SDKs. v1.1.0 closed the ergonomics gap (non-throwing track/identify/reset); v1.2.0 closes every remaining gap that a serious customer would notice — auto- tracking, performance vitals, mobile lifecycle, App Store privacy manifest, and ambient signal modules.

Added — Auto-tracking (sessions + screens + taps)

Cross-platform event vocabulary identical to Web SDK so a single dashboard query returns Web + iOS + Android rows uniformly:

  • session.started / session.ended with sessionId + durationMs. 30-minute idle threshold matches GA4 / Mixpanel / Web SDK convention — a quick app-switch keeps the same session.
  • page.viewed — fires automatically on every UIViewController.viewDidAppear via method swizzling. Skips framework hosts (UINavigationController, UIHostingController, _SwiftUI types). 250ms dedup window collapses push/pop animation double-fires.
  • element.clicked — fires on every UIControl action (UIButton, UISwitch, UISlider, UISegmentedControl) AND on SwiftUI button taps via UIWindow.sendEvent capture. Captures accessibilityLabel, accessibilityIdentifier, class name, viewport coordinates.

Every event is enriched with the current sessionId so funnels work without explicit instrumentation.

Privacy guardrails baked in:

  • Secure text fields, accessibility labels containing password / card / ssn / credit / cvv / pin are skipped silently.
  • Opt-out per element via accessibilityIdentifier containing cd-noTrack — Mixpanel-style convention familiar to iOS devs.
  • 100ms tap-coalesce defeats React-Native-style double-fires.

Configurable via CrossdeckOptions(autoTrack: .off) for strict- consent flows, or feature-grained:

CrossdeckOptions(
    autoTrack: AutoTrackConfig(
        sessions: true,
        screenViews: true,
        taps: false,  // disable tap autocapture only
        sessionResumeThresholdSeconds: 30 * 60
    )
)

Added — PrivacyInfo.xcprivacy bundled in the SDK

Apple began enforcing the required-reason API manifest at App Store Connect submit in May 2024. Without one, every embedding app is rejected. Crossdeck now ships its own PrivacyInfo.xcprivacy declaring:

  • NSPrivacyAccessedAPICategoryUserDefaults reason CA92.1
  • NSPrivacyAccessedAPICategorySystemBootTime reason 35F9.1
  • NSPrivacyTracking: false (we do not link identity across third parties)

Consumer apps inherit the manifest automatically via SPM's resource copy — no copy-paste, no one-off rejections.

Added — MetricKit performance vitals (opt-in)

Mirrors Web SDK's web-vitals.ts. Set

CrossdeckOptions(enablePerformanceMonitoring: true) to receive:

  • perf.metrics — daily aggregate (cold launch samples, resume samples, hang samples, peak memory, cumulative CPU).
  • perf.hang — near-real-time UI-blocked diagnostics with hangDuration + metadata.
  • perf.cpu_exception — sustained CPU spike diagnostics.
  • perf.disk_write_exception — high-volume disk write diagnostics.
  • perf.crash_diagnostic — MetricKit's process-fatal exception pipeline (complement to NSSetUncaughtExceptionHandler).

iOS 14+ / macOS 12+. Off by default — payload size is meaningful and not every customer wants the signal.

Added — Proactive network-edge flush

NWPathMonitor watches reachability. On offline → online transitions, the event queue flushes immediately instead of waiting for the next 5-second timer. Closes the latency gap on intermittent connections (subway, airplane mode toggle).

ON by default via CrossdeckOptions(enableReachabilityFlush: true). iOS 12+ / macOS 10.14+.

Added — Automatic StoreKit 2 purchase tracking (opt-in)

CrossdeckOptions(automaticPurchaseTracking: true) installs a

Transaction.updates AsyncSequence consumer. Every signed transaction (purchase, restore, renewal, refund, family-shared) flows to /purchases/sync via the same HTTP path syncPurchases() uses AND fires a public funnel event:

  • purchase.completed for new transactions
  • purchase.refunded for revoked transactions (carries revocationReason)
  • purchase.unverified for transactions Apple's signature check fails — fraud-signal candidate, never synced to backend

iOS 15+. Off by default because most apps already invoke

syncPurchases() from their own confirmation flow.

Added — Deep-link + push interaction tracking helpers

Public API surface for the consumer to forward intent from their SceneDelegate / UNUserNotificationCenter:

  • cd.trackDeepLink(url:source:) — extracts UTM + click-id query parameters (gclid, fbclid, msclkid, ttclid, li_fat_id, twclid) as top-level properties. Fires deeplink.opened.
  • cd.trackPushReceived(userInfo:) / trackPushInteraction(userInfo:actionIdentifier:) — surfaces marketing-platform IDs (campaign_id, message_id, etc.) without logging the alert body. Fires push.received / push.interacted.

Fixed — willTerminate flush observer

Force-quit from the app switcher previously lost up to one batch of queued events. v1.2.0 observes UIApplication.willTerminateNotification and runs queue.persistAll() so the events land on disk before the process dies. Next launch's queue rehydration ships them.

Fixed — macOS / watchOS lifecycle parity

Cmd+Q on a Mac Catalyst or pure-AppKit Crossdeck client previously fell off the lifecycle hook (only UIKit was wired). v1.2.0 adds

NSApplication.willTerminateNotification + WKExtension.applicationDidEnterBackgroundNotification branches so every Apple OS the SDK targets has a persist-on-suspend guarantee.

Migration

None required. All new modules are additive or default-OFF where they could be surprising. v1.1.0 call sites compile clean against v1.2.0.

To benefit from auto-tracking, no code change — start using the defaults. Customers who want to disable a specific signal:

CrossdeckOptions(
    // …
    autoTrack: AutoTrackConfig(taps: false),
    enableReachabilityFlush: false,
    enablePerformanceMonitoring: false,    // already default
    automaticPurchaseTracking: false        // already default
)
Xcode → File → Packages → Update to Latest Package Versions ·GitHub release
Swift v1.1.0 minor

Fire-and-forget API ergonomics — matches Mixpanel / Amplitude / Sentry / Firebase Analytics iOS conventions. Dogfood feedback flagged that requiring try? at every analytics call site is hostile in Swift even though Web/Node/RN's track() throw — Swift's compile-time enforcement makes the same shape user-hostile.

Changed — track, identify, reset no longer throw

The three most-called methods now have non-throwing signatures. Validation intent is unchanged; only the Swift-side signalling mechanism is now idiomatic.

- try? cd?.track("paywall_seen")            // v1.0.x — Swift required try?
+ cd?.track("paywall_seen")                 // v1.1.0 — clean call site

- try? cd?.identify(userId: "user_123")     // v1.0.x
+ cd?.identify(userId: "user_123")          // v1.1.0

- try? cd?.reset()                          // v1.0.x
+ cd?.reset()                               // v1.1.0

Validation failures (empty event name, empty userId, called after

stop()) now:

  • Log a warning via debugLogger with a *_dropped key naming the failure code.
  • Trigger assertionFailure in Debug builds — loud during dev, silent no-op in Release. Aligns with Apple's first-party SDK conventions (UserDefaults, URLSession, OSLog: none throw on invalid arguments).
  • Skip the actual work — the call becomes a no-op.

Migration

This is a soft break. All v1.0.x callers still compile:

  • try? cd.track(...) → compiles with a "no calls to throwing functions" warning. Drop the try? to clean up.
  • try cd.track(...) inside a do/catch → compiles but the catch becomes unreachable (warning). Drop both try and the catch.
  • Plain cd.track(...) (the v1.1.0 idiom) → compiles clean.

The non-throwing methods are:

  • track(_:properties:)
  • identify(userId:email:traits:)
  • reset()

Still throwing (legitimate runtime failure modes):

  • Crossdeck.start(options:) — config validation
  • identifyAndWait(userId:email:traits:) — network round-trip + cdcust_ return
  • forget() — network round-trip
  • getEntitlements() — network round-trip
  • syncPurchases(rail:...) — network round-trip
  • flush(), heartbeat() — network round-trip

Cross-SDK consistency

Web/Node/RN's track() keep their throwing signature because in JavaScript, an uncaught throw propagates to the global error handler without requiring try/catch at every call site. The platform contract is "track validates input and signals failure for empty name" — Swift's signalling is now language-idiomatic (assertionFailure + debug log) instead of throws.

Xcode → File → Packages → Update to Latest Package Versions ·GitHub release
Swift v1.0.3 patch

Critical compile-fix release. v1.0.0–v1.0.2 declared iOS(.v13) in

Package.swift but defaultDebugLogger() used Apple's modern

Logger API, which is iOS 14 / macOS 11 / tvOS 14 / watchOS 7+. Apps with a deployment target below those minimums failed to compile the SDK with 'Logger' is only available in iOS 14.0 or newer.

Fixed

  • defaultDebugLogger() now branches on availability. iOS 14+ uses Logger with structured privacy: .public interpolation; older OS versions fall back to the legacy os_log family (iOS 10+). Signal vocabulary identical; Console.app filtering on the com.crossdeck.sdk subsystem works on both paths.
  • Package now compiles against any deployment target ≥ iOS 13 — same floor Package.swift has always claimed.

Notes

  • No API changes. Strictly additive availability gate.
Xcode → File → Packages → Update to Latest Package Versions ·GitHub release
Swift v1.0.2 patch

Dogfood pass on the v1.0.1 surface. One additive API change to close the biggest friction point a first-time Swift dev hit walking the install path; everything else is documentation / snippet polish that ships on cross-deck.com.

Added

  • Crossdeck.current — process-singleton accessor. Returns the most-recently-started client, or nil before start has succeeded in this process / after the current client's stop is called. Thread-safe via an NSLock; safe to read from any actor or queue.

``swift // Anywhere outside a SwiftUI view (services, view models, // AppDelegate, Combine pipelines, background workers): Crossdeck.current?.identify(userId: user.id, email: user.email) Crossdeck.current?.track("paywall_seen") if Crossdeck.current?.isEntitled("pro") == true { … } ``

Inside SwiftUI views, keep using @Environment(\.crossdeck) — it participates in dependency tracking and is the idiomatic answer for view bodies. The static accessor is for the 50% of the codebase that isn't a View.

Bank-grade discipline: stop() clears the slot iff the stopped instance is the one currently advertised, so concurrent start+stop sequences on a second client never clobber the first client's slot.

Changed

  • No behaviour changes. Public API is strictly additive — every v1.0.1 caller continues to compile and behave identically.
Xcode → File → Packages → Update to Latest Package Versions ·GitHub release
Swift v1.0.1 patch

KPMG/PwC-grade audit pass on the v1.0.0 surface. Every finding the audit flagged is closed in this release. Plus one critical cross-SDK canonical rename so the Swift identity surface matches the Web/Node/RN role-model contract exactly.

Breaking — identity API renamed to match the platform contract

The v1.0.0 identify signature drifted from Web/Node/RN. v1.0.1 restores zero-drift parity. Migration is a one-line change:

- try? cd.identify(customerId: "user_847", traits: ["email": "[email protected]", "plan": "pro"])
+ try? cd.identify(userId: "user_847", email: "[email protected]", traits: ["plan": "pro"])

Specifically:

  • customerId:userId:. The previous name collided with crossdeckCustomerId (the cdcust_… canonical handle), confusing the mental model. The Web/Node/RN SDKs all use userId.
  • email is now a first-class top-level argument. Previously it was buried inside traits and missed the bank-grade identity-merge that the Web SDK gets when email is shipped separately. Now hoisted to the wire as $email on the $identify event, matching Web/Node/RN.
  • Internal customerId field on Identity renamed to developerUserId everywhere — the same name Web/Node/RN's Diagnostics.developerUserId uses.
  • Wire event field renamed from customer_id to developer_user_id (also matches what the backend ingest expects).
  • EntitlementSnapshot.customerIddeveloperUserId.
  • Identity.setCustomerIdSync(...)setDeveloperUserIdSync(...).
  • Error code missing_customer_idmissing_user_id.

The Swift SDK doc now ships native auth-provider code blocks for Sign In with Apple, Firebase Auth iOS, and Auth0 iOS, matching the Web SDK doc's coverage of Firebase / NextAuth / Clerk / Supabase / Auth0 / custom backends.

Added — sync paywall reads

  • Crossdeck.isEntitled(_:) — synchronous bool check scoped to the currently identified customer. Safe to call from SwiftUI bodies and UIKit tap handlers. Never blocks on network.
  • Crossdeck.entitlementsForCurrentCustomer() — synchronous set read. Returns nil if no customer is identified or the cache is cold for them.
  • Internally backed by NSLock-protected mirror boxes on EntitlementCache, Identity, SuperProperties, and ConsentManager. Every actor mutation updates its sync mirror atomically; reads acquire the lock only.

Fixed — bank-grade contract violations

  • NSException handler now chains into the prior handler. Previous v1.0.0 overwrote the global handler, silently breaking Crashlytics / Sentry / Bugsnag for any consumer who turned on captureUncaughtExceptions. ErrorCapture.install now captures NSGetUncaughtExceptionHandler() before registering ours and invokes the prior handler after our snapshot.
  • PII scrubber runs on $error events. Previously the error pipeline bypassed the scrubber — a try? that surfaced "user [email protected] not found" shipped raw. Now every scrubbable field on the wire $error payload (message, stack symbols, breadcrumb messages + data) is run through the configured scrubber when consent.scrubPII is true.
  • Breadcrumbs attached to $error events. Previously collected but dropped before enqueue. Now ship as error.breadcrumbs: [{timestamp_ms, category, level, message, data}] on the wire payload.
  • identify(...) unconditionally clears the entitlement cache. Previous v1.0.0 only cleared on didChange || priorId == nil. Now identifies always clear, matching the documented contract that prevents stale entitlement leaks across customer switches.
  • Self-request skip wired into captureError(_:). Errors whose URL host matches the configured ingest endpoint are dropped before processing — closes the feedback loop where a custom- middleware-wrapped ingest failure would generate an $error event that itself fails, ad infinitum.
  • track() / identify() race fixed. The pre-existing pattern read identity inside a Task, racing concurrent identify Tasks. Now reads identity synchronously on the caller's thread before spawning the enqueue task. Deterministic ordering between identify and a subsequent track.
  • Empty key validation on super-properties. register("", v) and registerOnce("", v) previously wrote a null-key entry that landed on every wire event; now silently rejected at the boundary.

Fixed — privacy + correctness

  • Errors-consent gate. The error pipeline now honours consent.errors — previously only the analytics pipeline observed consent. Consumers can independently allow analytics while denying error capture (or vice versa).
  • Removed dead code: stale (anon, cust) tuple in error capture + unused NSRange in scrubPII.

Tests

  • +19 new tests (53 → 72 total). Coverage added for: sync paywall reads from any thread, identify cache clearing under same-id idempotent calls, identify cache clearing across customer switches, stop() rejecting subsequent calls (idempotent stop), URL-stub HTTP tests covering 2xx success, 4xx permanent (400/401/422), 5xx retryable, 408 retryable, 429 + Retry-After honoured, Idempotency-Key shipped verbatim in the request header, User-Agent header carries SDK name + version.

Notes

  • Public API is additive — every v1.0.0 caller still compiles.
  • The Crossdeck class remains @unchecked Sendable with a detailed safety comment explaining the lock pattern.
Xcode → File → Packages → Update to Latest Package Versions ·GitHub release
Node v1.3.1 patch

Patch fix for the 1.3.0 dist-load contract. Mirrors the

@cross-deck/[email protected] patch — SDK_VERSION is now sourced from a generated src/_version.ts file (produced by

scripts/sync-sdk-versions.mjs from package.json) instead of a runtime import { version } from "../package.json" that needs a

with { type: "json" } assertion to load as ESM. Wire contract is unchanged. 1.3.0 was never published to npm; 1.3.1 is the first 1.3.x line to reach npm.

Node v1.3.0 minor

KPMG bank-grade audit closure. Six review batches landed five SDK PRs and a backend wiring fix that closes every P0 plus 12 of 13 P1 findings. No public method renames; one internal contract change (ErrorTracker.beforeSend is now a getter) that also removes the

Object.defineProperty workaround the node SDK shipped to compensate for the same broken contract on web. Behavioural changes to the queue and the PII scrub strictly improve correctness. The wire

Crossdeck-Sdk-Version header now reads from package.json so it cannot drift from the published bundle.

Fixed (P0)

  • PII scrub sentinel tokens aligned with the backend. [email] / [card]<email> / <card>, matching backend/src/api/lib/scrub.ts. The same event scrubbed by SDK + backend now carries the same sentinel — dashboard aggregation works again.
  • setErrorBeforeSend contract cleaned up. The ErrorTracker.beforeSend field is now a getter (() => fn | null). Removed the Object.defineProperty hack on tracker.opts that worked around the old captured-by-value bug — cleaner contract, lockstep with web.
  • Event queue drops 4xx batches. Pre-fix every catch triggered scheduleRetry with the same Idempotency-Key. A 401 (key revoked), 400/422 (malformed batch), 403 (permission), 404 (wrong baseUrl) spun the retry timer indefinitely while the backlog grew silently. New isPermanent4xx() helper hard-stops on any 4xx EXCEPT 408 / 429 (transient by spec). On permanent failure: drop the batch, increment dropped, fire onPermanentFailure(info), emit queue.permanent_failure on the EventEmitter, log via console.error regardless of debug mode.
  • Error-capture self-skip derived from baseUrl. Pre-fix hardcoded to api.cross-deck.com; customers on staging / regional / self-hosted base URLs recursed (5xx → captureHttp → enqueue → /events → captureHttp → ∞). Now strict-hostname compare against selfHostname extracted from constructor baseUrl. Closes the substring-match bypass (api.cross-deck.com.attacker.example would have matched).

Added

  • onPermanentFailure callback on EventQueueConfig, surfaced via CrossdeckServer.on("queue.permanent_failure", …) for host-app paging.
  • sdk.flush_permanent_failure debug signal in the DebugSignal vocabulary.

Changed

  • SDK_VERSION is now imported from package.json. The Crossdeck-Sdk-Version header always matches the published bundle. Single source of truth.
  • Event ingest envelope now ships environment. Pre-fix web sent it and node didn't; backend v1-events.ts cross-checks it against the API-key-derived env and rejects mismatches loudly (env_mismatch). Defence-in-depth so a "live key, env: sandbox" misconfig fails fast instead of polluting the wrong dashboard.
  • syncPurchases body spread bug. Pre-fix { rail: input.rail ?? "apple", ...input } — the ...input ran LAST and overrode the default when the caller passed rail: undefined explicitly. Reversed: { ...input, rail }.
  • PII scrub regex uses .replace() unconditionally. Dropped the .test()-gating that carried lastIndex state between calls.
  • bootHeartbeat: false no longer silences the sdk.no_durable_store warning. Pre-fix the warning lived inside emitBootTelemetry() which sat inside the bootHeartbeat gate, so the opt-out silenced the entire reason entitlementStore exists. Split into two methods: emitDurabilityWarning() (local-only, unconditional) and emitBootTelemetryEvent() (phone-home, still gated).
  • isEntitled(string) requires the cdcust_ prefix for canonical- path resolution. Pre-fix any string with a cache entry resolved through the canonical path — a small cross-tenant primitive if a tenant's userId collided with another tenant's crossdeckCustomerId. Non-prefixed strings now drop to alias lookup only.
  • Self-skip applies to breadcrumbs too, not just captureHttp. Error reports no longer carry noisy POST https://api.cross-deck.com/v1/events crumb entries.

Wiring (backend, paired)

  • v1-events ingest now honours the per-project piiAllowList. The admin management surface (v1-pii-allow-list.ts) was persisted + audit-logged but the hot ingest path never read it. The new backend/src/api/lib/pii-allow-list-cache.ts (60s TTL, single-flight) feeds the project's allow-list to scrubProperties() on every batch. HARD_LOCKED_PATTERNS are always stripped from the effective list regardless of what's in storage. (Backend-only — listed here so server-SDK consumers know defence-in-depth is fully closed.)
React Native v1.0.0 initial

First public release. Built bank-grade from day one — every audit pattern landed during the @cross-deck/web + @cross-deck/node KPMG review is baked in. Three Crossdeck pillars in one SDK, modelled on the shipping @cross-deck/web API surface so a cross-platform team writes identical call-sites:

import { Crossdeck } from "@cross-deck/react-native";

Crossdeck.init({
  appId: "app_rn_xxx",
  publicKey: "cd_pub_live_…",
  environment: "production",
});

await Crossdeck.identify("user_847");
if (Crossdeck.isEntitled("pro")) showPro();
Crossdeck.track("paywall_shown", { variant: "v3" });

Subscriptions & entitlements

  • Durable last-known-good entitlement cache. EntitlementCache.hydrate() loads from AsyncStorage during init(), so isEntitled() is correct from the first call after init() resolves — no cold-start window where a returning Pro customer reads as free.
  • An outage can never fail a paying customer down to free. A failed getEntitlements() never clears the cache; only a successful fetch replaces it. Each entitlement is still honoured against its own validUntil, so a timed-out trial still ends.
  • onEntitlementsChange(listener) subscriber API for reactive UI binding — fires after getEntitlements() / syncPurchases() / reset(). Listener errors are swallowed (a buggy consumer can't crash the SDK or other listeners) and counted in diagnostics().
  • syncPurchases({ rail, signedTransactionInfo | purchaseToken }) forwards Apple StoreKit 2 or Google Billing evidence for backend verification + entitlement projection.
  • isEntitled(key) + listEntitlements() are synchronous reads of the in-memory cache. Subscribe via onEntitlementsChange for reactive bindings.

Analytics

  • Bank-grade event queue. pendingBatch slot keeps the in-flight batch with the SAME Idempotency-Key across retries (Stripe pattern) — backend dedupe on (projectId, eventId) handles the belt-and-suspenders. Persisted blob always carries [...pendingBatch, ...buffer] via AsyncStorage so an app crash mid-flight replays the in-flight batch on the next launch.
  • 4xx hard-stop. 400 / 401 / 403 / 404 / 422 etc. drop the batch loudly: onPermanentFailure callback + console.error regardless of debug mode + dropped counter increments. Pre-fix (web/node 1.2.x and earlier) every error retried forever with the same key.
  • Exponential backoff with full jitter on retryable failures (5xx / network / 408 / 429). Honours server Retry-After when bigger than the computed window, capped at 24h as a sanity guard.
  • Hard buffer cap (1000 events). Past the cap we evict the OLDEST events and increment dropped so the developer can see the loss in diagnostics().
  • Super properties (register / unregister) and groups (group(type, id, traits)) — Mixpanel pattern, attached to every event automatically. Both cleared on reset().

Error capture

  • ErrorUtils.setGlobalHandler chains in front of RN's default handler (the red-box developer overlay) so uncaught errors AND unhandled promise rejections are captured WITHOUT breaking the dev experience. Stack frames parsed via the Hermes / JSC / V8 unified parser.
  • globalThis.fetch wrap catches 5xx + network failures. The configured selfHostname (derived from init({ baseUrl })) is excluded so a Crossdeck-side outage doesn't recurse through its own fetch-wrap. Strict hostname compare (no substring matches — api.cross-deck.com.attacker.example doesn't falsely match).
  • Per-fingerprint rate limit (5 per minute by default) defends against runaway loops. Per-session cap (100) bounds the worst case.
  • captureError(err) / captureMessage(msg) manual API for try/catch blocks + soft signals.
  • setErrorBeforeSend(hook) with the bank-grade getter contract — a hook installed AFTER init() fires on the next captured error. Pre-fix on web/node 1.2.x the hook was captured by value and silently inert if installed late.
  • Breadcrumb buffer (50 entries) auto-populated by every track() call + every fetch request (with the self-skip filter). Attached to every error report.

Privacy & compliance

  • PII scrub — defensive regex pass over every string property value before flush. Email-shaped → <email>, card-number-shaped → <card> (sentinel tokens aligned with the backend so dashboard aggregation works across SDK-scrub and backend-scrub paths). Recursive walk: nested plain objects + arrays-of-objects are visited, so a {user:{email:"[email protected]"}} payload ships scrubbed.
  • Crossdeck.consent({...}) — three independent dimensions (analytics / marketing / errors), each defaulting to true (granted). consent({analytics: false}) drops every subsequent track() silently.
  • Crossdeck.forget() — GDPR / CCPA right to be forgotten. Calls /v1/identity/forget + wipes every local state surface.

Diagnostics

  • Crossdeck.diagnostics() — stable shape whether or not init() has been called. Returns identity (anonymousId, crossdeckCustomerId, developerUserId), clock skew (server vs client Date.now() at last heartbeat), entitlement cache freshness, queue stats (buffered, dropped, in-flight, last error, consecutive failures, next retry).
  • Boot heartbeat verifies the publishable key against the Crossdeck API the moment the SDK is constructed. The dashboard's "Verify install" check turns green within ~200ms without the caller having to add an explicit call. Disable via autoHeartbeat: false for CI / tests.

Cross-cutting

  • SDK_VERSION codegen'd from package.json via scripts/sync-sdk-versions.mjs — the wire Crossdeck-Sdk-Version header can never drift from the published bundle. CI gate via --check mode catches drift before publish.
  • Identity continuity via AsyncStorage (optional peer dep) with graceful in-memory fallback when AsyncStorage isn't installed (Storybook snapshots, vitest under node).
  • TypeScript-first — strict mode, noUncheckedIndexedAccess, every public type exported.

Coverage gaps explicitly deferred

  • Auto-track sessions + deep-links (AppState lifecycle + Linking API) deferred to 1.1.0. v1.0 expects the developer to wire Crossdeck.track("screen.viewed", {...}) from their nav lib's listener. Adding AppState + Linking properly is its own design decision (background-foreground policy, session timeout semantics, cold-start vs warm-start distinction).
  • Bundle-size budget gate — RN apps don't have a per-byte CDN cost the way web does; size discipline is a v1.1 add.
Swift v1.0.0 initial

Initial release. Brings the Crossdeck Swift SDK to the same bank-grade contract as the Web and Node SDKs.

Event ingestion

  • Durable, deduplicated, batched event queue. Pending batch lives in a dedicated slot held across retries — a crash mid-flight does NOT lose the batch; it rehydrates from UserDefaults on relaunch and re-sends with the original Idempotency-Key.
  • 4xx hard stop. Permanent failures (invalid_request_error, authentication_error, permission_error) drain through the onPermanentFailure callback and never block newer events.
  • Retry-After honoured even above the local maxMs, clamped at 24h as a sanity ceiling against pathological server responses.
  • Buffer overflow drops OLDEST events, preserving the most-recent diagnostic signal.

Error capture

  • Uncaught NSException handler installs an SDK-aware bridge.
  • Manual captureError(...) for handled errors. Both paths attach a normalised stack + breadcrumb ring buffer.
  • beforeSend hook for per-error filter / mutation (return nil to drop).
  • Self-request detection: HTTP failures against the SDK's own ingest endpoint are skipped to prevent feedback loops.

Identity + entitlements

  • anonymousId persisted in UserDefaults, regenerated only on reset().
  • identify(...) unconditionally clears the entitlement cache so a switched-customer never inherits the prior user's entitlements.
  • Entitlement cache scoped on (customerId, entitlements) — reads for a different customer return nil, never the wrong set.

Privacy

  • PII scrubber on by default. <email> and <card> tokens (angle- bracketed) match the platform-wide vocabulary across Web/Node/RN and backend.
  • Recursive walk over nested dictionaries + arrays.
  • Default-deny consent state: both analytics and errors off until the consumer opts in via setConsent(...).

Concurrency

  • Strict concurrency enabled in Package.swift.
  • All shared mutable state lives behind Swift actors (EventQueue, Identity, EntitlementCache, SuperProperties, Breadcrumbs, ConsentManager).
  • All cross-actor types are Sendable.

Platforms

  • iOS 13+, iPadOS 13+, macOS 11+, tvOS 13+, watchOS 7+.
  • Zero runtime dependencies.
Xcode → File → Packages → Update to Latest Package Versions ·GitHub release
Web v1.3.0 minor

KPMG bank-grade audit closure. Six review batches landed five SDK PRs and a backend wiring fix that closes every P0 plus 12 of 13 P1 findings. No public method renames; one internal contract change (ErrorTracker.beforeSend is now a getter); behavioural changes to the queue and the PII scrub that strictly improve correctness. Default-safe: existing Crossdeck.init({...}) callsites keep working exactly the same. The wire Crossdeck-Sdk-Version header now reads from package.json so it cannot drift from the published bundle.

Fixed (P0)

  • PII scrub now walks NESTED objects. Pre-fix scrubPiiFromProperties only scrubbed top-level keys plus 1-deep arrays of strings; nested plain objects passed through unchanged. Every error.* event ships nested frames[] / breadcrumbs[] / context{} / http{} — the leak surface was broad. New impl recurses into plain objects + arrays-of-objects. Date / Map / Set / Error instances + class instances pass through untouched (the property validator owns those shapes).
  • PII scrub sentinel tokens aligned with the backend. [email] / [card]<email> / <card>, matching backend/src/api/lib/scrub.ts. The same event scrubbed by SDK + backend now carries the same sentinel — dashboard aggregation works again.
  • setErrorBeforeSend installed AFTER init() now actually fires. Pre-fix the ErrorTracker captured beforeSend by value at construction, so any hook a customer installed later was silently inert and their PII-redaction escape hatch ran on zero errors. Contract is now a getter; the tracker resolves the current hook on every report.
  • Event queue durability hole during flush. Pre-fix the buffer was spliced + the persistent blob saved EMPTY before awaiting the network call — a hard-crash mid-flight wiped the persisted batch and the events were lost forever. New pendingBatch slot keeps the in-flight batch in the persisted blob until the server confirms it. Side benefit: retries now reuse the same Idempotency-Key (Stripe pattern, brings web in lockstep with node).
  • identify() cross-customer cache leak. Pre-fix the entitlement-cache clear was gated on priorCdcust && new && prior !== new, missing two real scenarios where a previous user's entitlements leaked to a new login (ITP / partial cookie eviction wiped cdcust but left the cache; rehydration from a pre-persisted-identity legacy install). New contract: clear when the resolved cdcust differs OR the cache is non-empty under an unknown identity.
  • Error-capture self-skip derived from baseUrl. Pre-fix hardcoded to api.cross-deck.com; customers on staging / regional / self-hosted relay base URLs recursed (5xx → captureHttp → enqueue → /events → captureHttp → ∞). Now strict-hostname compare against selfHostname extracted from init({ baseUrl }). Case-insensitive. Closes a subtle substring-match bypass (api.cross-deck.com.attacker.example would have matched).

Added

  • onPermanentFailure callback on the event queue. Fires when the queue drops a batch because the server returned a permanent 4xx (anything except 408 / 429). Loud console.error independent of debug mode, plus the new sdk.flush_permanent_failure debug signal. Pre-fix the queue retried 4xx forever with the same Idempotency-Key, silently growing the backlog while customers thought events were landing.
  • onPermanentFailure({ status, droppedCount, lastError }) is also exposed on the underlying EventQueueConfig for embedders wiring their own diagnostics surface.
  • Event-validation regression: DAG sibling sharing. Two sibling properties pointing at the SAME sub-object no longer trip a false [circular_reference] flag. The validator now uses an ancestor-only stack instead of a shared WeakSet — real cycles still flag, legitimate DAGs pass through verbatim.

Changed

  • SDK_VERSION is now imported from package.json. The Crossdeck-Sdk-Version header always matches the published bundle. Pre-fix the constant drifted independently — the published 1.2.0 bundle reported @cross-deck/[email protected] on the wire because nobody bumped the literal.
  • 4xx hard-stop on the event queue. Status codes other than 408 / 429 in the 4xx range are NOT retryable; the queue drops the batch and surfaces it via onPermanentFailure. 408 / 429 / 5xx / network errors stay retryable. RFC-correct.
  • Retry-After is honoured even above maxMs. Pre-fix the policy clamped server-supplied Retry-After to maxMs (60s default) — a Retry-After: 120 got truncated to 60s and we hammered the rate limit twice as fast as asked. New 24h absolute sanity cap against server bugs / HTTP-date clock-skew.
  • reset() clears the clock-skew snapshot. diagnostics().clock.skewMs no longer echoes the prior session's skew after logout.
  • pageviewId nulls on session boundary. Pre-fix it survived 30-min idle resets and corrupted post-resume event → pageview correlation.
  • init() re-entry tears down prior listeners (uninstallUnloadFlush, autoTracker, webVitals, errors). Pre-fix duplicate pagehide / beforeunload / visibilitychange listeners accumulated across HMR / config-swap calls.
  • PII scrub regex now uses .replace() unconditionally. Dropped the .test()-gating that carried lastIndex state between calls; the gate could false-skip strings that actually matched. Same fix on both SDKs.
  • isLocalHostname() matches 0.0.0.0 and IPv6 fe80::/10 so webpack-dev-server / Vite dev defaults and cross-device Safari Web Inspector hostnames stop polluting live analytics.
  • Self-skip applies to breadcrumbs too, not just captureHttp. Error reports no longer carry noisy POST https://api.cross-deck.com/v1/events crumb entries.
  • syncPurchases body spread bug. Pre-fix { rail: input.rail ?? "apple", ...input } — the ...input ran LAST and overrode the default when the caller passed rail: undefined explicitly. Reversed: { ...input, rail }.
  • Bundle-size budgets raised to fit the durability + permanent-failure surface (~1.5 KB gzipped of bank-grade code). core ESM 33 → 35 KB, core CJS 34 → 36 KB, react / vue ESM 33 → 35 KB, UMD 18 → 19 KB. Still well under single-pillar competitor ceilings.

Wiring (backend, paired)

  • v1-events ingest now honours the per-project piiAllowList. The admin management surface (v1-pii-allow-list.ts) was persisted + audit-logged but the hot ingest path never read it. The new backend/src/api/lib/pii-allow-list-cache.ts (60s TTL, single-flight) feeds the project's allow-list to scrubProperties() on every batch. HARD_LOCKED_PATTERNS are always stripped from the effective list regardless of what's in storage. (Backend-only — listed here so SDK consumers know defence-in-depth is fully closed.)
Node v1.2.0 minor

Added

  • Pluggable durable entitlement store (entitlementStore). A new constructor option taking an async EntitlementStore (a load / save pair) — back it with Redis, your own database, or a KV. Every successful getEntitlements() persists the result to it, and on a network failure the SDK falls back to the stored snapshot. This is what gives serverless deployments (Cloud Run / Lambda) cold-start durability that an in-memory cache alone cannot. EntitlementStore and StoredEntitlements are exported.
  • Staleness fields in diagnostics(). entitlements.staleCustomers, isStale, durableStore, and coldStartDurable — so serving last-known-good through a Crossdeck outage is observable, not silent.
  • sdk.no_durable_store debug signal, emitted once on a serverless runtime with no entitlementStore configured, alongside a durability fact on the boot telemetry event — so the cold-start gap is measurable rather than a surprise in production.

Changed

  • The entitlement cache is now durable last-known-good. isEntitled() and list() no longer expire to false / [] when entitlementCacheTtlMs elapses — they keep serving the last successfully-fetched entitlements. The TTL is now a refresh hint, not an invalidation. Each entitlement is still honoured against its own validUntil. A brief Crossdeck outage can no longer fail a paying customer down to free 60 seconds after a warm.
Web v1.1.0 minor

Added

  • Durable last-known-good entitlement cache. The entitlement cache is now persisted to device storage and re-hydrated synchronously on SDK boot, so isEntitled() is correct from the very first call for a returning customer — there is no cold-start window where a paying user flashes as free. A failed getEntitlements() never clears the cache; only a successful fetch replaces it, so a brief Crossdeck outage cannot fail a paying customer down to free. Each entitlement is still honoured against its own validUntil, so a timed-out trial still ends.
  • Cache staleness signal. diagnostics().entitlements.stale flags when the cache is serving last-known-good after a failed refresh attempt, or after the data has aged past 24h — making serving-through-an-outage observable instead of a silent unbounded window.

Changed

  • The entitlement cache is cleared on reset() and on an identity switch, so a prior user's entitlements never leak to the next person on the same device.
  • Bundle-size budgets raised to fit the durable cache. Device-storage persistence, boot hydration, and refresh-failure tracking are ~0.8 KB gzipped of real code. New gzipped budgets: core ESM 32 → 33 KB (32.81 KB actual), core CJS 33 → 34 KB (33.27 KB actual — CJS also carries CommonJS boilerplate the ESM build avoids), react.mjs / vue.mjs 32 → 33 KB. The UMD build holds at 18 KB. Still well under the single-pillar competitive ceiling — Sentry 30 KB (errors only), Mixpanel 55 KB (analytics only), PostHog 40 KB (analytics only) — for one bundle that ships all three pillars. See sdks/web/scripts/check-bundle-size.mjs.
Node v1.1.1 patch

Changed

  • Ported the "never silently surface an Unknown error" hardening to @cross-deck/node — a captured error with no usable type or message is now labelled precisely instead of collapsing to Unknown error.
CLI v1.1.1 patch

Fixed

  • Trailing-slash normalisation no longer corrupts Sentry sentinel schemes. Before: --url-prefix app:/// produced app:/file.js on the wire because the naive replace(/\/+$/, "") ate the empty-host slashes. Now it preserves scheme://[host]/ and only collapses extras on the path part — app:/// stays app:///, https://x.com/a// becomes https://x.com/a/. Covered by 8 new test cases in normaliseUrlPrefix.
npm install -D @cross-deck/cli@latest ·npm
CLI v1.1.0 minor

Dogfood-driven polish from installing the CLI against a real Crossdeck backend project. Three customer-surfaced gaps, all closed.

Added

  • crossdeck doctor — Sentry/Stripe-pattern install diagnostic. Validates auth token shape, derives environment from the key prefix, and proves API reachability without uploading anything. The pre-flight check customers should run once before wiring the CLI into CI.
  • CROSSDECK_SECRET_KEY env var as the canonical name for the auth token. Matches every other Crossdeck SDK we publish. CROSSDECK_AUTH_TOKEN is honoured as a back-compat alias for users who set it during the v1.0.x window.
  • --url-prefix now accepts Sentry-style sentinel schemes for non-browser bundles. app:/// for server-side Node / Cloud Functions / Lambda, webpack:// for worker bundles, capacitor:// and react-native:// for native shells. http(s) browser URLs continue to work unchanged.

Changed

  • "No maps found" hint now lists TypeScript's tsc alongside Vite / Webpack / ESBuild, and calls out the exact tsconfig.json settings required ("sourceMap": true, "inlineSources": true). The previous text mentioned bundler-only configurations.
  • --url-prefix validation error names the actual supported forms (browser, server, native) instead of "must be http(s)".
npm install -D @cross-deck/cli@latest ·npm
Node v1.1.0 minor

Added

  • Auto-heartbeat on construction. new CrossdeckServer({...}) now fires a heartbeat in the background the moment the SDK is constructed, fire-and-forget. The dashboard's row flips LIVE within ~200 ms of the customer's process boot — no explicit .heartbeat() call required in the bootstrap. Solves the cold-start serverless verification problem at its root (function boot triggers SDK construction triggers heartbeat; the install-verifier's URL probe doubles as a cold-start waker).
  • New option bootHeartbeat?: boolean (default true). Set false for latency-sensitive cold paths that want the prior v1.0.0 caller-controlled behaviour. Implicitly disabled in testMode.

Why this is non-breaking

The boot heartbeat is fire-and-forget and swallows its own errors — the caller's code never blocks on it, never throws, and a failure (bad key, network blip, firewall) has zero effect on subsequent event flushes. Equivalent to Sentry's Sentry.init() boot session.

Node v1.0.0 major

Full three-USP server SDK release. Version-aligned with @cross-deck/[email protected]. Bank-grade quality bar — Stripe + Apple + Google VP-level QA review across two passes. 6,796 LOC of source / 6,230 LOC of tests / 398 unit tests + 19 e2e todos passing / Gate 3 fixture verifying the snippet against the built bundle. Web-SDK parity at the capability level: every Web SDK guarantee that has a server-side analogue ships here.

Added — USP 1 (errors)

  • server.captureError(err, options?) — manual try/catch capture.
  • server.captureMessage(msg, level?) — non-error signals (Sentry pattern).
  • server.setTag(key, value) / setTags(tags) / setContext(name, data) / addBreadcrumb(crumb) / setErrorBeforeSend(hook).
  • Auto-wired process.on('uncaughtException') + process.on('unhandledRejection') + globalThis.fetch wrap (5xx + network failures).
  • Stack-frame parsing (V8 + Firefox/Safari) with Node in_app heuristics for node_modules/, node:, internal/, @cross-deck/node.
  • Breadcrumb ring buffer (default 50 entries) attached to every error report.
  • djb2-fingerprinted grouping + per-fingerprint rate limit (default 5/min) + per-session cap (default 100). Fingerprint Map bounded at 4,096 with dead-entry prune + FIFO eviction.

Added — USP 2 (analytics)

  • Durable event queue: exponential backoff with full jitter, Retry-After honoured, Idempotency-Key reused on retry of the same batch (Stripe pattern).
  • flush-on-exitprocess.on('beforeExit') + SIGTERM + SIGINT drain bounded by flushOnExitTimeoutMs. Critical for Lambda / Cloud Functions where the runtime freezes between invocations.
  • server.register(properties) / server.unregister(key) / server.group(type, id, traits?) — Mixpanel-style super-properties + group analytics.
  • @cross-deck/node/auto-events subpath:
  • crossdeckExpress(server, opts?) + crossdeckExpressErrorHandler(server, opts?) (Express 4 + 5) — emits request.handled with route + method + statusCode + durationMs + userAgent + responseBytes. Captures uncaught route errors with request context.
  • wrapLambdaHandler(server, handler, opts?) — emits function.invoked / function.completed / function.failed with cold-start detection, awaits flush() before return. Extracts statusCode + responseBytes for HTTP-style returns.
  • wrapFunction(server, handler, opts?) — generic Firebase v1/v2 / Cloud Run wrap, shape-preserving.

Added — USP 3 (entitlements)

  • Per-customer TTL cache (default 60s) with LRU eviction bounded at maxCustomers (default 10,000) for long-running multi-tenant servers.
  • server.isEntitled(hint, key) — synchronous lookup after first warm. Accepts canonical customerId OR IdentityHints ({customerId, userId, anonymousId}).
  • server.listEntitlements(hint) — full snapshot.
  • server.onEntitlementsChange(listener) — subscribe to cache mutations.
  • userId / anonymousIdcrossdeckCustomerId alias map (bounded at 10,000 with FIFO eviction).
  • verifyWebhookSignature(payload, header, secret, options?) — HMAC-SHA256 + constant-time compare + 5-min replay window + multi-secret rotation.
  • signWebhookPayload(payload, secret, timestampSec) — pure helper for fixture authors.

Added — cross-cutting

  • runtime-info detection for 13 platforms: AWS Lambda, Azure Functions, Google App Engine, Firebase Functions v1/v2, Cloud Run, Vercel, Netlify, Heroku, Render, Railway, Fly.io, generic Kubernetes, plain Node fallback. Auto-attached as runtime.* on every event + error.
  • server.heartbeat() — boot validation: GET /sdk/heartbeat returns project + app metadata, throws on auth failure.
  • server.flush(): Promise<void> — explicit drain.
  • server.diagnostics() — stable shape with runtime + events + errors + entitlements blocks.
  • server.shutdown() — teardown for tests + custom lifecycles. Clears super-properties, groups, cache, aliases, breadcrumbs, error state.
  • scrubPii(value) + scrubPiiFromProperties(obj) — opt-in PII regex utilities (email + card-number shapes).
  • ConsoleDebugLogger + NullDebugLogger — NorthStar §16 debug signal vocabulary.
  • CrossdeckErrorCode literal union derived from CROSSDECK_ERROR_CODES + isCrossdeckErrorCode() type guard for type-safe code comparisons.
  • HeartbeatResponse + Diagnostics + 30+ exported types.
  • /auto-events subpath in package.json exports.

Changed

  • Breaking: track(event) is now synchronous (returns void), enqueues for batched delivery, and auto-fills anonymousId with a process-stable anon_node_… when no identity hint is supplied. The old await track(...) shape is replaced by enqueue-and-flush.
  • ingest(events[]) retains immediate-POST behaviour for bulk-import callers (no auto-fill, returns IngestResponse).
  • Secret key prefix in diagnostics() is now masked as cd_sk_(test|live)_****<last4> (Stripe pattern).

Added — QA review v2 (bank-grade SDK extras)

  • Error subclass hierarchy (Stripe pattern): CrossdeckAuthenticationError, CrossdeckPermissionError, CrossdeckValidationError, CrossdeckRateLimitError, CrossdeckNetworkError, CrossdeckInternalError, CrossdeckConfigurationError. All extend CrossdeckError. Pick the right subclass via makeCrossdeckError(payload). Constructed automatically by crossdeckErrorFromResponse().
  • CrossdeckError.toJSON() — structured-logger compatible serialisation. Includes type, code, requestId, status, retryAfterMs, stack. Critical for production observability with Pino / Winston / DataDog.
  • Crossdeck-Api-Version header on every request, pinned to CROSSDECK_API_VERSION constant. Forward-compat with backend evolution (Stripe Stripe-Version pattern).
  • User-Agent header: @cross-deck/node/<sdk> node/<node-version> <platform>. HTTP best practice. Override the runtime token via runtimeToken: "bun/1.0" in options.
  • Idempotent retry on GET methods — default 3 attempts with exponential backoff + full jitter, retrying on 408 + 5xx (except 501) and on network failures. Honours server Retry-After. POST retries stay queue-driven (with batch-level Idempotency-Key reuse). Configurable via httpRetries: { maxAttempts, retryableStatuses }.
  • testMode: true option — every HTTP call short-circuits to a synthetic success response, no network goes out. Path-aware (returns the right shape per endpoint). For caller test suites that don't want to mock globalThis.fetch.
  • onRequest / onResponse hooks on CrossdeckServerOptions. Fire on every request (including retries), carrying method, URL, status, durationMs, attempt number. Synchronous, errors swallowed — telemetry must never break the request pipeline.
  • AbortSignal pass-through on every async method. Final RequestOptions argument with { signal, timeoutMs }. Caller-aborted requests throw CrossdeckNetworkError({ code: "request_aborted" }). Composes with the per-request timeout — whichever fires first wins.
  • CrossdeckServer extends EventEmitter — typed on / once / off / emit overloads via CrossdeckServerEvents. Events: queue.flush_succeeded, queue.flush_failed, queue.dropped, queue.buffer_changed, error.captured, entitlements.warmed, sdk.shutdown.
  • Symbol.dispose + Symbol.asyncDispose — TC39 explicit resource management. using server = new CrossdeckServer(...) shuts down on scope exit; await using flushes first.
  • server.isReady(): boolean — synchronous readiness check. false on sustained retry storm (≥ 5 consecutive failures) or buffer pressure (≥ 80% of HARD_BUFFER_CAP).
  • server.awaitReady(timeoutMs?, pollIntervalMs?): Promise<boolean> — backpressure-aware wait for ready state.
  • server.getHealth() — k8s-friendly snapshot: ready, healthy, bufferedEvents, inFlight, consecutiveFailures, lastFlushAt, lastError, errorHandlersInstalled.
  • server.bulkGrantEntitlement(grants[]) + bulkRevokeEntitlement(revokes[]) — bounded-concurrency fan-out (default 5). Returns settled array; partial failures preserved as { ok: false, error } entries.

Notes

  • Bundle size: dist/index.cjs ~98 KB, dist/auto-events/index.cjs ~11 KB.
  • Zero runtime dependencies (fetch + node:crypto + node:events only).
  • 398 unit tests + 19 e2e todos passing. Source-to-test ratio ~100%.
  • Deferred to later releases: Fastify adapter (v0.3.0), Cloudflare Workers / Vercel Edge / Bun / Deno (v0.4+), OpenTelemetry / Pino / Winston log-capture integration (roadmap), HTTP keep-alive agent, request compression, SDK-level sampling.
Web v1.0.1 patch

Changed

  • Never silently surface an Unknown error again. Non-Error throws (throw { code: 500 }, custom classes, a Response from fetch) now keep their type name, message field, and useful properties (code / status / cause chain). Cross-origin script errors land with a clear label and a cross_origin: true tag instead of being silently dropped. Distinct call sites no longer collapse into one Unknown error bucket. (PR #65 — @cross-deck/node 1.1.1 later ported the same hardening.)
CLI v1.0.0 initial

Initial release.

Added

  • crossdeck upload-sourcemaps command — discovers .js + .map pairs in a build directory, batches them into ≤100-file chunks, uploads to /v1/releases/sourcemaps with a cd_sk_* secret key.
  • Directory walker that honours the trailing //# sourceMappingURL= comment for accurate .js.map pairing (Webpack, Vite, Rollup, ESBuild, Next.js).
  • Edge-case handling: inline data-URI maps (skipped with hint), missing companion .map files (skipped with hint), ?v=… query suffix on the source-map comment, .mjs and .cjs extensions, nested asset directories, automatic node_modules exclusion.
  • Auth resolution via CROSSDECK_AUTH_TOKEN env var or --auth-token flag. Publishable keys (cd_pub_*) rejected with a clear error message.
  • Optional CROSSDECK_PROJECT_ID env var / --project flag — backend infers project from the secret key but the flag lets multi-tenant CI scripts assert which tenant they expect to hit.
  • Per-batch progress callback so CI logs surface upload progress rather than blocking quietly.
  • Bank-grade error mapping: HTTP error responses become typed ApiError with status, code, and requestId so customers can correlate failures with backend logs.
  • 24 unit tests (discover, config, api-client). Coverage thresholds enforced at 80%/80% statements/branches.
  • README with bundler-by-bundler setup guide, CI examples (GitHub Actions, Vercel), exit-code reference, and privacy posture.
npm install -D @cross-deck/cli@latest ·npm
Node v0.1.0 initial

Initial server SDK release.

Added

  • Separate @cross-deck/node package with no browser assumptions.
  • CrossdeckServer constructor with secret-key validation.
  • Secret-key HTTP transport with typed CrossdeckError handling.
  • Web-parity sanitisation for traits and event properties, plus a transport backstop that converts serialization failures into stable CrossdeckErrors.
  • identify() / aliasIdentity() for server-side identity linking.
  • forget() for server-side GDPR/CCPA deletion requests.
  • getEntitlements() by customerId, userId, or anonymousId.
  • getCustomerEntitlements(customerId) server-only direct lookup route.
  • track() and ingest() for explicit server-side event ingest.
  • syncPurchases() for Apple signed purchase forwarding.
  • grantEntitlement() and revokeEntitlement() server-side manual overrides.
  • getAuditEntry() for server-side audit-log reads.
  • Dual ESM/CJS build.
  • Strict TypeScript + Vitest coverage for transport and public method routing.
Web v1.0.0 major

Error capture — the third pillar. Closes the trio: analytics + revenue/entitlements + errors all ship in one SDK. After this release the SDK covers every USP the platform sells. Bumped to

1.0.0 because every pillar is now in the box.

Backwards-compatible: every Wave 1-4 API is unchanged. New error APIs are additive. Source-compatible with 0.10.x — existing

Crossdeck.init({...}) callsites work exactly the same.

Added

  • Automatic uncaught-error capture. Global window.onerror listener catches every uncaught synchronous error. Stack traces parsed into normalised frames (Chrome / Firefox / Safari). Reported as error.unhandled Crossdeck events through the same durable + retried + idempotent queue as analytics.
  • Automatic promise-rejection capture. Global window.onunhandledrejection listener catches unhandled async failures. Reported as error.unhandledrejection.
  • Automatic HTTP-failure capture. fetch() and XMLHttpRequest are wrapped to detect 5xx + network failures the app code didn't catch. Reported as error.http. Crossdeck's own API calls are explicitly excluded so a Crossdeck outage doesn't self-amplify into the queue.
  • Crossdeck.captureError(err, { context, tags, level }) — manual capture from try/catch blocks. Sentry pattern.
  • Crossdeck.captureMessage(message, level) — non-error signals ("we hit the deprecated path", "soft warning"). Reported as error.message.
  • Crossdeck.setTag(key, value) / Crossdeck.setTags(tags) — flat key/value labels attached to every subsequent error report.
  • Crossdeck.setContext(name, data) — structured named context attached to every subsequent error report (Sentry pattern).
  • Crossdeck.addBreadcrumb(crumb) — custom breadcrumb for the rolling buffer.
  • Crossdeck.setErrorBeforeSend(hook) — pre-send filter; return null to drop, or a modified CapturedError to scrub fields. The only way to redact app-specific PII (auth tokens in URLs, etc.) before the report leaves the browser.
  • Breadcrumb ring buffer. Every analytics event auto-emits a breadcrumb. The last 50 are attached to every error report so the engineer reading the error sees exactly how the user got into the broken state. Cleared on reset() / forget().
  • Fingerprinting. Every error gets a stable 8-character hex fingerprint (djb2 of message + top 3 in-app frames). Dashboard uses this to group identical errors so 1,000 occurrences of the same bug show as 1 issue, not 1,000.
  • Rate limiting. Per fingerprint: max 5 reports per minute. Defends against runaway loops (e.g. error in setInterval). Hard session cap: 100 errors total. After that, capture stops until the next session — the developer is told via Sentry receiving "1 unique error" instead of "1 million events".
  • Noise filtering. Default ignoreErrors strips well-known browser noise (ResizeObserver loop limit exceeded, Script error., etc.). Default denyUrls strips browser-extension frames (chrome-extension://, moz-extension://, etc.).
  • autoTrack.errors: boolean flag (default true). Disable if you have a separate error tracker (Sentry, Bugsnag) and don't want duplicates.
  • consent.errors dimension (already in 0.10.0 for Web Vitals) now ALSO gates error reporting. consent({ errors: false }) silently drops every error event.
  • PII scrub runs on every error payload (stack strings, URLs, context blobs) before they leave the browser — same regex pass as the analytics path.
  • New error code in CROSSDECK_ERROR_CODES for the request_timeout / fetch_failed family already covered.
  • 47 new tests (306 total, up from 260):

- tests/breadcrumbs.test.ts — 6 cases.

- tests/stack-parser.test.ts — 13 cases covering Chrome / Firefox / Safari formats + in-app detection + fingerprinting.

- tests/error-capture.test.ts — 21 cases covering captureError, captureMessage, filtering, rate limiting, sampling, beforeSend hook, context/tags attachment, breadcrumb snapshot, consent gating.

- tests/crossdeck.test.ts — 7 new integration cases.

- tests/dist-loading.test.ts — extended to assert the new public methods exist on the built artefact.

- e2e/smoke.spec.ts — 5 new Playwright cases covering real- browser error capture (manual captureError, uncaught window.onerror, captureMessage, breadcrumb attachment, consent gate).

Changed

  • Bundle-size budgets bumped to account for the new pillar: core ESM / CJS / React / Vue from 28 KB → 32 KB; UMD from 16 KB → 18 KB. The full SDK now ships at ~30 KB gz — comparable to Sentry's @sentry/browser alone (which doesn't include analytics or revenue). All three pillars in one bundle.
  • AutoTrackOptions extended with errors: boolean.
  • track() now gates error. events on consent.errors (in addition to the existing webvitals. gate); everything else continues to gate on consent.analytics.

Compatibility

Source-compatible with 0.10.x. No public API removed. The new error capture is on by default — applications that already have Sentry installed should set autoTrack: { errors: false } to avoid duplicate reporting.

Web v0.10.0 minor

Privacy + compliance + operational pass (Waves 3 + 4). Locks down GDPR / CCPA support, ships the CDN + framework story, and publishes the error-code surface that Stripe-style integrators depend on. Backwards-compatible — every new field defaults to "don't change behaviour". Source-compatible with 0.9.x.

Added

  • Crossdeck.consent({ analytics, marketing, errors }) — three independent consent dimensions, each defaulting to true (granted). Gates track(), identify(), paid-traffic click IDs, referrer URLs, and Web Vitals appropriately. Crossdeck.consentStatus() returns the current snapshot.
  • respectDnt: true in init() — opt-in DNT support. When the browser exposes navigator.doNotTrack === "1", ALL three consent dimensions are locked OFF permanently (no subsequent consent() call can flip them back on).
  • scrubPii: true (default-on) in init() — Stripe-grade regex pass over every event property value, URL path, and title before flush. Email-shaped → <email>, card-number-shaped → <card> (tokens aligned with the backend's defence-in-depth scrubber). The walk is recursive: nested plain objects + arrays-of-objects are visited. Caller's input is never mutated. Disable for pipelines that do their own redaction.
  • Crossdeck.forget(): Promise<void> — GDPR / CCPA right to be forgotten. Calls the new /v1/identity/forget endpoint and wipes ALL local state. Idempotent. Server-side failure does NOT block local wipe.
  • @cross-deck/web/vue subpackage — Vue 3 composables (useEntitlement(key)Ref<boolean>, useEntitlements()Ref<string[]>) that mirror the React subpackage's contract. Subscribes to the entitlement cache via onEntitlementsChange. SSR-safe.
  • UMD CDN bundledist/crossdeck.umd.min.js, registered via unpkg / jsdelivr package.json fields. Exposes window.Crossdeck for no-build-step consumers (plain HTML, Webflow, docs). 13 KB gzipped.
  • CROSSDECK_ERROR_CODES + getErrorCode(code) — machine-readable index of every error code the SDK can throw, with description, resolution, and retryable flag. Also emitted as dist/error-codes.json sidecar. Stripe pattern.
  • Bundle-size budget enforcementnpm run size (also runs in prepublishOnly) fails the release if any artefact exceeds its gzipped budget. Current ceilings: 28 KB for core / framework subpackages, 16 KB for UMD.
  • New debug signals: sdk.consent_changed, sdk.consent_denied, sdk.consent_dnt_applied, sdk.pii_scrubbed.

Backend changes (paired)

  • POST /v1/identity/forget — new endpoint. Resolves the customer from any identity hint, sets forgottenAt: now on the customer record, queues a forgetRequests row for the retention-cleanup worker to drain.
  • POST /v1/identity/alias — now accepts optional traits in the body and persists them under customers/{cdcust}.traits additively (per-key merge). Defence-in-depth sanitisation server-side: max 32 keys, 1 KB per value, primitives only.

Compatibility

Source-compatible with 0.9.x. The new defaults (scrubPii: true, respectDnt: false) preserve existing analytics shape for current consumers. The Vue subpackage adds an optional peer dependency declared in peerDependenciesMeta — non-Vue consumers don't install it.

Web v0.9.0 minor

Data completeness pass (Wave 2). Closes the gap between Crossdeck's event surface and Mixpanel / Segment / Amplitude. Backwards-compatible — Crossdeck.init({...}) callsites don't need to change; the new APIs are additive.

Added

  • Crossdeck.identify(userId, { traits }) — accept profile traits (name, plan, signupDate, role) alongside the email field. Traits are sanitised at the SDK boundary and persisted server-side on the customer record under customers/{cdcust}.traits (per-key merge, additive — a later identify call with { plan: "pro" } doesn't wipe a prior call's { name: "Wes" }). Defence-in-depth server validation: max 32 keys, 1 KB per value, primitives only.
  • Crossdeck.register(properties) + unregister(key) + getSuperProperties() — Mixpanel "super properties" pattern. Set keys once, attached to every subsequent event of this SDK instance. Null value deletes a key. Persists across page reloads via the identity storage; cleared on reset() / forget().
  • Crossdeck.group(type, id, traits?) + getGroups() — Mixpanel / Segment "Group Analytics". Each event carries $groups.<type>: id for B2B SaaS dashboards. Multiple types coexist (org + team + plan). Pass id: null to clear a group membership.
  • Paid-traffic click ID capturegclid (Google Ads), fbclid (Meta), msclkid (Microsoft), ttclid (TikTok), li_fat_id (LinkedIn), twclid (X / Twitter). Captured at session start alongside UTMs, attached to every event of the session.
  • pageviewId — stable per-page-view identifier minted on every page.viewed and attached to every subsequent event until the next page.viewed. Mixpanel's $current_url-style correlation — lets dashboards answer "user clicked X on page Y" without timestamp arithmetic.
  • Web Vitals capturewebvitals.lcp, webvitals.inp, webvitals.cls, webvitals.fcp, webvitals.ttfb events emitted via PerformanceObserver. LCP / CLS / INP flush at page hidden (final values only known after user activity stops). New autoTrack.webVitals flag (default true). Hand-rolled (~120 lines), zero runtime deps.

Changed

  • IdentifyOptions extended with traits?: Record<string, unknown>. Existing email-only callers unaffected.
  • AutoTrackOptions extended with webVitals: boolean. Existing init() callsites without autoTrack get it default-on.
  • SessionAcquisition extended with the six paid-traffic click-ID fields. Existing acquisition consumers unaffected — fields are empty strings when not present.

Compatibility

Source-compatible with 0.8.x. No public API removed. Every new field defaults to a sensible value that preserves the previous behaviour for existing callsites.

Web v0.8.0 minor

Bank-grade plumbing pass (Wave 1). Six closely-coupled hardenings that bring the SDK's reliability surface up to Stripe / Segment / Mixpanel standards. Backwards-compatible: no public API removed, every new option has a sensible default, every behaviour change is additive. Source-compatible with 0.7.x — Crossdeck.init({...}) callsites do not need to change.

Added

  • Durable event queue. Queued events are now written through to the SDK's identity store (typically localStorage) so a hard browser crash, power loss, or terminal-flush keepalive: true cap exceedance (64 KB) doesn't lose data. On the next SDK boot the persisted queue is rehydrated and replayed. Backend dedupes by eventId so a replayed event already on the wire when the tab crashed is safe — ReplacingMergeTree handles it. New module event-storage.ts (PersistentEventStore). Skipped when persistIdentity: false (strict-consent flows).
  • Exponential backoff with full jitter on flush failures. Replaces the prior "retry on the next idle window" policy which hot-looped a flapping endpoint. Defaults: baseMs=1000, factor=2, maxMs=60000. Each failure schedules the next flush at min(maxMs, baseMs 2^attempts) Math.random() ms out. Reset on success. Surface via diagnostics().events.consecutiveFailures + nextRetryAt. New module retry-policy.ts (RetryPolicy, computeNextDelay).
  • Retry-After header support on 429 / 503. The HTTP layer now parses the header (delta-seconds or HTTP-date per RFC 7231 §7.1.3) onto CrossdeckError.retryAfterMs, and the retry policy honours it when it's longer than the computed backoff. Stripe pattern — the server is the authority on its own pressure.
  • Idempotency-Key header per batch. Every /v1/events POST now carries Idempotency-Key: batch_<rand>. Retries of the SAME logical batch reuse the SAME key so a future server-side idempotency layer can short-circuit duplicate work without inspecting bodies. Per-event eventId dedup remains in place — this is belt-and-suspenders.
  • Request timeout via AbortController. New timeoutMs option on CrossdeckOptions and per-request options.timeoutMs on HttpClient.request(). Default 15 000 ms. Without this, a captive portal / DNS hang / satellite link could leave a request open for the browser's default (5+ minutes on Chrome) and lock the queue forever. Pass timeoutMs: 0 to disable (useful for tests). New error: CrossdeckError({ type: "network_error", code: "request_timeout" }).
  • Property validation at enqueue. track(name, properties) now sanitises properties BEFORE the event lands in the queue. New module event-validation.ts. Behaviour:

- Drops functions, symbols, undefined values (with a debug warning).

- Coerces Date → ISO string, BigInt → string, Error{ name, message, stack }, Map → plain object, Set → array.

- Truncates string values longer than maxStringLength (default 1024) with an ellipsis.

- Replaces circular refs with "[circular]" and depth > 5 nesting with "[depth-exceeded]".

- Caps total per-event property byte size at maxBatchPropertyBytes (default 8 KB); past the cap, largest properties drop first and a __truncated: true marker is added.

- Caller's input is never mutated — sanitisation always produces a defensive copy.

- Output is guaranteed JSON.stringify-safe. One bad property can no longer poison the entire batch indefinitely.

  • Listener-error counter on EntitlementCache. Listener exceptions are still swallowed (a buggy consumer must not crash the SDK) but the cumulative count is now surfaced as diagnostics().entitlements.listenerErrors so a broken subscriber can be spotted without a debug session.
  • Clock-skew diagnostics. Crossdeck.heartbeat() now captures the server's serverTime and the local Date.now() at the same moment. Surfaces via diagnostics().clock.{lastServerTime, lastClientTime, skewMs} so a wrong-system-clock problem (kid changed the date, dev machine bad NTP) surfaces in dashboards before it corrupts a day of analytics.
  • New debug signals: sdk.property_coerced, sdk.queue_persisted, sdk.queue_restored, sdk.flush_retry_scheduled. Fire in debug mode only — quiet by default.
  • 65 new tests (203 total, up from 138):

- tests/event-validation.test.ts — 19 cases covering every coercion / drop / truncation / depth / size-cap path + JSON-roundtrip + no-mutation guarantee.

- tests/event-storage.test.ts — 8 cases covering load / save round-trip, debouncing, malformed-blob recovery, version sentinel, throwing-storage degradation.

- tests/retry-policy.test.ts — 12 cases covering backoff math, jitter, Retry-After precedence, attempt overflow safety, counter reset.

- tests/event-queue.test.ts — 9 new cases covering Idempotency-Key uniqueness, retry scheduling, server Retry-After honouring, durable rehydration, write-through, persistent clear on success, reset() wipe.

- tests/http.test.ts — 5 new cases covering Idempotency-Key passthrough, abort-timeout behaviour, per-call timeout override, 0-disables-timeout, Retry-After parse onto retryAfterMs.

- tests/errors.test.ts — 9 new cases covering parseRetryAfterHeader for delta-seconds, HTTP-date, past dates, malformed input.

- tests/entitlement-cache.test.ts — 1 new case covering the listener-error counter.

- tests/crossdeck.test.ts — 1 new case asserting the full Wave-1 diagnostic surface.

Changed

  • CrossdeckError now carries an optional retryAfterMs field, populated from the response's Retry-After header on 4xx/5xx.
  • Diagnostics shape extended with:

- clock: { lastServerTime, lastClientTime, skewMs }

- entitlements.listenerErrors: number

- events.consecutiveFailures: number, events.nextRetryAt: number | null

  • Existing Diagnostics fields and their semantics are unchanged.

Migration

No callsite changes required. New options (timeoutMs, retry tuning) default to sensible bank-grade values. To opt out of property validation, pass already-clean property objects — there's no escape hatch, and there shouldn't be: an SDK that lets one bad event poison the whole batch isn't bank-grade.

Web v0.6.0 minor

Bank-grade analytics enrichment. Two additive changes that close the gap between Crossdeck's analytics surface and Google Analytics 4 / Google Ads dashboards: identity continuity that survives cleared storage, and first-touch acquisition attribution attached to every event of a session. No public API changes — Crossdeck.init({...}) callsites do not need to change.

Added

  • Identity continuity — dual-store redundancy. The SDK now writes anonymousId and crossdeckCustomerId to BOTH localStorage (primary) and a 1st-party document.cookie (secondary). On boot it reads both and prefers primary; if primary is empty, it recovers from the cookie and resyncs primary. This protects against ITP localStorage purges, "clear site data" actions, and aggressive privacy extensions — a returning user keeps the same Crossdeck identity instead of becoming a phantom new visitor on dashboards. See sdks/SDK_TRUTH.md § "Identity continuity — bank-grade redundancy" for the full contract.
  • CookieStorage adapter in storage.ts. Sets Path=/, Max-Age=63072000 (2y), SameSite=Lax, Secure (when over HTTPS — omitted on http://localhost so dev works without a TLS cert). Encodes/decodes cookie names + values defensively so embedded ; and = survive round-trip.
  • First-touch acquisition capture in AutoTracker. On every session.started the SDK reads window.location.search and document.referrer and captures utm_source, utm_medium, utm_campaign, utm_content, utm_term, plus referrer. Non-empty values are auto-attached to every subsequent event of that session — matching GA4's session-pinned attribution semantics. SPA route changes mid-session do NOT re-read the URL; a new session (>30 min idle, or explicit resetSession()) re-captures off the current URL.
  • AutoTracker.currentAcquisition getter. Returns the captured-once-per-session acquisition context for inspection / tests / framework bindings. Returns empty strings (not undefined) when there's no active session so callers can spread without conditional logic.
  • captureAcquisition() exported from auto-track.ts for unit testing acquisition extraction in isolation.
  • 18 new tests (138 total, up from 120):

- storage.test.ts — 6 cases covering CookieStorage round-trip, URL-encoding survival, attribute emission (Path / SameSite / Max-Age / Secure on HTTPS, Secure-omitted on HTTP), null on broken cookies, no-op in Node (no document).

- identity.test.ts — 6 cases covering the redundancy contract: writes-to-both, recovery from secondary when primary cleared, recovery from primary when secondary cleared, primary-wins-on-conflict, set/reset both, defence-in-depth against a throwing secondary.

- auto-track.test.ts — 5 cases: captureAcquisition reads utm_*, returns empty for clean URLs, currentAcquisition is session-pinned (SPA navigation does NOT change it mid-session), resetSession re-captures off the current URL, returns empty when no session exists.

Server-side enrichment (lands without an SDK upgrade)

The 0.6.0 SDK pairs with these backend changes that started populating ClickHouse columns ahead of this release — every existing 0.5.0 install starts seeing them in dashboards immediately:

  • Geographyevents.country populated from the Cloudflare CF-IPCountry header at /v1/events. Server-decided, not client-trusted.
  • New vs returningevents.is_new populated by a Firestore-transactional visitors/{anonymousId} upsert in the ClickHouse projector. First event for a new anonymousId wins the race; concurrent inserts converge.
  • Device hoistevents.browser, events.os, events.device_class hoisted out of properties_json to first-class LowCardinality columns for fast slicing.
  • Acquisition columnsevents.utm_source, events.utm_medium, events.utm_campaign, events.utm_content, events.utm_term, events.referrer_host populated from event properties (which the 0.6.0 SDK now sends; pre-0.6.0 events get empty strings).
  • Sessionssessions table aggregates the same enrichment columns via any / max (for is_new) so per-session breakdowns don't have to fan out across raw events.
  • ClickHouse migration 006_analytics_columns.sql is idempotent and additive — old rows already in events keep working with empty / 0 defaults.

Privacy posture

Privacy posture is unchanged from single-store identity. The cookie holds only the same anonymousId already in localStorage — no fingerprintable data, no PII. Anything that can read localStorage on the same origin can read this cookie; the security model is identical to Stripe, Segment, and PostHog's 1st-party identity cookies. persistIdentity: false continues to disable all persistence (in-memory only) for customers running strict consent flows.

Compatibility

Source-compatible with 0.5.0. No public API changes. No deprecated symbols. Existing snippets do not need to change.

Web v0.4.0 minor

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.

Added

  • 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.
  • @cross-deck/web/react subpath export — first-class React hooks built on top of the subscribe API:

- useEntitlement(key): boolean — re-renders the component the moment the cache mutates so a JSX snippet like useEntitlement("pro") && <ProBadge /> actually works.

- useEntitlements(): readonly string[] — reactive list of all active entitlement keys.

  • 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.
  • 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.
  • 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.

Why this exists

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.

Build

  • 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.
  • React is now an optional peer dependency (react >=18).

Compatibility

Source-compatible with 0.3.0. No breaking changes — onEntitlementsChange and the React hooks are purely additive.

Web v0.3.0 minor

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.

Added

  • 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.
  • Crossdeck.flush() — alias of the old flushEvents(), matching the standardised name.
  • Crossdeck.syncPurchases(input) — replaces purchaseApple. Posts to /v1/purchases/sync and accepts an optional rail field for future Stripe/Google support.
  • 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).
  • 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.
  • 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.

Changed

  • 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.
  • 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.
  • The not_started configuration error code is now not_initialized to match the rename.

Removed

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.

Web v0.2.0 minor
  • Added auto-tracking: sessions, page views, and device-info enrichment are on by default in browsers. See autoTrack config to disable individually or wholesale.
  • Stable Diagnostics shape regardless of whether start() has been called — pre-start values are sensible empties.
Web v0.1.0 initial

Initial public release.