@wireai/activation 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/AGENTS.md +51 -0
  2. package/CHANGELOG.md +111 -1
  3. package/INTEGRATION_PROMPT.md +13 -1
  4. package/README.md +106 -4
  5. package/dist/analytics/index.d.mts +35 -6
  6. package/dist/analytics/index.d.ts +35 -6
  7. package/dist/analytics/index.js +222 -94
  8. package/dist/analytics/index.js.map +1 -1
  9. package/dist/analytics/index.mjs +214 -95
  10. package/dist/analytics/index.mjs.map +1 -1
  11. package/dist/{currentSession-D0Vq7_VE.d.ts → currentSession-D6RiVtc8.d.ts} +187 -29
  12. package/dist/{currentSession-DdDkprpM.d.mts → currentSession-DsSDHqor.d.mts} +187 -29
  13. package/dist/index.d.mts +236 -82
  14. package/dist/index.d.ts +236 -82
  15. package/dist/index.js +330 -60
  16. package/dist/index.js.map +1 -1
  17. package/dist/index.mjs +314 -61
  18. package/dist/index.mjs.map +1 -1
  19. package/dist/questionnaire/index.d.mts +1 -1
  20. package/dist/questionnaire/index.d.ts +1 -1
  21. package/dist/questionnaire/index.js +59 -8
  22. package/dist/questionnaire/index.js.map +1 -1
  23. package/dist/questionnaire/index.mjs +59 -8
  24. package/dist/questionnaire/index.mjs.map +1 -1
  25. package/dist/reviews/index.d.mts +2 -2
  26. package/dist/reviews/index.d.ts +2 -2
  27. package/dist/reviews/index.js +97 -17
  28. package/dist/reviews/index.js.map +1 -1
  29. package/dist/reviews/index.mjs +97 -17
  30. package/dist/reviews/index.mjs.map +1 -1
  31. package/dist/{transport-Bzb-bcB2.d.mts → transport-CF_eHwzC.d.mts} +15 -16
  32. package/dist/{transport-B31G0Cib.d.ts → transport-DsRe4epC.d.ts} +15 -16
  33. package/llms.txt +9 -0
  34. package/package.json +1 -1
  35. package/src/activation/useWireActivation.ts +12 -1
  36. package/src/activation/wireActivation.ts +36 -24
  37. package/src/analytics/analyticsFacade.ts +113 -29
  38. package/src/analytics/currentSession.ts +83 -0
  39. package/src/analytics/eventQueue.ts +20 -11
  40. package/src/analytics/index.ts +29 -1
  41. package/src/analytics/reportClientEvent.ts +50 -2
  42. package/src/analytics/screenTracking.ts +6 -1
  43. package/src/analytics/useAnalytics.ts +22 -1
  44. package/src/context/deviceId.ts +109 -0
  45. package/src/context/userContext.ts +73 -0
  46. package/src/identity/userIdentity.ts +10 -0
  47. package/src/index.ts +50 -2
  48. package/src/questionnaire/runtime.ts +12 -2
  49. package/src/questionnaire/transport.ts +5 -1
  50. package/src/questionnaire/useQuestionnaireGate.ts +9 -7
  51. package/src/revenuecat/index.ts +55 -0
  52. package/src/revenuecat/purchaseEvents.ts +167 -0
  53. package/src/revenuecat/revenueCatBridge.ts +221 -0
  54. package/src/revenuecat/types.ts +95 -0
  55. package/src/reviews/decision.ts +8 -1
  56. package/src/reviews/runtime.ts +92 -1
  57. package/src/reviews/transport.ts +27 -10
  58. package/src/reviews/useReviewGate.ts +12 -7
  59. package/src/session-analytics/lifecycle.ts +15 -13
  60. package/src/session-analytics/reportSessionStart.ts +15 -4
  61. package/src/session-analytics/useLifecycleEvents.ts +68 -28
package/AGENTS.md CHANGED
@@ -50,6 +50,8 @@ There is no `analytics` subpath: the app-event / analytics API is exported from
50
50
  - `themeFromBrand({ primary })`: derive a full theme from one brand color.
51
51
  - `mergeTheme`, `defaultOnboardingTheme`, `OnboardingThemeProvider`, `useOnboardingTheme`.
52
52
  - `defaultIllustrations`: dependency-free fallback art; spread your own over it.
53
+ - `createRevenueCatBridge({ analytics, entitlementId })`: the RevenueCat purchase funnel (see "RevenueCat" below).
54
+ - `activationJoinContext(deviceKey)`: builds the `userContext` value that joins an onboarding session to the app's later events. Read the RevenueCat section before you use it.
53
55
  - Types: `OnboardingTheme`, `OnboardingResult`, `OnboardingEvent`, `WireOnboardingConfig`, `WireOnboardingProps`, `StepValidator`, `OnboardingCopy`, `IllustrationRegistry`.
54
56
 
55
57
  ## `<WireOnboarding>` props
@@ -131,6 +133,55 @@ write your own fetch.
131
133
  `createWireActivation(config)`. This is the kit-owned replacement for hand-rolling
132
134
  session-id + await-POST + revalidate.
133
135
 
136
+ ## RevenueCat (the purchase funnel)
137
+
138
+ The app sells subscriptions through `react-native-purchases`? Wire the paywall to the same event
139
+ stream as onboarding. The kit does NOT depend on `react-native-purchases` (it is a native module and
140
+ the kit never forces a rebuild); the adapter types the RevenueCat objects structurally, so you pass
141
+ the real ones you already have.
142
+
143
+ ```tsx
144
+ import { createAnalytics, createRevenueCatBridge, activationJoinContext } from "@wireai/activation";
145
+
146
+ const analytics = createAnalytics({ serverUrl, apiKey, storage, appId, userContext: { deviceKey } });
147
+ const revenuecat = createRevenueCatBridge({ analytics, entitlementId: "pro" });
148
+
149
+ revenuecat.paywallShown(offering, { source: variant });
150
+ revenuecat.checkoutStarted(pkg, { source: variant });
151
+ const entitled = revenuecat.purchaseCompleted(customerInfo, pkg, { source: variant });
152
+ revenuecat.purchaseFailed(error, pkg, { source: variant });
153
+ revenuecat.purchasesRestored(customerInfo);
154
+ revenuecat.syncPlanTier(customerInfo); // at launch, writes plan_tier with no event
155
+ ```
156
+
157
+ Canonical names (they are also the `question_key` strings a firing trigger matches on):
158
+ `wire_paywall_shown`, `wire_checkout_started`, `wire_purchase_completed`, `wire_purchase_failed`,
159
+ `wire_purchase_restored`.
160
+
161
+ **The join key is `user_context.device_key`, never `session_id`.** An onboarding session id is the
162
+ A2A `contextId` and an app-event session id is the per-open id; the two live in separate spaces on
163
+ purpose, so intersecting them returns nothing. Put the SAME device key on both sides: the analytics
164
+ and activation surfaces auto-mint and stamp it on every event, and onboarding gets it from
165
+ `<WireOnboarding userContext={activationJoinContext(deviceKey)} ... />`. Never hand-write
166
+ `userContext={{ deviceKey }}`, because the server's device lookup reads `device_key` and a
167
+ misspelled bucket produces an empty funnel instead of an error.
168
+
169
+ **If the host owns no device id, read the kit's.** Do not leave the onboarding side blank.
170
+ `resolveAutoDeviceKey({ appId, storage })` returns the exact id `createAnalytics` /
171
+ `createWireActivation` auto-mint and persist for this install, so both halves of the join agree:
172
+
173
+ ```tsx
174
+ import { resolveAutoDeviceKey, activationJoinContext } from "@wireai/activation";
175
+
176
+ const deviceKey = resolveAutoDeviceKey({ appId, storage }); // the SAME id the analytics side stamps
177
+ <WireOnboarding config={config} userContext={activationJoinContext(deviceKey)} ... />
178
+ ```
179
+
180
+ Always pass `storage`. Without it the id is per-LAUNCH, not per-install, and a per-launch key makes
181
+ every open look like a new device, which breaks `min_sessions` and A/B arm stickiness as surely as
182
+ no key breaks the join. The same rule governs the lifecycle wiring: `useLifecycleEvents` falls back
183
+ to this id for `app.session_started` / `app.first_open` ONLY when `config.storage` is present.
184
+
134
185
  ## Gotchas (do not miss)
135
186
 
136
187
  - **EAS / cloud builds:** install via **git URL or registry**, never a local `file:` path *outside the app repo* (EAS won't resolve it).
package/CHANGELOG.md CHANGED
@@ -3,7 +3,117 @@
3
3
  All notable changes to `@wireai/activation` (formerly `wireai-onboarding`).
4
4
  Historical entries below the rename keep the old package name on purpose.
5
5
 
6
- ## [Unreleased]
6
+ ## [0.12.0] — 2026-07-27
7
+
8
+ The release Myelino 2.2.0 pins. Ships the RevenueCat path (#52), the identity/counting audit,
9
+ the session-id fallback contract, and the transport/config fixes behind a silently dead lifecycle
10
+ stream (a consumer's `first_open` read 6 all-time while the emitting code was deployed).
11
+
12
+ ### Added
13
+
14
+ - **RevenueCat path (`createRevenueCatBridge`).** The purchase funnel is now a first-class, documented
15
+ path instead of glue every consumer writes itself. New ROOT exports: `createRevenueCatBridge`,
16
+ `WIRE_PURCHASE_EVENTS` (`wire_paywall_shown`, `wire_checkout_started`, `wire_purchase_completed`,
17
+ `wire_purchase_failed`, `wire_purchase_restored`), the pure mappers (`activeEntitlement`,
18
+ `resolvePlanTier`, `describePackage`, `describeEntitlement`, `isUserCancelled`, `describeFailure`),
19
+ `PLAN_TIER_CONTEXT_KEY`, and the structural RevenueCat types. The bridge reports through the
20
+ `createAnalytics` or `createWireActivation` instance the host already holds (a compile-time check in
21
+ `revenueCatBridge.ts` keeps both assignable), writes `plan_tier` (paid / trial / free) to the bound
22
+ user context, drops the store's localized error `message` and keeps only the stable RevenueCat
23
+ `code`, and reports a store-confirmed purchase that granted no entitlement as
24
+ `wire_purchase_failed` with `reason: "not_entitled"` rather than swallowing it. **No new dependency:
25
+ `react-native-purchases` is a native module and is never imported**, so the RevenueCat objects are
26
+ typed structurally and the host passes its real ones.
27
+ - **`activationJoinContext(deviceKey)`.** The `userContext` value that joins an onboarding session to
28
+ the app's later events. The join key is `user_context.device_key` and NEVER `session_id`: an
29
+ onboarding session id is the A2A `contextId`, an app-event session id is the per-open id, and
30
+ intersecting the two id spaces returns zero rows every time. This helper exists so the wire spelling
31
+ (`device_key`) is decided in one place; a hand-written `userContext={{ deviceKey }}` builds a bucket
32
+ the server's device lookup does not read, which produces a silently empty funnel rather than an
33
+ error. `src/revenuecat/joinKey.test.ts` locks both sides against each other.
34
+ - **One shared auto `device_key` per install.** `createAnalytics` and `createWireActivation` each
35
+ minted their OWN id and raced a storage read, so a host building both (the documented wiring)
36
+ stamped two different `device_key`s on one install — splitting the key that `min_sessions`, A/B arm
37
+ stickiness and the purchase-to-onboarding join all read. Now ONE id lives in a `globalThis` slot
38
+ keyed by `Symbol.for(...)` (the `currentSession` pattern). `useLifecycleEvents` now stamps the
39
+ shared key on lifecycle events too (storage-gated, so a per-launch id never corrupts the counter),
40
+ and `resolveAutoDeviceKey` is exported from the root and `./analytics` barrels.
41
+
42
+ ### Fixed
43
+
44
+ - **Every event carries a `session_id`, or the server eats it.** `POST /v1/events` validates per
45
+ event inside a try/except that counts the failure as "skipped" and returns HTTP 200 anyway, so an
46
+ event without a session id was accepted by the wire and discarded. `ensureCurrentSessionId()`
47
+ returns the registered per-open id when there is one and otherwise mints one, REGISTERS it so later
48
+ events join the same session, and warns once per process in `__DEV__` (mount `useLifecycleEvents`
49
+ at the app root). Closes the `reportAppEvent(target, "screen", { deviceKey })` hole and
50
+ `wire.track()`'s refusal to send without a registered open.
51
+ - **`config.sessionId` on `createAnalytics` now warns in dev.** Passing it pins EVERY event from the
52
+ instance to that one frozen id and opts out of the live per-open session — the exact footgun that
53
+ flatlined a consumer's lifecycle metrics. The JSDoc and README now state the freeze semantics
54
+ honestly; omitting it is the correct default.
55
+ - **The persistent transport paths read the `/v1/events` ACK body.** A 200 was never a receipt: the
56
+ server reports discarded events only as `skipped` in the body. The event queue and
57
+ `reportSessionStart` now warn in dev with the skipped count. Log-only — retry/dequeue semantics
58
+ unchanged.
59
+ - **`user_context.app_version` falls back to `detectAppVersion()`** when the host passes nothing, so
60
+ version attribution on the facade path no longer depends on the host forwarding its own version.
61
+
62
+ ## [0.11.0] — 2026-07-21
63
+
64
+ Architecture-audit fixes (#50/#51). One BEHAVIOR CHANGE, called out below.
65
+
66
+ ### Changed (BEHAVIOR) — review gate no longer fires on the first session
67
+
68
+ - **`ReviewConfig.minSessions` now defaults to `2` (was `0`).** An unconfigured host with no server
69
+ decision used to fire the review prompt on the FIRST mount (`resolveRules` defaulted the floor to
70
+ 0, so `local_rules_met` was immediate). That is the shape of the 2026-07-16 incident where a
71
+ first-session user got prompted and left 1 star. The local floor is now 2, so the client rules
72
+ need a second session before they can fire. A server `decision` (from `fetchReviewDecision`) still
73
+ overrides everything, and a host that genuinely wants first-session prompting sets
74
+ `minSessions: 1` (or `0`) explicitly.
75
+
76
+ ### Added
77
+
78
+ - **Logout / reset.** `analytics.reset()` on the `createAnalytics` surface unbinds the user
79
+ (clears the in-memory `boundUserId`, strips `userId` / `userEmail` / `extra` from the bound
80
+ `WireUserContext`, keeps the non-PII `device_key`, and removes the persisted
81
+ `wireai:analytics:userId:<appId>` key). New top-level **`clearUserContext({ storage, appId })`**
82
+ covers the `createWireActivation` / `wire` path (recreate the instance without the user's context
83
+ after calling it). Fixes shared-device attribution of user B's events to user A. Also exported:
84
+ `clearPiiFromContext`, `analyticsUserIdStorageKey`.
85
+ - **Email-shape guard on `identify`.** `identify(id)` and `setUserContext({ userId })` now refuse to
86
+ bind an email-shaped id (a raw email in the opaque `user_id` is a PII leak, it belongs in
87
+ `userContext.userEmail`) and warn in dev. Opt out with `allowEmailAsUserId: true` on
88
+ `createAnalytics`. New exported helper `looksLikeEmail`. Non-breaking.
89
+ - **Client event timestamp.** `ClientEvent` gains an optional additive `ts`. The offline queue stamps
90
+ it at enqueue time so two byte-identical events fired seconds apart (a genuine repeat) are no
91
+ longer collapsed by the identical-JSON de-dup, while two same-instant re-enqueues still collapse.
92
+
93
+ ### Fixed
94
+
95
+ - **`first_open` phantom session.** `useLifecycleEvents` now mints ONE per-open `session_id` on mount
96
+ and hands it to BOTH `app.first_open` and `app.session_started`, so `first_open` no longer carries
97
+ a session id the server never saw a `session_started` for (which the server back-filled into a
98
+ phantom session, inflating session counts). A later foreground open still mints its own fresh id.
99
+ - **Questionnaire decision guard.** `fetchQuestionnaireDecision` now returns `null` for a body
100
+ without a boolean `fire` (mirrors `fetchReviewDecision`), instead of letting `{}` through as a
101
+ truthy "decision" whose `fire === undefined` reads as a silent "never fire".
102
+
103
+ ### Internal
104
+
105
+ - **Unified the `/v1/events` POST builders.** `buildEventsRequest` (in `reportClientEvent.ts`) is now
106
+ exported and is the ONE description of the events endpoint (url + headers + body). The offline
107
+ queue (`postBatch`, keeping its abort-timeout), `reportAppEvent` (reviews transport), and the
108
+ lifecycle direct-POST fallback all route through it instead of hand-rolling the request.
109
+ - **Docs truth pass.** New README "Rich user context & PII" section (raw `userEmail` by default +
110
+ `hashEmail`, `reset` / `clearUserContext` on logout, the auto per-install `device_key` and its
111
+ store-declaration implications). The blanket "No PII / nothing identifies a device" claims are
112
+ scoped to the onboarding `userContext` prop + the `collectDeviceContext()` snapshot. `llms.txt`
113
+ now lists the `/analytics`, `/reviews`, `/questionnaire` subpath surfaces.
114
+ - Added the `session/sessionIdentity.integration.test.ts` dist-canary (PR #47/#48).
115
+
116
+ ## [0.10.0] - 2026-07-20
7
117
 
8
118
  ### Added: `wire.track` + `useWireActivation` (fire a gate off an in-app action)
9
119
 
@@ -53,7 +53,19 @@ STEPS (do them in order, stop and ask if a convention is ambiguous):
53
53
  `useQuestionnaireGate` the same way), and at the action site call `await track("journal_done")`.
54
54
  Do NOT hand-roll the session id or the POST; `wire.track` owns both and revalidates the gate on
55
55
  a successful 2xx.
56
- 9. Verify: type-check (and lint the changed files); test the flag-off + backend-error paths.
56
+ 9. RevenueCat (only if my app sells subscriptions through `react-native-purchases`): wire my paywall
57
+ to the same event stream with `createRevenueCatBridge({ analytics, entitlementId: "<my
58
+ entitlement, e.g. pro>" })` from `@wireai/activation`. Do NOT add or import
59
+ `react-native-purchases` in the kit path; the adapter types the RevenueCat objects structurally,
60
+ so pass my real `PurchasesOffering` / `PurchasesPackage` / `CustomerInfo` straight in. Replace my
61
+ hand-rolled paywall analytics calls with `paywallShown` / `checkoutStarted` / `purchaseCompleted`
62
+ / `purchaseFailed` / `purchasesRestored`, and call `syncPlanTier(customerInfo)` once at launch.
63
+ THE JOIN KEY: pass the SAME device key to the analytics instance AND to onboarding via
64
+ `<WireOnboarding userContext={activationJoinContext(deviceKey)} />`, because purchases join to
65
+ onboarding on `user_context.device_key` and never on `session_id` (those are separate id spaces
66
+ and intersecting them returns zero rows). If my app has no single stable device key yet, say so
67
+ instead of inventing one.
68
+ 10. Verify: type-check (and lint the changed files); test the flag-off + backend-error paths.
57
69
 
58
70
  Report back: the files you changed (path:line), the typecheck result, and anything you couldn't
59
71
  infer about my conventions.
package/README.md CHANGED
@@ -246,7 +246,7 @@ import { WireOnboarding } from "@wireai/activation";
246
246
  | `storage` | `WireOnboardingStorage` | Host-injected storage (AsyncStorage-compatible `getItem/setItem/removeItem`) for **session-id persistence**: an app KILL mid-onboarding resumes the SAME backend session instead of minting a new one, so the funnel's `started` count stays honest (no phantom drops). Pass AsyncStorage as-is, or a 3-line MMKV wrapper. Omit for the previous per-mount behavior. Persists the kit's own correlation seed only — answers stay the host's job via `onComplete`. |
247
247
  | `sessionTtlMs` | `number` | How long a persisted session id stays resumable. Default `3600000` (1h, the backend's session TTL). With `storage` only. |
248
248
  | `persistKey` | `string` | Override the storage key (default `wireai:session:<appId>`). Scope per-user if one device can onboard multiple accounts mid-flow. With `storage` only. |
249
- | `userContext` | `Record<string, string \| number \| boolean>` | Host-injected, non-PII context the app already knows (signup method, referral, plan, a HASHED user id). Forwarded on session metadata + client events for funnel segmentation. **No PII** (no raw emails/names); primitives only; the server caps size/keys. See [Device & user context](#device--user-context). |
249
+ | `userContext` | `Record<string, string \| number \| boolean>` | Host-injected, non-PII context the app already knows (signup method, referral, plan, a HASHED user id). Forwarded on session metadata + client events for funnel segmentation. **No PII on THIS prop** (no raw emails/names); primitives only; the server caps size/keys. See [Device & user context](#device--user-context). ⚠️ Distinct from the analytics/activation **`WireUserContext`** object (`createAnalytics` / `useWireActivation`), which additionally accepts an opt-in raw `userEmail` — see [Rich user context & PII](#rich-user-context--pii). |
250
250
  | `userId` | `string` | Your own OPAQUE user id, so onboarding sessions reconcile to real users later (console sessions to your user table / GA4 users). Optional and supports **late binding**: present at mount it rides the session-start metadata; if it changes mid-session (the user just registered) the kit emits an `identify` event; available only after the flow, use `identifyOnboarding(...)`. **No PII** (not an email/name/phone); trimmed and capped at 128 chars. See [User identity](#user-identity). |
251
251
 
252
252
  ## Helpers (the reusable substrate)
@@ -274,7 +274,7 @@ You rarely hand-roll config, gating, analytics, or attribution — the kit ships
274
274
  Two optional, host-injected inputs let onboarding analytics segment the funnel by device and by what your app already knows about the user. The kit collects the device snapshot itself (no dependency added); everything else you pass.
275
275
 
276
276
  - **`config.appVersion`** (string): your app version, e.g. `Constants.expoConfig?.version`. The kit reads nothing to get it, so it stays dependency-free; you inject it.
277
- - **`userContext`** (`Record<string, string | number | boolean>`): non-PII context the app already has, like signup method, referral source, plan tier, or a hashed user id. Same host-injection idea as `storage`. Do not put raw emails, names, or phone numbers here; pass a hash if you need a user key. Values are primitives only, and the server caps key count/size and drops deep nesting.
277
+ - **`userContext`** (`Record<string, string | number | boolean>`): non-PII context the app already has, like signup method, referral source, plan tier, or a hashed user id. Same host-injection idea as `storage`. Do not put raw emails, names, or phone numbers here; pass a hash if you need a user key. Values are primitives only, and the server caps key count/size and drops deep nesting. This is the **onboarding** prop specifically. The analytics/activation surface takes a richer object with an opt-in raw email field; see [Rich user context & PII](#rich-user-context--pii).
278
278
 
279
279
  Both ride the A2A session-start metadata AND every client event (`dropped`, `client_fallback`). Old servers ignore the extra fields, so it is backward compatible.
280
280
 
@@ -296,7 +296,9 @@ Only React Native built-ins and the standard `Intl` global, each read defensivel
296
296
 
297
297
  ### No tracking (privacy-label-neutral)
298
298
 
299
- No advertising IDs (no IDFA/GAID), no `getUniqueId`, no fingerprinting APIs. Nothing here identifies a user or device uniquely, so adopting it does not change your App Privacy or Data Safety declarations, and it adds zero dependencies. On the backend the server derives a coarse country from the sent locale/timezone only, and never processes or stores IP addresses.
299
+ No advertising IDs (no IDFA/GAID), no `getUniqueId`, no fingerprinting APIs. **The `collectDeviceContext()` snapshot described in this section** carries no unique identifier, so adopting it alone does not change your App Privacy or Data Safety declarations, and it adds zero dependencies. On the backend the server derives a coarse country from the sent locale/timezone only, and never processes or stores IP addresses.
300
+
301
+ > ⚠️ **The analytics façade is different.** `createAnalytics` / `useWireActivation` auto-mint a stable, persisted, per-install `device_key` (a first-party correlation id, the privacy category of a first-party cookie) that rides on every event. It is not a hardware id and cannot be joined across apps, but it IS a per-install identifier, so if you adopt that surface, declare it accordingly. See [Rich user context & PII](#rich-user-context--pii).
300
302
 
301
303
  ## User identity
302
304
 
@@ -339,7 +341,52 @@ await identifyOnboarding({ config, userId: newUser.id, contextId: contextIdRef.c
339
341
 
340
342
  If you passed `storage` to `<WireOnboarding>`, you can omit `contextId` and hand `identifyOnboarding` the same `storage` (plus `appId` or `persistKey`) and it recovers the persisted session id itself. That works while the session is still persisted (before completion clears it), so the captured-`contextId` path above is the safe one post-completion. `identifyOnboarding` is fire-and-forget and never throws; it resolves `true` when it dispatched an `identify` and `false` when it could not (no user id, no server url, or no resolvable session).
341
343
 
342
- **No PII.** `userId` is length-bound only; the kit cannot detect an email for you. Keep it opaque, the same rule as `userContext`.
344
+ **No PII.** The onboarding `userId` prop is length-bound only; the kit cannot detect an email for you. Keep it opaque, the same rule as the onboarding `userContext` prop.
345
+
346
+ ## Rich user context & PII
347
+
348
+ The onboarding props above are non-PII by contract. The **analytics and activation surfaces** (`createAnalytics(...)` / `useAnalytics`, `createWireActivation(...)` / `useWireActivation`) take a richer `WireUserContext` object that CAN carry PII, so it needs its own privacy rules. Three things to know:
349
+
350
+ **1. `userEmail` sends a RAW email by default.** `WireUserContext.userEmail` lands in its own `user_context.user_email` field (never mixed into the opaque `userId`). It is opt-in: the kit never auto-collects it, and you pass it only with the user's consent (for EU users, treat it as personal data). By default it is sent **as-is**. For a non-reversible form, set `hashEmail: true` and the kit folds it with a dependency-free hash and stamps `user_context.user_email_hashed: true`. For a cryptographic digest, hash it host-side (e.g. SHA-256 via `expo-crypto`) and pass the digest as `userEmail` with `hashEmail` falsy.
351
+
352
+ ```tsx
353
+ const analytics = createAnalytics({ serverUrl, apiKey, storage, appId });
354
+ analytics.setUserContext({ userId: user.id, userEmail: user.email, hashEmail: true }); // opt-in, folded
355
+ ```
356
+
357
+ **Email-shape guard.** `identify(id)` (and `setUserContext({ userId })`) refuse to bind an id that looks like an email and warn in dev, because a raw email in the opaque `user_id` is a PII leak, it belongs in `userEmail`. If your internal user id genuinely IS an email, set `allowEmailAsUserId: true` on `createAnalytics` to opt out of the guard.
358
+
359
+ **2. Clear the user on logout.** On a shared device, a persisted user binding would attribute user B's events to user A. Two ways to clear it:
360
+
361
+ - Hold the analytics instance: call **`analytics.reset()`**. It unbinds the in-memory user, strips the PII fields (`userId`, `userEmail`, `extra`) from the bound context, and removes the persisted `wireai:analytics:userId:<appId>` key. The non-PII `device_key` is kept (it groups a device, not a user).
362
+ - The `wire` / `createWireActivation` path captures its config immutably, so call the standalone **`clearUserContext({ storage, appId })`** to purge the persisted binding, and RECREATE the instance without the user's `userContext` so no further events carry their identity.
363
+
364
+ ```tsx
365
+ import { clearUserContext } from "@wireai/activation";
366
+ // on sign-out:
367
+ analytics.reset(); // if you hold the analytics instance
368
+ await clearUserContext({ storage, appId }); // the wire/activation path (then recreate without PII)
369
+ ```
370
+
371
+ **3. The auto `device_key` is a per-install identifier.** When you supply no `deviceKey`, the analytics and activation surfaces auto-mint a stable per-install `device_key`, persist it via `storage` (`wireai:analytics:deviceKey:<appId>`), and stamp it on every event so the server's review/questionnaire gating and A/B stickiness work out of the box. It carries no hardware id, no IDFA/GAID, and cannot be joined across apps (the privacy category of a first-party cookie), but it IS a persistent per-install id. If you adopt these surfaces, declare it in your App Privacy / Data Safety accordingly. Supply your own `deviceKey` to override it.
372
+
373
+ ### Do not pass `sessionId` to `createAnalytics`
374
+
375
+ `CreateAnalyticsConfig.sessionId` is an opt-out knob, not a default. Set it and **every** event that
376
+ instance sends (including `identify`) is pinned to that one frozen id, and the instance stops
377
+ following the live per-open session the kit registers from `app.session_started` (see
378
+ [Session mapping](#session-mapping-know-when-a-user-opens-the-app-again)). Lifecycle analytics then
379
+ collapse onto a single device-scoped id: one "first open" for the life of the install, however many
380
+ times the user comes back. The kit warns about it in dev builds.
381
+
382
+ ```tsx
383
+ const analytics = createAnalytics({ serverUrl, apiKey, storage, appId }); // ✅ follows each app-open
384
+ const analytics = createAnalytics({ serverUrl, apiKey, sessionId: myId }); // ⚠️ frozen for good
385
+ ```
386
+
387
+ Omit it. Pass one only if your host runs its own session lifecycle and owns the id the server should
388
+ correlate on. This is a different field from the per-open `sessionId` that `useWireActivation` and
389
+ `reportAppEvent` READ (that one is the live id, and reading it is always fine).
343
390
 
344
391
  ## Session mapping (know when a user opens the app again)
345
392
 
@@ -936,6 +983,61 @@ If you are outside React (a service, a saga), `createWireActivation(config)` is
936
983
  factory. It returns `{ track, sessionId, subscribeRevalidation, getRevalidationVersion }`: the
937
984
  same `track`, plus the raw pub/sub the hook wraps for you.
938
985
 
986
+ ## RevenueCat (the purchase funnel)
987
+
988
+ If you sell subscriptions with [RevenueCat](https://www.revenuecat.com/), the paywall is the other end of the funnel onboarding starts. `createRevenueCatBridge` wires the two together: one constructor, then your existing paywall call sites.
989
+
990
+ The kit does **not** depend on `react-native-purchases`, and it never will. That package is a native module, and the kit's whole promise is that it never puts a native rebuild in your way. So the adapter types the RevenueCat objects structurally instead, which means you hand it the real `CustomerInfo` / `PurchasesPackage` / `PurchasesOffering` you already have. Nothing new gets installed.
991
+
992
+ ```tsx
993
+ import { createAnalytics, createRevenueCatBridge } from "@wireai/activation";
994
+
995
+ const analytics = createAnalytics({ serverUrl, apiKey, storage, appId, userContext: { deviceKey } });
996
+ const revenuecat = createRevenueCatBridge({ analytics, entitlementId: "pro" });
997
+ ```
998
+
999
+ Then five call sites, all fire-and-forget, none of which can throw at your paywall:
1000
+
1001
+ ```tsx
1002
+ revenuecat.paywallShown(offering, { source: variant }); // offering_id + packages_count
1003
+ revenuecat.checkoutStarted(pkg, { source: variant }); // package, product, price, currency
1004
+
1005
+ const { customerInfo } = await Purchases.purchasePackage(pkg);
1006
+ if (revenuecat.purchaseCompleted(customerInfo, pkg, { source: variant })) navigateOn();
1007
+
1008
+ revenuecat.purchaseFailed(error, pkg, { source: variant }); // reason: "cancelled" | "error"
1009
+ revenuecat.purchasesRestored(await Purchases.restorePurchases());
1010
+ revenuecat.syncPlanTier(await Purchases.getCustomerInfo()); // at launch, no event
1011
+ ```
1012
+
1013
+ **The names are canonical**, the same way `WIRE_ONBOARDING_EVENTS` standardizes the onboarding funnel: `wire_paywall_shown`, `wire_checkout_started`, `wire_purchase_completed`, `wire_purchase_failed`, `wire_purchase_restored`. They are `app_event` `question_key` values on the wire, so they are also the exact strings a review or questionnaire firing trigger matches on.
1014
+
1015
+ **What the bridge decides for you.** The entitlement check lives in one place instead of being copy-pasted at the purchase site and the restore site. `plan_tier` (`paid` / `trial` / `free`) is written to the bound user context on every entitlement change, so the whole funnel can be sliced paid versus free. The store's error `message` is dropped and only the stable RevenueCat `code` rides the event, because that message is localized, unbounded, and occasionally names the account it failed for. And a purchase the store confirmed that granted **no** entitlement fires `wire_purchase_failed` with `reason: "not_entitled"` rather than returning quietly, which is the usual shape of a broken product-to-entitlement mapping.
1016
+
1017
+ ### The join key: `device_key`, never `session_id`
1018
+
1019
+ A purchase event is worth nothing unless you can join it to the same user's onboarding. Get this wrong and you do not get a wrong number, you get a permanent zero, which is far harder to notice.
1020
+
1021
+ `session_id` is not that key. The onboarding session id is the A2A `contextId`, the app-event session id gets minted per app-open, and the kit keeps those two id spaces apart on purpose. Intersect them and you get no rows. Ever.
1022
+
1023
+ The key is **`user_context.device_key`**, and you have to put it on both sides:
1024
+
1025
+ ```tsx
1026
+ import { activationJoinContext } from "@wireai/activation";
1027
+
1028
+ // Purchase side: createAnalytics / createWireActivation auto-mint and persist a device_key
1029
+ // and stamp it on every event. Supply your own to override it.
1030
+ const analytics = createAnalytics({ serverUrl, apiKey, storage, appId, userContext: { deviceKey } });
1031
+
1032
+ // Onboarding side: forward the SAME key. The kit passes userContext verbatim into the A2A
1033
+ // session-start metadata, and the server records it on the session's session_started event.
1034
+ <WireOnboarding config={config} userContext={activationJoinContext(deviceKey)} onComplete={persist} />
1035
+ ```
1036
+
1037
+ `activationJoinContext(deviceKey)` exists because the wire key is `device_key` and the prop-facing name is `deviceKey`. Hand-writing `userContext={{ deviceKey }}` produces a bucket the server's device lookup does not read, and you get the silent-zero funnel instead of an error. This is the one place that spelling is decided.
1038
+
1039
+ Skip the onboarding half and the purchase events are still valid on their own, they just cannot be attributed back to an onboarding.
1040
+
939
1041
  ## Feature controls (per-module kill switches)
940
1042
 
941
1043
  Every activation surface has a switch you can flip from the dashboard "in case of something":
@@ -1,6 +1,6 @@
1
- export { R as ReportAppEventOptions, r as reportAppEvent } from '../transport-Bzb-bcB2.mjs';
2
- import { W as WireUserContext, E as EventQueueOptions } from '../currentSession-DdDkprpM.mjs';
3
- export { A as AnalyticsEvent, C as ClientEvent, a as ClientEventTarget, b as ClientEventType, c as ContextEnvelope, d as ContextEnvelopeInput, e as EnvelopeSource, f as EventQueue, g as WIRE_ONBOARDING_EVENTS, h as WireOnboardingEventName, i as buildContextEnvelope, j as createEventQueue, k as getCurrentSessionId, m as makeSessionId, r as reportClientEvent, l as reportClientEventAwait, n as reportClientEvents, o as reportClientEventsAwait, p as resetCurrentSessionId, s as setCurrentSessionId, t as toAnalyticsEvent } from '../currentSession-DdDkprpM.mjs';
1
+ export { R as ReportAppEventOptions, r as reportAppEvent } from '../transport-CF_eHwzC.mjs';
2
+ import { W as WireUserContext, E as EventQueueOptions } from '../currentSession-DsSDHqor.mjs';
3
+ export { A as AUTO_DEVICE_ID_PREFIX, a as AnalyticsEvent, C as ClearUserContextOptions, b as ClientEvent, c as ClientEventTarget, d as ClientEventType, e as ContextEnvelope, f as ContextEnvelopeInput, D as DeviceKeyStorage, g as EnvelopeSource, h as EventQueue, R as ResolveAutoDeviceKeyOptions, i as WIRE_ONBOARDING_EVENTS, j as WireOnboardingEventName, k as analyticsUserIdStorageKey, l as buildContextEnvelope, m as clearPiiFromContext, n as clearUserContext, o as createEventQueue, p as deviceIdStorageKey, q as ensureCurrentSessionId, r as getCurrentSessionId, s as looksLikeEmail, t as makeSessionId, u as reportClientEvent, v as reportClientEventAwait, w as reportClientEvents, x as reportClientEventsAwait, y as resetAutoDeviceKeys, z as resetCurrentSessionId, B as resolveAutoDeviceKey, F as setCurrentSessionId, G as toAnalyticsEvent } from '../currentSession-DsSDHqor.mjs';
4
4
  import '../types-CNUqMK0D.mjs';
5
5
  import '../types-BKfpdZzX.mjs';
6
6
  import '../types-BcmagF6K.mjs';
@@ -33,7 +33,12 @@ interface ScreenTrackerOptions {
33
33
  serverUrl: string;
34
34
  apiKey: string;
35
35
  };
36
- /** The onboarding/session id to correlate screen views with, when known. */
36
+ /**
37
+ * The onboarding/session id to correlate screen views with, when known. Omitting it no longer
38
+ * means the view goes out WITHOUT a `session_id` (the server requires one and drops the event
39
+ * behind an HTTP 200 — that is why screen tracking silently produced nothing for a host that
40
+ * never mounted the lifecycle hook). `reportAppEvent` falls back to the current per-open id.
41
+ */
37
42
  sessionId?: string;
38
43
  /** A stable, non-PII device id — groups a device's sessions server-side. */
39
44
  deviceKey?: string;
@@ -103,8 +108,14 @@ type CreateAnalyticsConfig = {
103
108
  /** Tenant API key; sent as `Authorization: Bearer`. */
104
109
  apiKey: string;
105
110
  /**
106
- * Correlation id shared by every event from this instance (and the `identify` event). Defaults
107
- * to a fresh `makeSessionId()` at creation so all events agree on one id per analytics instance.
111
+ * ⚠️ OPT-OUT KNOB, not a default. Supplying a `sessionId` FREEZES the correlation id: every event
112
+ * this instance ever sends (including `identify`) is pinned to that one id, and the instance stops
113
+ * following the LIVE per-open session the server registered via `app.session_started`. Lifecycle
114
+ * analytics then collapse onto a single device-scoped session — one "first open", forever.
115
+ *
116
+ * OMIT IT — that is the correct default. Without it the kit reuses the live per-open session, and
117
+ * falls back to a stable per-instance id only until an open has been registered. Pass one ONLY if
118
+ * your host runs its own session lifecycle and owns the id the server should correlate on.
108
119
  */
109
120
  sessionId?: string;
110
121
  /** Tenant/app id used to namespace the queue's default storage key (`wireai:evtq:<appId>`). */
@@ -131,6 +142,14 @@ type CreateAnalyticsConfig = {
131
142
  * review/questionnaire gating + A/B stickiness. Supply `deviceKey` only to use your OWN id (it wins).
132
143
  */
133
144
  userContext?: WireUserContext;
145
+ /**
146
+ * ESCAPE HATCH for the email-shape guard. By default `identify(id)` and a `setUserContext({ userId })`
147
+ * REFUSE to bind an id that looks like an email (`local@domain.tld`) and warn in dev — because a
148
+ * raw email in the opaque `user_id` is a PII leak; an email belongs in the opt-in
149
+ * `userContext.userEmail` field. Set `true` ONLY if your real internal user id genuinely IS an
150
+ * email address and you accept it as the pseudonymous key. Default `false` (guard on).
151
+ */
152
+ allowEmailAsUserId?: boolean;
134
153
  };
135
154
  /** Optional queue tuning knobs, forwarded verbatim to {@link createEventQueue}. */
136
155
  type AnalyticsOptions = Partial<Pick<EventQueueOptions, "maxSize" | "batchSize" | "baseBackoffMs" | "maxBackoffMs" | "maxRetries">>;
@@ -152,6 +171,16 @@ type Analytics = {
152
171
  * binds like {@link identify}. Takes effect on subsequent events. Fire-and-forget.
153
172
  */
154
173
  setUserContext(partial: Partial<WireUserContext>): void;
174
+ /**
175
+ * LOGOUT: unbind the current user so a shared device never attributes user B's events to user A.
176
+ * Clears the in-memory `boundUserId`, strips the PII / pseudonymous fields (`userId`, `userEmail`,
177
+ * `extra`) from the bound {@link WireUserContext} (keeping the non-PII `device_key` + `appVersion`,
178
+ * which group a DEVICE not a user), and removes the persisted `wireai:analytics:userId:<appId>`
179
+ * key so it cannot be rehydrated on the next launch. Subsequent events are anonymous until the
180
+ * next `identify` / `setUserContext`. Fire-and-forget; mirrors the `reset()` convention on the
181
+ * screen tracker. The standalone `clearUserContext({ storage, appId })` covers the `wire` path.
182
+ */
183
+ reset(): void;
155
184
  /** Attempt an immediate drain of the pending buffer. Fire-and-forget. */
156
185
  flush(): void;
157
186
  /** Host reconnect signal: reset backoff and drain now. Fire-and-forget. */
@@ -1,6 +1,6 @@
1
- export { R as ReportAppEventOptions, r as reportAppEvent } from '../transport-B31G0Cib.js';
2
- import { W as WireUserContext, E as EventQueueOptions } from '../currentSession-D0Vq7_VE.js';
3
- export { A as AnalyticsEvent, C as ClientEvent, a as ClientEventTarget, b as ClientEventType, c as ContextEnvelope, d as ContextEnvelopeInput, e as EnvelopeSource, f as EventQueue, g as WIRE_ONBOARDING_EVENTS, h as WireOnboardingEventName, i as buildContextEnvelope, j as createEventQueue, k as getCurrentSessionId, m as makeSessionId, r as reportClientEvent, l as reportClientEventAwait, n as reportClientEvents, o as reportClientEventsAwait, p as resetCurrentSessionId, s as setCurrentSessionId, t as toAnalyticsEvent } from '../currentSession-D0Vq7_VE.js';
1
+ export { R as ReportAppEventOptions, r as reportAppEvent } from '../transport-DsRe4epC.js';
2
+ import { W as WireUserContext, E as EventQueueOptions } from '../currentSession-D6RiVtc8.js';
3
+ export { A as AUTO_DEVICE_ID_PREFIX, a as AnalyticsEvent, C as ClearUserContextOptions, b as ClientEvent, c as ClientEventTarget, d as ClientEventType, e as ContextEnvelope, f as ContextEnvelopeInput, D as DeviceKeyStorage, g as EnvelopeSource, h as EventQueue, R as ResolveAutoDeviceKeyOptions, i as WIRE_ONBOARDING_EVENTS, j as WireOnboardingEventName, k as analyticsUserIdStorageKey, l as buildContextEnvelope, m as clearPiiFromContext, n as clearUserContext, o as createEventQueue, p as deviceIdStorageKey, q as ensureCurrentSessionId, r as getCurrentSessionId, s as looksLikeEmail, t as makeSessionId, u as reportClientEvent, v as reportClientEventAwait, w as reportClientEvents, x as reportClientEventsAwait, y as resetAutoDeviceKeys, z as resetCurrentSessionId, B as resolveAutoDeviceKey, F as setCurrentSessionId, G as toAnalyticsEvent } from '../currentSession-D6RiVtc8.js';
4
4
  import '../types-Buj9Lw9t.js';
5
5
  import '../types-BKfpdZzX.js';
6
6
  import '../types-BcmagF6K.js';
@@ -33,7 +33,12 @@ interface ScreenTrackerOptions {
33
33
  serverUrl: string;
34
34
  apiKey: string;
35
35
  };
36
- /** The onboarding/session id to correlate screen views with, when known. */
36
+ /**
37
+ * The onboarding/session id to correlate screen views with, when known. Omitting it no longer
38
+ * means the view goes out WITHOUT a `session_id` (the server requires one and drops the event
39
+ * behind an HTTP 200 — that is why screen tracking silently produced nothing for a host that
40
+ * never mounted the lifecycle hook). `reportAppEvent` falls back to the current per-open id.
41
+ */
37
42
  sessionId?: string;
38
43
  /** A stable, non-PII device id — groups a device's sessions server-side. */
39
44
  deviceKey?: string;
@@ -103,8 +108,14 @@ type CreateAnalyticsConfig = {
103
108
  /** Tenant API key; sent as `Authorization: Bearer`. */
104
109
  apiKey: string;
105
110
  /**
106
- * Correlation id shared by every event from this instance (and the `identify` event). Defaults
107
- * to a fresh `makeSessionId()` at creation so all events agree on one id per analytics instance.
111
+ * ⚠️ OPT-OUT KNOB, not a default. Supplying a `sessionId` FREEZES the correlation id: every event
112
+ * this instance ever sends (including `identify`) is pinned to that one id, and the instance stops
113
+ * following the LIVE per-open session the server registered via `app.session_started`. Lifecycle
114
+ * analytics then collapse onto a single device-scoped session — one "first open", forever.
115
+ *
116
+ * OMIT IT — that is the correct default. Without it the kit reuses the live per-open session, and
117
+ * falls back to a stable per-instance id only until an open has been registered. Pass one ONLY if
118
+ * your host runs its own session lifecycle and owns the id the server should correlate on.
108
119
  */
109
120
  sessionId?: string;
110
121
  /** Tenant/app id used to namespace the queue's default storage key (`wireai:evtq:<appId>`). */
@@ -131,6 +142,14 @@ type CreateAnalyticsConfig = {
131
142
  * review/questionnaire gating + A/B stickiness. Supply `deviceKey` only to use your OWN id (it wins).
132
143
  */
133
144
  userContext?: WireUserContext;
145
+ /**
146
+ * ESCAPE HATCH for the email-shape guard. By default `identify(id)` and a `setUserContext({ userId })`
147
+ * REFUSE to bind an id that looks like an email (`local@domain.tld`) and warn in dev — because a
148
+ * raw email in the opaque `user_id` is a PII leak; an email belongs in the opt-in
149
+ * `userContext.userEmail` field. Set `true` ONLY if your real internal user id genuinely IS an
150
+ * email address and you accept it as the pseudonymous key. Default `false` (guard on).
151
+ */
152
+ allowEmailAsUserId?: boolean;
134
153
  };
135
154
  /** Optional queue tuning knobs, forwarded verbatim to {@link createEventQueue}. */
136
155
  type AnalyticsOptions = Partial<Pick<EventQueueOptions, "maxSize" | "batchSize" | "baseBackoffMs" | "maxBackoffMs" | "maxRetries">>;
@@ -152,6 +171,16 @@ type Analytics = {
152
171
  * binds like {@link identify}. Takes effect on subsequent events. Fire-and-forget.
153
172
  */
154
173
  setUserContext(partial: Partial<WireUserContext>): void;
174
+ /**
175
+ * LOGOUT: unbind the current user so a shared device never attributes user B's events to user A.
176
+ * Clears the in-memory `boundUserId`, strips the PII / pseudonymous fields (`userId`, `userEmail`,
177
+ * `extra`) from the bound {@link WireUserContext} (keeping the non-PII `device_key` + `appVersion`,
178
+ * which group a DEVICE not a user), and removes the persisted `wireai:analytics:userId:<appId>`
179
+ * key so it cannot be rehydrated on the next launch. Subsequent events are anonymous until the
180
+ * next `identify` / `setUserContext`. Fire-and-forget; mirrors the `reset()` convention on the
181
+ * screen tracker. The standalone `clearUserContext({ storage, appId })` covers the `wire` path.
182
+ */
183
+ reset(): void;
155
184
  /** Attempt an immediate drain of the pending buffer. Fire-and-forget. */
156
185
  flush(): void;
157
186
  /** Host reconnect signal: reset backoff and drain now. Fire-and-forget. */