@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
@@ -24,7 +24,12 @@
24
24
  * `notifyOnline()`; persistence is the host-injected AsyncStorage-compatible subset.
25
25
  */
26
26
  import type { ContextEnvelope } from "./contextEnvelope";
27
- import type { ClientEvent, ClientEventTarget } from "./reportClientEvent";
27
+ import {
28
+ buildEventsRequest,
29
+ warnOnSkippedEvents,
30
+ type ClientEvent,
31
+ type ClientEventTarget,
32
+ } from "./reportClientEvent";
28
33
  import type { WireOnboardingStorage } from "../session/persistedSession";
29
34
 
30
35
  /** Envelope source: a fixed envelope or a provider evaluated at enqueue time (fresh network type). */
@@ -161,6 +166,11 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
161
166
  const stamp = (event: ClientEvent): ClientEvent => {
162
167
  const env = resolveEnvelope();
163
168
  const stamped: ClientEvent = { ...event };
169
+ // Client enqueue timestamp: distinguishes a GENUINE repeat (same event fired seconds apart)
170
+ // from a REDUNDANT re-enqueue of the same instant. The de-dup signature below includes it, so
171
+ // two identical events enqueued in the same millisecond still collapse (a re-render), while the
172
+ // same action repeated later carries a fresh `ts` and survives. A caller-set `ts` is preserved.
173
+ if (stamped.ts === undefined) stamped.ts = Date.now();
164
174
  if (!env) return stamped;
165
175
  if (!stamped.device && env.device) stamped.device = env.device;
166
176
  if (!stamped.session_id && env.sessionId) stamped.session_id = env.sessionId;
@@ -227,19 +237,18 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
227
237
  // The queue's OWN awaited POST. Reads `res.ok` to drive retry/dequeue. NEVER throws — a missing
228
238
  // fetch, a rejecting network, or a JSON error resolves to `false` (batch stays, retry schedules).
229
239
  const postBatch = async (events: ClientEvent[]): Promise<boolean> => {
230
- if (!target?.serverUrl || events.length === 0) return false;
240
+ // Build the /v1/events request through the ONE canonical builder (url + headers + body) so this
241
+ // queue never re-describes the endpoint. The abort-timeout stays: the queue owns retry/dequeue,
242
+ // so a hung request must be cut loose to schedule a backoff rather than block the drain forever.
243
+ const req = buildEventsRequest(target, events);
244
+ if (!req) return false;
231
245
  const controller = typeof AbortController !== "undefined" ? new AbortController() : undefined;
232
246
  const timer = setTimeout(() => controller?.abort(), 15_000);
233
247
  try {
234
- const url = `${target.serverUrl.replace(/\/$/, "")}/v1/events`;
235
- const headers: Record<string, string> = { "Content-Type": "application/json" };
236
- if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
237
- const res = await fetch(url, {
238
- method: "POST",
239
- headers,
240
- body: JSON.stringify({ events }),
241
- signal: controller?.signal,
242
- });
248
+ const res = await fetch(req.url, { ...req.init, signal: controller?.signal });
249
+ // A 200 can still carry `skipped:N` events the server threw away. Log-only: the ack below
250
+ // stays `res.ok`, so retry/dequeue behaviour is unchanged.
251
+ warnOnSkippedEvents(res);
243
252
  return !!(res && (res as { ok?: boolean }).ok);
244
253
  } catch {
245
254
  return false;
@@ -64,5 +64,33 @@ export type {
64
64
  // ─── The thin optional React hook over the façade ─────────────────────────────
65
65
  export { useAnalytics } from "./useAnalytics";
66
66
 
67
+ // ─── Logout / reset helpers + the email-shape guard (analytics-focused surface) ─
68
+ export {
69
+ clearUserContext,
70
+ clearPiiFromContext,
71
+ analyticsUserIdStorageKey,
72
+ } from "../context/userContext";
73
+ export type { ClearUserContextOptions } from "../context/userContext";
74
+ export { looksLikeEmail } from "../identity/userIdentity";
75
+
76
+ // ─── The ONE auto-minted, persisted per-install `device_key` (the join key) ────
77
+ // Public so an analytics-only consumer can read the SAME id the façade stamps and forward it to the
78
+ // onboarding side via `activationJoinContext(deviceKey)`. Without it the join has no reachable key.
79
+ export {
80
+ resolveAutoDeviceKey,
81
+ resetAutoDeviceKeys,
82
+ deviceIdStorageKey,
83
+ AUTO_DEVICE_ID_PREFIX,
84
+ } from "../context/deviceId";
85
+ export type { DeviceKeyStorage, ResolveAutoDeviceKeyOptions } from "../context/deviceId";
86
+
67
87
  // ─── Current per-open session registry (identify/app-events reuse the live session) ───
68
- export { getCurrentSessionId, setCurrentSessionId, resetCurrentSessionId } from "./currentSession";
88
+ export {
89
+ getCurrentSessionId,
90
+ // The write-through read every wire path uses: returns the registered per-open id, minting +
91
+ // registering one when no app-open has been registered (the server drops an event with no
92
+ // `session_id` and still answers 200).
93
+ ensureCurrentSessionId,
94
+ setCurrentSessionId,
95
+ resetCurrentSessionId,
96
+ } from "./currentSession";
@@ -76,6 +76,16 @@ export type ClientEvent = {
76
76
  * Lets the backend reconcile onboarding sessions to real users. Old servers ignore it.
77
77
  */
78
78
  user_id?: string;
79
+ /**
80
+ * Client-stamped epoch-ms timestamp of when the event was ENQUEUED on the device. Optional and
81
+ * ADDITIVE: the offline queue stamps it at enqueue time (see `createEventQueue`) so two otherwise
82
+ * byte-identical events fired seconds apart (a genuine repeat, e.g. the user taps "share" twice)
83
+ * are NOT collapsed by the queue's identical-JSON de-dup — while two truly simultaneous
84
+ * re-enqueues of the same instant (a redundant re-render) still share a `ts` and collapse. A raw
85
+ * `Date.now()`, never a wall-clock the server trusts (the server derives its own receive time);
86
+ * an old/strict server that does not model it simply ignores the unknown field.
87
+ */
88
+ ts?: number;
79
89
  };
80
90
 
81
91
  /** Where to POST. Derived from `WireOnboardingConfig` (`serverUrl` + `apiKey`). */
@@ -102,8 +112,8 @@ export const makeSessionId = (): string =>
102
112
  * copy of the endpoint path, headers, or envelope shape to drift. Returns `null` when there is
103
113
  * nothing to send (no target / no events) or serialization throws, so callers just bail.
104
114
  */
105
- const buildEventsRequest = (
106
- target: ClientEventTarget | undefined,
115
+ export const buildEventsRequest = (
116
+ target: { serverUrl: string; apiKey?: string } | undefined,
107
117
  events: ClientEvent[],
108
118
  ): { url: string; init: RequestInit } | null => {
109
119
  if (!target?.serverUrl || events.length === 0) return null;
@@ -118,6 +128,44 @@ const buildEventsRequest = (
118
128
  }
119
129
  };
120
130
 
131
+ /** RN sets this global; absent under node/SSR. Read defensively inside {@link warnOnSkippedEvents}. */
132
+ declare const __DEV__: boolean | undefined;
133
+
134
+ /**
135
+ * Read the `/v1/events` ACK body and warn (dev builds only) when the server DISCARDED events.
136
+ *
137
+ * The endpoint answers HTTP **200** with `{ ok, written, skipped }` — an event it refuses (a missing
138
+ * `session_id`, a malformed payload) is counted in `skipped`, never surfaced in the status code. Every
139
+ * send path here reads `res.ok` alone, so a whole batch can evaporate behind a green response. This
140
+ * consumes the body of the PERSISTENT paths (the offline queue + session-start) and names the count.
141
+ *
142
+ * LOG ONLY: returns immediately, never throws, and never influences retry / dequeue / return values.
143
+ * A response with no usable `.json` (an old server, a test mock) is silently ignored.
144
+ */
145
+ export const warnOnSkippedEvents = (res: unknown): void => {
146
+ try {
147
+ const json = (res as { json?: () => Promise<unknown> } | null | undefined)?.json;
148
+ if (typeof json !== "function") return;
149
+ void Promise.resolve(json.call(res))
150
+ .then((body) => {
151
+ const skipped = (body as { skipped?: unknown } | null | undefined)?.skipped;
152
+ if (typeof skipped !== "number" || skipped <= 0) return;
153
+ if (typeof __DEV__ !== "undefined" && __DEV__ && typeof console !== "undefined" && console.warn) {
154
+ console.warn(
155
+ `[wireai] the server ACCEPTED the /v1/events POST but DISCARDED ${skipped} event(s) ` +
156
+ "(skipped in the response body) — they are gone, not retried. The usual cause is an " +
157
+ "event with a missing or empty session_id.",
158
+ );
159
+ }
160
+ })
161
+ .catch(() => {
162
+ // Unreadable / already-consumed body — best-effort logging, swallow.
163
+ });
164
+ } catch {
165
+ // A hostile response object — swallow.
166
+ }
167
+ };
168
+
121
169
  /**
122
170
  * POST one or more client events, fire-and-forget. A missing/invalid target, a build error,
123
171
  * a missing `fetch`, or a network failure is swallowed — the call returns immediately and the
@@ -42,7 +42,12 @@ export interface ScreenTrackerOptions {
42
42
  * exposes. When omitted, the tracker still de-dups and fires `onScreen`, but sends nothing.
43
43
  */
44
44
  target?: { serverUrl: string; apiKey: string };
45
- /** The onboarding/session id to correlate screen views with, when known. */
45
+ /**
46
+ * The onboarding/session id to correlate screen views with, when known. Omitting it no longer
47
+ * means the view goes out WITHOUT a `session_id` (the server requires one and drops the event
48
+ * behind an HTTP 200 — that is why screen tracking silently produced nothing for a host that
49
+ * never mounted the lifecycle hook). `reportAppEvent` falls back to the current per-open id.
50
+ */
46
51
  sessionId?: string;
47
52
  /** A stable, non-PII device id — groups a device's sessions server-side. */
48
53
  deviceKey?: string;
@@ -11,7 +11,7 @@
11
11
  * analytics.track("content_share", { source: "feed" });
12
12
  * // ...on reconnect: analytics.notifyOnline();
13
13
  */
14
- import { useRef } from "react";
14
+ import { useEffect, useRef } from "react";
15
15
 
16
16
  import {
17
17
  createAnalytics,
@@ -38,5 +38,26 @@ export const useAnalytics = (
38
38
  prevKeys.current = currentKeys;
39
39
  ref.current = createAnalytics(config, options);
40
40
  }
41
+
42
+ // A LATE-ARRIVING host device key must still reach the instance.
43
+ //
44
+ // `createAnalytics` copies `config.userContext` into a closure at construction and never re-reads
45
+ // the prop, and `config.userContext.deviceKey` is deliberately NOT part of `currentKeys` (rebuilding
46
+ // the instance would throw away the event queue's pending buffer). So a host that hydrates its
47
+ // device id asynchronously — an AsyncStorage read that resolves after first render — used to be
48
+ // stamped with the kit's auto-minted `wdev_*` id FOREVER, while a sibling `useWireActivation`
49
+ // (which does key on it) rebuilt and used the real one. One install, two `device_key` values, in
50
+ // the same app, on the key every gating rule and the purchase↔onboarding join reads.
51
+ //
52
+ // `setUserContext` is the non-destructive seam for exactly this: it updates the bound context in
53
+ // place, so subsequent events carry the host key with no queue rebuild.
54
+ const hostDeviceKey =
55
+ typeof config.userContext?.deviceKey === "string" && config.userContext.deviceKey.trim()
56
+ ? config.userContext.deviceKey.trim()
57
+ : undefined;
58
+ useEffect(() => {
59
+ if (hostDeviceKey) ref.current?.setUserContext({ deviceKey: hostDeviceKey });
60
+ }, [hostDeviceKey]);
61
+
41
62
  return ref.current;
42
63
  };
@@ -41,3 +41,112 @@ export const mintDeviceId = (): string => {
41
41
  const time = Date.now().toString(36);
42
42
  return `${AUTO_DEVICE_ID_PREFIX}${time}_${randomChunk()}${randomChunk()}`;
43
43
  };
44
+
45
+ // ── The ONE auto device key per install ──────────────────────────────────────────────────────
46
+ //
47
+ // WHY A REGISTRY AND NOT A `let` PER FACTORY: `createAnalytics` and `createWireActivation` each
48
+ // used to mint their OWN id synchronously and then race a storage read to overwrite it. A host that
49
+ // creates BOTH (the documented wiring: a façade for `track`/`screen`, an activation instance for the
50
+ // gate-firing `wire.track`) therefore had TWO auto ids for ONE install. Every event carried whichever
51
+ // id its own surface minted, so the `device_key` the server groups a device's sessions under — the
52
+ // key `min_sessions`, A/B arm stickiness, and the purchase↔onboarding join all read — SPLIT in two.
53
+ // On a first run both also wrote their own id to the same storage slot, so which one survived was a
54
+ // coin flip. Same failure class as joining two event families on disjoint id spaces: no error, just
55
+ // halved counts and a join that misses.
56
+ //
57
+ // The fix is the pattern this repo already uses for `currentSession` and `activation revalidation`:
58
+ // ONE value in a `globalThis` slot keyed by `Symbol.for(...)`, so every inlined copy of this module
59
+ // (tsup duplicates modules across the `.` / `./analytics` bundles) addresses the SAME registry.
60
+ // Keyed by `appId` so two tenants in one process never share an id.
61
+ //
62
+ // RESIDUAL WINDOW (documented, not fixed here): the storage read is async, so events emitted in the
63
+ // milliseconds before hydration completes still carry the freshly minted id rather than the persisted
64
+ // one. The registry makes every surface agree on WHICH id that is; it does not make the read sync.
65
+
66
+ /** Well-known key into the runtime-global symbol registry — one auto-id registry across every bundle. */
67
+ const AUTO_DEVICE_KEY_SLOT: unique symbol = Symbol.for("@wireai/activation:autoDeviceKeys");
68
+
69
+ /** The shared registry: the live id per `appId`, plus the set of appIds whose hydration already ran. */
70
+ type AutoDeviceKeyRegistry = { keys: Map<string, string>; hydrating: Set<string> };
71
+
72
+ type GlobalWithDeviceKeys = typeof globalThis & {
73
+ [AUTO_DEVICE_KEY_SLOT]?: AutoDeviceKeyRegistry;
74
+ };
75
+
76
+ const deviceKeyGlobal = globalThis as GlobalWithDeviceKeys;
77
+
78
+ const autoDeviceKeyRegistry = (): AutoDeviceKeyRegistry => {
79
+ const existing = deviceKeyGlobal[AUTO_DEVICE_KEY_SLOT];
80
+ if (existing) return existing;
81
+ const created: AutoDeviceKeyRegistry = { keys: new Map(), hydrating: new Set() };
82
+ deviceKeyGlobal[AUTO_DEVICE_KEY_SLOT] = created;
83
+ return created;
84
+ };
85
+
86
+ /** The persistence subset {@link resolveAutoDeviceKey} needs (a strict subset of `WireOnboardingStorage`). */
87
+ export type DeviceKeyStorage = {
88
+ getItem(key: string): Promise<string | null>;
89
+ setItem(key: string, value: string): Promise<void>;
90
+ };
91
+
92
+ /** Options for {@link resolveAutoDeviceKey}. Omitting `storage` gives a PROCESS-scoped id, not a
93
+ * per-install one — see the caller notes: a caller with no persistence must decide whether a
94
+ * per-launch id is better or worse than no id for its metric. */
95
+ export interface ResolveAutoDeviceKeyOptions {
96
+ /** Tenant/app id — namespaces both the registry entry and the storage slot. */
97
+ appId?: string;
98
+ /** Host persistence. Present → the id survives launches. Absent → process-scoped only. */
99
+ storage?: DeviceKeyStorage;
100
+ }
101
+
102
+ /**
103
+ * The ONE auto-minted `device_key` for an install, shared by every kit surface.
104
+ *
105
+ * SYNCHRONOUS by contract (a fire-and-forget event path cannot await): returns the current live id
106
+ * immediately, minting one on first call. When `storage` is supplied it also kicks off a SINGLE
107
+ * hydration per `appId` that adopts the persisted id (or persists the freshly minted one). Callers
108
+ * should call this per EVENT rather than caching the return value, so an event built after hydration
109
+ * carries the persisted id.
110
+ *
111
+ * A host-supplied `deviceKey` always wins — callers must short-circuit before reaching this.
112
+ * Never throws: a missing, hung, or rejecting storage adapter degrades to the in-memory id.
113
+ */
114
+ export const resolveAutoDeviceKey = (opts: ResolveAutoDeviceKeyOptions = {}): string => {
115
+ const registry = autoDeviceKeyRegistry();
116
+ const appId = opts.appId ?? "default";
117
+
118
+ let id = registry.keys.get(appId);
119
+ if (!id) {
120
+ id = mintDeviceId();
121
+ registry.keys.set(appId, id);
122
+ }
123
+
124
+ const storage = opts.storage;
125
+ // Single-flight: the FIRST caller with storage owns hydration for this appId; later callers just
126
+ // read whatever the registry currently holds.
127
+ if (storage && !registry.hydrating.has(appId)) {
128
+ registry.hydrating.add(appId);
129
+ const slot = deviceIdStorageKey(appId);
130
+ const minted = id;
131
+ try {
132
+ void Promise.resolve(storage.getItem(slot))
133
+ .then((saved) => {
134
+ const persisted = typeof saved === "string" && saved.trim() ? saved.trim() : undefined;
135
+ if (persisted) registry.keys.set(appId, persisted);
136
+ else void Promise.resolve(storage.setItem(slot, minted)).catch(() => {});
137
+ })
138
+ .catch(() => {});
139
+ } catch {
140
+ // A storage adapter that throws synchronously — degrade to the in-memory id.
141
+ }
142
+ }
143
+
144
+ return registry.keys.get(appId) ?? id;
145
+ };
146
+
147
+ /** Test-only: forget every auto id + hydration flag so a unit test starts from a clean registry. */
148
+ export const resetAutoDeviceKeys = (): void => {
149
+ const registry = autoDeviceKeyRegistry();
150
+ registry.keys.clear();
151
+ registry.hydrating.clear();
152
+ };
@@ -23,6 +23,7 @@
23
23
  * dependency-free FNV-1a fold (see {@link hashEmailFnv1a}) — no crypto library, no async.
24
24
  */
25
25
  import { sanitizeUserId } from "../identity/userIdentity";
26
+ import type { WireOnboardingStorage } from "../session/persistedSession";
26
27
 
27
28
  /**
28
29
  * The single, extensible user-context object. A host passes it ONCE (at analytics init) and may
@@ -152,6 +153,78 @@ export const namespaceExtra = (
152
153
  return out;
153
154
  };
154
155
 
156
+ /**
157
+ * The storage key the analytics façade persists the bound opaque `user_id` under (namespaced per
158
+ * `appId`, mirroring {@link deviceIdStorageKey}). Exported so a logout path can target it directly.
159
+ */
160
+ export const analyticsUserIdStorageKey = (appId?: string): string =>
161
+ `wireai:analytics:userId:${appId ?? "default"}`;
162
+
163
+ /**
164
+ * Return a COPY of a {@link WireUserContext} with every USER-scoped (PII / pseudonymous) field
165
+ * removed — `userId`, `userEmail`, `hashEmail`, and `extra` — while KEEPING the non-PII device-scope
166
+ * fields (`appVersion`, `deviceKey`). This is the in-memory half of logout: after it, the same
167
+ * analytics instance keeps its stable `device_key` (which groups a DEVICE, not a user) but no longer
168
+ * stamps the previous user's id/email onto events. Pure; never mutates the input.
169
+ */
170
+ export const clearPiiFromContext = (ctx: WireUserContext = {}): WireUserContext => {
171
+ const rest: WireUserContext = {};
172
+ if (typeof ctx.appVersion === "string") rest.appVersion = ctx.appVersion;
173
+ if (typeof ctx.deviceKey === "string") rest.deviceKey = ctx.deviceKey;
174
+ return rest;
175
+ };
176
+
177
+ /** Options for {@link clearUserContext}. */
178
+ export interface ClearUserContextOptions {
179
+ /** Host persistence (AsyncStorage subset) — the persisted bound `user_id` is removed from here. */
180
+ storage?: WireOnboardingStorage;
181
+ /** Tenant/app id — namespaces the persisted key (`wireai:analytics:userId:<appId>`). */
182
+ appId?: string;
183
+ }
184
+
185
+ /**
186
+ * LOGOUT primitive: purge the persisted, bound opaque `user_id` for an app so the NEXT user on a
187
+ * shared device is not silently attributed to the previous one. Removes the
188
+ * `wireai:analytics:userId:<appId>` key that the analytics façade persists and reuses across
189
+ * launches. Fire-and-forget: a missing storage or a failing adapter resolves quietly.
190
+ *
191
+ * COVERAGE. The stateful `createAnalytics(...)` instance also exposes {@link Analytics.reset}, which
192
+ * does this AND clears the in-memory binding + PII in one call — prefer it when you hold the
193
+ * instance. This standalone helper covers the `createWireActivation` / `wire` path (whose config is
194
+ * captured immutably, so it has no `reset`): call `clearUserContext({ storage, appId })` on logout,
195
+ * and RECREATE the `wire` / analytics instance without the user's `userContext` (userId/userEmail)
196
+ * so no further events carry the previous user's identity. The non-PII per-install `device_key`
197
+ * (`wireai:analytics:deviceKey:<appId>`) is intentionally left in place — it groups a device, not a
198
+ * person, and stays stable across users of the same install.
199
+ */
200
+ export const clearUserContext = async (opts: ClearUserContextOptions = {}): Promise<void> => {
201
+ const storage = opts.storage;
202
+ if (!storage) return;
203
+ try {
204
+ await storage.removeItem(analyticsUserIdStorageKey(opts.appId));
205
+ } catch {
206
+ /* best-effort, swallow — logout must never throw into the UI */
207
+ }
208
+ };
209
+
210
+ /**
211
+ * The `userContext` value to hand `<WireOnboarding userContext={...} />` so an onboarding session
212
+ * and the app's later events (purchases, actions, screens) share ONE join key.
213
+ *
214
+ * WHY it exists as a named function instead of an inline object literal: the wire key is
215
+ * `device_key`, the prop-facing name is `deviceKey`, and the analytics surfaces auto-mint the value
216
+ * for you. A host that hand-writes `userContext={{ deviceKey }}` produces a bucket the server's
217
+ * device lookup does not read, and the resulting funnel is silently EMPTY rather than wrong. This is
218
+ * the one place that spelling is decided.
219
+ *
220
+ * Pass the SAME `deviceKey` you gave `createAnalytics` / `createWireActivation`. `session_id` is not
221
+ * a join key across those two families: an onboarding session id is the A2A `contextId` and an
222
+ * app-event session id is the per-open id, so intersecting them returns nothing.
223
+ */
224
+ export const activationJoinContext = (
225
+ deviceKey: string,
226
+ ): Record<string, string | number | boolean> => resolveUserContext({ deviceKey }).userContext ?? {};
227
+
155
228
  /** Options for {@link resolveUserContext}. */
156
229
  export interface ResolveUserContextOptions {
157
230
  /**
@@ -44,6 +44,16 @@ export const sanitizeUserId = (raw: unknown): string | undefined => {
44
44
  return trimmed.length > USER_ID_MAX_LENGTH ? trimmed.slice(0, USER_ID_MAX_LENGTH) : trimmed;
45
45
  };
46
46
 
47
+ /**
48
+ * A permissive email-SHAPE test (`local@domain.tld`) — NOT an RFC validator. Its ONE job is to
49
+ * catch the common integration mistake of binding a RAW EMAIL as the opaque `user_id`: that leaks
50
+ * PII into the top-level id (which the server treats as an opaque key and may surface), when the
51
+ * email belongs in the opt-in `user_context.user_email` field instead. `identify()` uses this to
52
+ * refuse an email-shaped id (with a dev warning) unless the host opts in explicitly. Trims first.
53
+ */
54
+ export const looksLikeEmail = (value: unknown): boolean =>
55
+ typeof value === "string" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
56
+
47
57
  /** Options for {@link identifyOnboarding}. */
48
58
  export type IdentifyOnboardingOptions = {
49
59
  /** Tenant transport, same shape as `WireOnboardingConfig` (only these two fields are used). */
package/src/index.ts CHANGED
@@ -141,7 +141,7 @@ export { detectAppVersion } from "./device/appVersion";
141
141
  export { detectNativeModel } from "./device/deviceModel";
142
142
 
143
143
  // ─── User identity (opaque pseudonymous id; late binding, dependency-free) ────
144
- export { identifyOnboarding, sanitizeUserId, USER_ID_MAX_LENGTH } from "./identity/userIdentity";
144
+ export { identifyOnboarding, sanitizeUserId, looksLikeEmail, USER_ID_MAX_LENGTH } from "./identity/userIdentity";
145
145
  export type { IdentifyOnboardingOptions } from "./identity/userIdentity";
146
146
 
147
147
  // ─── Rich user context (one object → every event's user_context; opt-in email PII) ────
@@ -150,6 +150,10 @@ export {
150
150
  namespaceExtra,
151
151
  hashEmailFnv1a,
152
152
  isWireScalar,
153
+ clearUserContext,
154
+ clearPiiFromContext,
155
+ analyticsUserIdStorageKey,
156
+ activationJoinContext,
153
157
  RESERVED_USER_CONTEXT_KEYS,
154
158
  EXTRA_KEY_PREFIX,
155
159
  } from "./context/userContext";
@@ -157,12 +161,29 @@ export type {
157
161
  WireUserContext,
158
162
  ResolvedUserContext,
159
163
  ResolveUserContextOptions,
164
+ ClearUserContextOptions,
160
165
  } from "./context/userContext";
161
- export { mintDeviceId, deviceIdStorageKey, AUTO_DEVICE_ID_PREFIX } from "./context/deviceId";
166
+ export {
167
+ mintDeviceId,
168
+ deviceIdStorageKey,
169
+ AUTO_DEVICE_ID_PREFIX,
170
+ // `resolveAutoDeviceKey` is the id `createAnalytics` / `createWireActivation` auto-mint and persist.
171
+ // It MUST be public: the documented purchase↔onboarding join is
172
+ // `<WireOnboarding userContext={activationJoinContext(deviceKey)} />`, and a host that owns NO device
173
+ // id of its own had no way to obtain the key the analytics side was already stamping — so its
174
+ // purchase events carried `wdev_*` while its onboarding session carried no `device_key` at all, and
175
+ // the join returned the silent zero the README warns about.
176
+ resolveAutoDeviceKey,
177
+ resetAutoDeviceKeys,
178
+ } from "./context/deviceId";
179
+ export type { DeviceKeyStorage, ResolveAutoDeviceKeyOptions } from "./context/deviceId";
162
180
 
163
181
  // ─── Current per-open session registry (identify/app-events reuse the live session) ───
164
182
  export {
165
183
  getCurrentSessionId,
184
+ // Returns the registered per-open id, minting + registering one when no app-open has been
185
+ // registered yet — so no wire path can emit the empty `session_id` the server drops behind a 200.
186
+ ensureCurrentSessionId,
166
187
  setCurrentSessionId,
167
188
  resetCurrentSessionId,
168
189
  } from "./analytics/currentSession";
@@ -185,6 +206,33 @@ export {
185
206
  resetActivationRevalidation,
186
207
  } from "./activation";
187
208
 
209
+ // ─── RevenueCat (purchase funnel → the same events stream, joined on device_key) ──
210
+ export {
211
+ createRevenueCatBridge,
212
+ WIRE_PURCHASE_EVENTS,
213
+ PLAN_TIER_CONTEXT_KEY,
214
+ activeEntitlement,
215
+ describeEntitlement,
216
+ describeFailure,
217
+ describePackage,
218
+ isUserCancelled,
219
+ resolvePlanTier,
220
+ } from "./revenuecat";
221
+ export type {
222
+ RevenueCatBridge,
223
+ RevenueCatBridgeConfig,
224
+ PurchaseProps,
225
+ WirePurchaseEventName,
226
+ PlanTier,
227
+ RevenueCatCustomerInfoLike,
228
+ RevenueCatEntitlementLike,
229
+ RevenueCatErrorLike,
230
+ RevenueCatOfferingLike,
231
+ RevenueCatPackageLike,
232
+ RevenueCatProductLike,
233
+ RevenueCatSink,
234
+ } from "./revenuecat";
235
+
188
236
  // ─── Session mapping (one `app.session_started` per app-open → /v1/events) ─────
189
237
  export {
190
238
  reportSessionStart,
@@ -5,7 +5,13 @@
5
5
  * host that mounted CoachmarkProvider gets questionnaire gating for free - no second storage
6
6
  * to wire. Once-per-user is keyed on the questionnaire `id`.
7
7
  */
8
- export { resolveStorage, readInt, writeInt } from "../reviews/runtime";
8
+ export {
9
+ resolveStorage,
10
+ readInt,
11
+ writeInt,
12
+ bumpSessionCount,
13
+ currentOpenId,
14
+ } from "../reviews/runtime";
9
15
 
10
16
  /** Once-gate key. Keyed by app version when `oncePerVersion` is on, so a new release re-enables. */
11
17
  export const questionnaireSeenKey = (id: string, version?: string): string =>
@@ -15,6 +21,10 @@ export const questionnaireSeenKey = (id: string, version?: string): string =>
15
21
  export const questionnaireLastShownKey = (id: string): string =>
16
22
  `wire_questionnaire_${id}_last`;
17
23
 
18
- /** Session-count key, incremented once per gate mount, for the min-sessions rule. */
24
+ /** Session-count key, incremented once per APP-OPEN (not per mount), for the min-sessions rule. */
19
25
  export const questionnaireSessionsKey = (id: string): string =>
20
26
  `wire_questionnaire_${id}_sessions`;
27
+
28
+ /** Companion key holding the open id the counter was LAST incremented for (see `bumpSessionCount`). */
29
+ export const questionnaireSessionOpenKey = (id: string): string =>
30
+ `wire_questionnaire_${id}_open`;
@@ -75,7 +75,11 @@ export const fetchQuestionnaireDecision = async (
75
75
  const res = await fetch(url, { headers });
76
76
  if (!res || !res.ok) return null;
77
77
  const json = (await res.json()) as QuestionnaireDecisionResponse | null;
78
- return json ?? null;
78
+ // A body without a boolean `fire` is not a decision. Guard it explicitly (mirrors
79
+ // `fetchReviewDecision`) rather than letting `{}` through as a truthy object a gate would treat
80
+ // as a verdict (`{}.fire === undefined` is falsy, so it would silently read as "never fire").
81
+ if (!json || typeof json.fire !== "boolean") return null;
82
+ return json;
79
83
  } catch {
80
84
  /* unreachable / non-2xx / bad JSON / missing-fetch - never show */
81
85
  return null;
@@ -20,8 +20,10 @@ import { useResolvedFeatures } from "../features/WireFeaturesProvider";
20
20
  import { sameDecision, shallowEqual } from "../reviews/equality";
21
21
  import { decideQuestionnaire, evaluateGate, resolveRules } from "./decision";
22
22
  import {
23
+ bumpSessionCount,
23
24
  questionnaireLastShownKey,
24
25
  questionnaireSeenKey,
26
+ questionnaireSessionOpenKey,
25
27
  questionnaireSessionsKey,
26
28
  readInt,
27
29
  resolveStorage,
@@ -71,14 +73,14 @@ export const useQuestionnaireGate = ({
71
73
  );
72
74
  const lastKey = questionnaireLastShownKey(config.id);
73
75
  const sessionsKey = questionnaireSessionsKey(config.id);
76
+ const sessionOpenKey = questionnaireSessionOpenKey(config.id);
74
77
 
75
- // Read (and bump) the session counter ONCE per mount: this mount is a new session.
76
- const sessions = useState(() => {
77
- const store = resolveStorage(storage);
78
- const next = readInt(store, sessionsKey) + 1;
79
- writeInt(store, sessionsKey, next);
80
- return next;
81
- })[0];
78
+ // Read (and bump) the app-open counter. IDEMPOTENT per app-open, NOT per mount see the same note
79
+ // in `useReviewGate`; `bumpSessionCount` keys off the live per-open session id, so a remount or a
80
+ // StrictMode double-invoke of this initializer reads the same number back instead of inflating it.
81
+ const sessions = useState(() =>
82
+ bumpSessionCount(resolveStorage(storage), sessionsKey, sessionOpenKey),
83
+ )[0];
82
84
 
83
85
  // Gate the local rules behind an optional client-side timeout, so a reachable server gets a
84
86
  // window to answer first. A present `decision` bypasses the wait entirely.
@@ -0,0 +1,55 @@
1
+ /**
2
+ * revenuecat - the drop-in RevenueCat to Wire activation path.
3
+ *
4
+ * Re-exported from the main `@wireai/activation` barrel (no separate subpath): it is a pure,
5
+ * dependency-free mapping layer with no UI, so it costs an analytics-only consumer nothing.
6
+ *
7
+ * Adopting it is a constructor plus your existing paywall call sites:
8
+ *
9
+ * import { createAnalytics, createRevenueCatBridge, activationJoinContext } from "@wireai/activation";
10
+ *
11
+ * const analytics = createAnalytics({ serverUrl, apiKey, storage, userContext: { deviceKey } });
12
+ * const revenuecat = createRevenueCatBridge({ analytics, entitlementId: "pro" });
13
+ *
14
+ * revenuecat.paywallShown(offering, { source: variant });
15
+ * revenuecat.checkoutStarted(pkg, { source: variant });
16
+ * const entitled = revenuecat.purchaseCompleted(customerInfo, pkg, { source: variant });
17
+ *
18
+ * And the join that makes the numbers real, on the onboarding side:
19
+ *
20
+ * <WireOnboarding userContext={activationJoinContext(deviceKey)} ... />
21
+ *
22
+ * See `revenueCatBridge.ts` for why that key is `device_key` and never `session_id`.
23
+ */
24
+
25
+ // ─── The bridge (the thing you wire) ──────────────────────────────────────────
26
+ export { createRevenueCatBridge } from "./revenueCatBridge";
27
+ export type {
28
+ RevenueCatBridge,
29
+ RevenueCatBridgeConfig,
30
+ PurchaseProps,
31
+ } from "./revenueCatBridge";
32
+
33
+ // ─── Canonical purchase-funnel names + the pure mappers behind the bridge ─────
34
+ export {
35
+ WIRE_PURCHASE_EVENTS,
36
+ PLAN_TIER_CONTEXT_KEY,
37
+ activeEntitlement,
38
+ describeEntitlement,
39
+ describeFailure,
40
+ describePackage,
41
+ isUserCancelled,
42
+ resolvePlanTier,
43
+ } from "./purchaseEvents";
44
+ export type { WirePurchaseEventName, PlanTier } from "./purchaseEvents";
45
+
46
+ // ─── Structural mirrors of the react-native-purchases shapes (no native dep) ──
47
+ export type {
48
+ RevenueCatCustomerInfoLike,
49
+ RevenueCatEntitlementLike,
50
+ RevenueCatErrorLike,
51
+ RevenueCatOfferingLike,
52
+ RevenueCatPackageLike,
53
+ RevenueCatProductLike,
54
+ RevenueCatSink,
55
+ } from "./types";