@wireai/activation 0.10.0 → 0.11.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 (42) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +33 -4
  3. package/dist/analytics/index.d.mts +21 -3
  4. package/dist/analytics/index.d.ts +21 -3
  5. package/dist/analytics/index.js +94 -61
  6. package/dist/analytics/index.js.map +1 -1
  7. package/dist/analytics/index.mjs +91 -62
  8. package/dist/analytics/index.mjs.map +1 -1
  9. package/dist/{currentSession-DdDkprpM.d.mts → currentSession-C0_odnIW.d.mts} +101 -1
  10. package/dist/{currentSession-D0Vq7_VE.d.ts → currentSession-DdnUq2HQ.d.ts} +101 -1
  11. package/dist/index.d.mts +3 -49
  12. package/dist/index.d.ts +3 -49
  13. package/dist/index.js +50 -41
  14. package/dist/index.js.map +1 -1
  15. package/dist/index.mjs +47 -42
  16. package/dist/index.mjs.map +1 -1
  17. package/dist/questionnaire/index.js +10 -2
  18. package/dist/questionnaire/index.js.map +1 -1
  19. package/dist/questionnaire/index.mjs +10 -2
  20. package/dist/questionnaire/index.mjs.map +1 -1
  21. package/dist/reviews/index.d.mts +1 -1
  22. package/dist/reviews/index.d.ts +1 -1
  23. package/dist/reviews/index.js +24 -9
  24. package/dist/reviews/index.js.map +1 -1
  25. package/dist/reviews/index.mjs +24 -9
  26. package/dist/reviews/index.mjs.map +1 -1
  27. package/dist/{transport-Bzb-bcB2.d.mts → transport-BGW9uXZJ.d.mts} +0 -15
  28. package/dist/{transport-B31G0Cib.d.ts → transport-jUJd5kxu.d.ts} +0 -15
  29. package/llms.txt +8 -0
  30. package/package.json +1 -1
  31. package/src/analytics/analyticsFacade.ts +72 -9
  32. package/src/analytics/eventQueue.ts +12 -11
  33. package/src/analytics/index.ts +9 -0
  34. package/src/analytics/reportClientEvent.ts +12 -2
  35. package/src/context/userContext.ts +55 -0
  36. package/src/identity/userIdentity.ts +10 -0
  37. package/src/index.ts +5 -1
  38. package/src/questionnaire/transport.ts +5 -1
  39. package/src/reviews/decision.ts +8 -1
  40. package/src/reviews/transport.ts +6 -8
  41. package/src/session-analytics/lifecycle.ts +6 -11
  42. package/src/session-analytics/useLifecycleEvents.ts +41 -27
@@ -1,20 +1,5 @@
1
1
  import { k as ReviewTarget, g as ReviewDecisionResponse, j as ReviewSubmission } from './types-CNUqMK0D.mjs';
2
2
 
3
- /**
4
- * transport.ts — kit → Wire server requests for the review module, all fire-and-forget
5
- * (analytics/reviews must never break the app). Mirrors analytics/reportClientEvent: a
6
- * thin fetch wrapper, Bearer tenant key, swallow every error.
7
- *
8
- * • submitReview → POST {serverUrl}/v1/reviews (the review row)
9
- * • fetchReviewDecision → GET {serverUrl}/v1/reviews/decision (best-effort, the AI seam)
10
- * • reportAppEvent → POST {serverUrl}/v1/events (generic app.* namespace)
11
- *
12
- * `reportAppEvent` is the strategic extension: it lets a host report arbitrary in-app
13
- * events through the SAME transport (stored server-side as event_type='app_event',
14
- * question_key=<name>), which is what the backend review-firing rules evaluate on — and
15
- * it seeds the broader app-analytics stream. Keep payloads minimal + non-PII.
16
- */
17
-
18
3
  /**
19
4
  * POST a review (the 1-4 feedback path). Fire-and-forget: a missing target, a build error,
20
5
  * a missing `fetch`, or a network failure is swallowed and the call returns immediately.
@@ -1,20 +1,5 @@
1
1
  import { k as ReviewTarget, g as ReviewDecisionResponse, j as ReviewSubmission } from './types-Buj9Lw9t.js';
2
2
 
3
- /**
4
- * transport.ts — kit → Wire server requests for the review module, all fire-and-forget
5
- * (analytics/reviews must never break the app). Mirrors analytics/reportClientEvent: a
6
- * thin fetch wrapper, Bearer tenant key, swallow every error.
7
- *
8
- * • submitReview → POST {serverUrl}/v1/reviews (the review row)
9
- * • fetchReviewDecision → GET {serverUrl}/v1/reviews/decision (best-effort, the AI seam)
10
- * • reportAppEvent → POST {serverUrl}/v1/events (generic app.* namespace)
11
- *
12
- * `reportAppEvent` is the strategic extension: it lets a host report arbitrary in-app
13
- * events through the SAME transport (stored server-side as event_type='app_event',
14
- * question_key=<name>), which is what the backend review-firing rules evaluate on — and
15
- * it seeds the broader app-analytics stream. Keep payloads minimal + non-PII.
16
- */
17
-
18
3
  /**
19
4
  * POST a review (the 1-4 feedback path). Fire-and-forget: a missing target, a build error,
20
5
  * a missing `fetch`, or a network failure is swallowed and the call returns immediately.
package/llms.txt CHANGED
@@ -23,6 +23,14 @@
23
23
  - `attributionMetadata(a)` → shape install/ad attribution into `config.metadata` (forwarded to the agent).
24
24
  - `reportClientEvent(target, event)` / `reportClientEvents` / `makeSessionId` (ROOT-exported, not a subpath) → report device-only funnel events. Contract: `POST {serverUrl}/v1/events`, header `Authorization: Bearer {apiKey}`, body `{ "events": [ ... ] }`; `target = { serverUrl, apiKey }` from the config. `<WireOnboarding>` does this automatically: `dropped` on unmount-without-complete, `client_fallback` on degrade-to-static. Hosts must not double-report fallback.
25
25
  - `deriveAnswers(messages)`, `themeFromBrand({ primary })`, `defaultIllustrations`, `DemoOnboarding` (dev/QA, no account).
26
+
27
+ ## Subpath surfaces (beyond onboarding)
28
+
29
+ The package is more than `<WireOnboarding>`. Tree-shakeable subpath exports (each keeps the onboarding UI out of an analytics-only bundle):
30
+
31
+ - `@wireai/activation/analytics`: `createAnalytics({ serverUrl, apiKey, storage })` (Segment/PostHog-shaped `track` / `screen` / `identify` / `setUserContext` / `reset` over an offline-first queue), `createScreenTracker` / `screenTrackingHandler` (auto screen views), `reportAppEvent`, `createEventQueue`, `clearUserContext` (logout). Rich `WireUserContext` supports an opt-in `userEmail` (raw by default, `hashEmail` to fold) and auto-mints a persisted per-install `device_key`. `identify` refuses email-shaped ids unless `allowEmailAsUserId`. See the README "Rich user context & PII" section.
32
+ - `@wireai/activation/reviews`: the in-app review gate. `useReviewGate`, `fetchReviewDecision` (server AI seam; a `{fire:false}` survives intact), `ReviewGate` UI. `minSessions` defaults to 2 (never prompts on the first session; a server decision still overrides).
33
+ - `@wireai/activation/questionnaire`: the pre-onboarding questionnaire gate. `useQuestionnaireGate`, `fetchQuestionnaireDecision` (same server seam; a body without a boolean `fire` resolves to null, never shows).
26
34
  - `useWireActivation({ serverUrl, apiKey, deviceKey? })` → `{ track, sessionId, revalidation }` (ROOT-exported; React-free factory `createWireActivation(config)`). `await track(name, meta?)` POSTs an `app_event` (`question_key=name`) under the current session, resolves `true` on 2xx, and bumps `revalidation`; list `revalidation` in a `fetchReviewDecision` / `fetchQuestionnaireDecision` effect's deps so a review/questionnaire gate re-fetches and fires off an in-app action instead of the host hand-rolling session-id + await-POST + revalidate.
27
35
 
28
36
  ## Files
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wireai/activation",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "private": false,
5
5
  "description": "Premium, fully-themable drop-in AI onboarding kit for React Native / Expo, on top of the open-source wireai-rn SDK.",
6
6
  "author": "Malik Chohra <malik@getwireai.com>",
@@ -30,9 +30,24 @@ import { buildContextEnvelope, type ContextEnvelope } from "./contextEnvelope";
30
30
  import { getCurrentSessionId } from "./currentSession";
31
31
  import { createEventQueue, type EventQueue, type EventQueueOptions } from "./eventQueue";
32
32
  import { makeSessionId, type ClientEvent } from "./reportClientEvent";
33
- import { resolveUserContext, type WireUserContext } from "../context/userContext";
33
+ import {
34
+ analyticsUserIdStorageKey,
35
+ clearPiiFromContext,
36
+ resolveUserContext,
37
+ type WireUserContext,
38
+ } from "../context/userContext";
34
39
  import { mintDeviceId, deviceIdStorageKey } from "../context/deviceId";
35
- import { sanitizeUserId } from "../identity/userIdentity";
40
+ import { looksLikeEmail, sanitizeUserId } from "../identity/userIdentity";
41
+
42
+ /** RN sets this global; absent under node/SSR. Read defensively via {@link warnInDev}. */
43
+ declare const __DEV__: boolean | undefined;
44
+
45
+ /** Emit a one-line developer warning, but ONLY in a dev build (RN `__DEV__`). No-op in prod/tests. */
46
+ const warnInDev = (message: string): void => {
47
+ if (typeof __DEV__ !== "undefined" && __DEV__ && typeof console !== "undefined" && console.warn) {
48
+ console.warn(message);
49
+ }
50
+ };
36
51
 
37
52
  /** Arbitrary non-PII event properties. Serialized to the event's `meta` (a JSON string) on the wire. */
38
53
  export type AnalyticsProps = Record<string, unknown>;
@@ -76,6 +91,14 @@ export type CreateAnalyticsConfig = {
76
91
  * review/questionnaire gating + A/B stickiness. Supply `deviceKey` only to use your OWN id (it wins).
77
92
  */
78
93
  userContext?: WireUserContext;
94
+ /**
95
+ * ESCAPE HATCH for the email-shape guard. By default `identify(id)` and a `setUserContext({ userId })`
96
+ * REFUSE to bind an id that looks like an email (`local@domain.tld`) and warn in dev — because a
97
+ * raw email in the opaque `user_id` is a PII leak; an email belongs in the opt-in
98
+ * `userContext.userEmail` field. Set `true` ONLY if your real internal user id genuinely IS an
99
+ * email address and you accept it as the pseudonymous key. Default `false` (guard on).
100
+ */
101
+ allowEmailAsUserId?: boolean;
79
102
  };
80
103
 
81
104
  /** Optional queue tuning knobs, forwarded verbatim to {@link createEventQueue}. */
@@ -101,6 +124,16 @@ export type Analytics = {
101
124
  * binds like {@link identify}. Takes effect on subsequent events. Fire-and-forget.
102
125
  */
103
126
  setUserContext(partial: Partial<WireUserContext>): void;
127
+ /**
128
+ * LOGOUT: unbind the current user so a shared device never attributes user B's events to user A.
129
+ * Clears the in-memory `boundUserId`, strips the PII / pseudonymous fields (`userId`, `userEmail`,
130
+ * `extra`) from the bound {@link WireUserContext} (keeping the non-PII `device_key` + `appVersion`,
131
+ * which group a DEVICE not a user), and removes the persisted `wireai:analytics:userId:<appId>`
132
+ * key so it cannot be rehydrated on the next launch. Subsequent events are anonymous until the
133
+ * next `identify` / `setUserContext`. Fire-and-forget; mirrors the `reset()` convention on the
134
+ * screen tracker. The standalone `clearUserContext({ storage, appId })` covers the `wire` path.
135
+ */
136
+ reset(): void;
104
137
  /** Attempt an immediate drain of the pending buffer. Fire-and-forget. */
105
138
  flush(): void;
106
139
  /** Host reconnect signal: reset backoff and drain now. Fire-and-forget. */
@@ -185,7 +218,7 @@ export const createAnalytics = (
185
218
  // Per-session, in-memory user binding. Seeded from the init context, then persisted across
186
219
  // launches when storage is provided.
187
220
  let boundUserId: string | undefined = sanitizeUserId(config.userContext?.userId);
188
- const storageKey = `wireai:analytics:userId:${config.appId ?? "default"}`;
221
+ const storageKey = analyticsUserIdStorageKey(config.appId);
189
222
 
190
223
  if (config.storage) {
191
224
  void config.storage
@@ -217,6 +250,19 @@ export const createAnalytics = (
217
250
  if (boundUserId && !event.user_id) event.user_id = boundUserId;
218
251
  };
219
252
 
253
+ // Email-shape guard for the OPAQUE user id. A raw email bound as `user_id` is a PII leak (it
254
+ // belongs in the opt-in `user_context.user_email`), so refuse it and warn in dev — unless the host
255
+ // opted in via `allowEmailAsUserId`. Returns the id to bind, or `undefined` to refuse.
256
+ const guardUserId = (clean: string): string | undefined => {
257
+ if (config.allowEmailAsUserId || !looksLikeEmail(clean)) return clean;
258
+ warnInDev(
259
+ "[wireai] identify() was called with an email-shaped id. A raw email must NOT be the opaque " +
260
+ "user_id (PII leak) — pass it as userContext.userEmail instead. Binding was skipped. Set " +
261
+ "allowEmailAsUserId:true on createAnalytics if your user id genuinely is an email.",
262
+ );
263
+ return undefined;
264
+ };
265
+
220
266
  const setUserContext = (partial: Partial<WireUserContext>): void => {
221
267
  if (!partial || typeof partial !== "object") return;
222
268
  // Deep-merge `extra` so a partial update adds keys instead of replacing the whole map.
@@ -226,14 +272,27 @@ export const createAnalytics = (
226
272
  : undefined;
227
273
  userContext = { ...userContext, ...partial };
228
274
  if (mergedExtra) userContext.extra = mergedExtra;
229
- // A user id supplied here binds like `identify` so subsequent events carry `user_id`.
275
+ // A user id supplied here binds like `identify` so subsequent events carry `user_id` — through
276
+ // the SAME email-shape guard (a raw email must not become the opaque user_id).
230
277
  const uid = sanitizeUserId(partial.userId);
231
278
  if (uid) {
232
- boundUserId = uid;
233
- if (config.storage) void config.storage.setItem(storageKey, uid).catch(() => {});
279
+ const bindable = guardUserId(uid);
280
+ if (bindable) {
281
+ boundUserId = bindable;
282
+ if (config.storage) void config.storage.setItem(storageKey, bindable).catch(() => {});
283
+ }
234
284
  }
235
285
  };
236
286
 
287
+ const reset = (): void => {
288
+ // In-memory binding cleared: subsequent events carry no user_id until the next identify.
289
+ boundUserId = undefined;
290
+ // Strip PII from the rich context but keep the device-scope fields (device_key / app_version).
291
+ userContext = clearPiiFromContext(userContext);
292
+ // Remove the persisted binding so a relaunch can't rehydrate the previous user's id.
293
+ if (config.storage) void config.storage.removeItem(storageKey).catch(() => {});
294
+ };
295
+
237
296
  const track = (event: string, props?: AnalyticsProps): void => {
238
297
  if (!event) return;
239
298
  const clientEvent: ClientEvent = {
@@ -264,16 +323,19 @@ export const createAnalytics = (
264
323
  const clean = sanitizeUserId(userId);
265
324
  // Blank / non-string → no binding, no event (sanitizeUserId returns undefined). >128 → truncated.
266
325
  if (!clean) return;
267
- boundUserId = clean;
326
+ // Email-shape guard: refuse to bind (and emit) a raw email as the opaque user_id unless opted in.
327
+ const bindable = guardUserId(clean);
328
+ if (!bindable) return;
329
+ boundUserId = bindable;
268
330
  if (config.storage) {
269
- void config.storage.setItem(storageKey, clean).catch(() => {});
331
+ void config.storage.setItem(storageKey, bindable).catch(() => {});
270
332
  }
271
333
  const clientEvent: ClientEvent = {
272
334
  event_type: "identify",
273
335
  // Reuse the LIVE per-open session id (see `resolveSessionId`) so the server binds identity to
274
336
  // the session it already saw instead of back-filling a phantom `session_started`.
275
337
  session_id: resolveSessionId(),
276
- user_id: clean,
338
+ user_id: bindable,
277
339
  };
278
340
  if (traits && Object.keys(traits).length > 0) clientEvent.meta = JSON.stringify(traits);
279
341
  applyContext(clientEvent);
@@ -285,6 +347,7 @@ export const createAnalytics = (
285
347
  screen,
286
348
  identify,
287
349
  setUserContext,
350
+ reset,
288
351
  flush: queue.flush,
289
352
  notifyOnline: queue.notifyOnline,
290
353
  size: queue.size,
@@ -24,7 +24,7 @@
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 { buildEventsRequest, type ClientEvent, type ClientEventTarget } from "./reportClientEvent";
28
28
  import type { WireOnboardingStorage } from "../session/persistedSession";
29
29
 
30
30
  /** Envelope source: a fixed envelope or a provider evaluated at enqueue time (fresh network type). */
@@ -161,6 +161,11 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
161
161
  const stamp = (event: ClientEvent): ClientEvent => {
162
162
  const env = resolveEnvelope();
163
163
  const stamped: ClientEvent = { ...event };
164
+ // Client enqueue timestamp: distinguishes a GENUINE repeat (same event fired seconds apart)
165
+ // from a REDUNDANT re-enqueue of the same instant. The de-dup signature below includes it, so
166
+ // two identical events enqueued in the same millisecond still collapse (a re-render), while the
167
+ // same action repeated later carries a fresh `ts` and survives. A caller-set `ts` is preserved.
168
+ if (stamped.ts === undefined) stamped.ts = Date.now();
164
169
  if (!env) return stamped;
165
170
  if (!stamped.device && env.device) stamped.device = env.device;
166
171
  if (!stamped.session_id && env.sessionId) stamped.session_id = env.sessionId;
@@ -227,19 +232,15 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
227
232
  // The queue's OWN awaited POST. Reads `res.ok` to drive retry/dequeue. NEVER throws — a missing
228
233
  // fetch, a rejecting network, or a JSON error resolves to `false` (batch stays, retry schedules).
229
234
  const postBatch = async (events: ClientEvent[]): Promise<boolean> => {
230
- if (!target?.serverUrl || events.length === 0) return false;
235
+ // Build the /v1/events request through the ONE canonical builder (url + headers + body) so this
236
+ // queue never re-describes the endpoint. The abort-timeout stays: the queue owns retry/dequeue,
237
+ // so a hung request must be cut loose to schedule a backoff rather than block the drain forever.
238
+ const req = buildEventsRequest(target, events);
239
+ if (!req) return false;
231
240
  const controller = typeof AbortController !== "undefined" ? new AbortController() : undefined;
232
241
  const timer = setTimeout(() => controller?.abort(), 15_000);
233
242
  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
- });
243
+ const res = await fetch(req.url, { ...req.init, signal: controller?.signal });
243
244
  return !!(res && (res as { ok?: boolean }).ok);
244
245
  } catch {
245
246
  return false;
@@ -64,5 +64,14 @@ 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
+
67
76
  // ─── Current per-open session registry (identify/app-events reuse the live session) ───
68
77
  export { getCurrentSessionId, setCurrentSessionId, resetCurrentSessionId } 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;
@@ -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,60 @@ 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
+
155
210
  /** Options for {@link resolveUserContext}. */
156
211
  export interface ResolveUserContextOptions {
157
212
  /**
@@ -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,9 @@ export {
150
150
  namespaceExtra,
151
151
  hashEmailFnv1a,
152
152
  isWireScalar,
153
+ clearUserContext,
154
+ clearPiiFromContext,
155
+ analyticsUserIdStorageKey,
153
156
  RESERVED_USER_CONTEXT_KEYS,
154
157
  EXTRA_KEY_PREFIX,
155
158
  } from "./context/userContext";
@@ -157,6 +160,7 @@ export type {
157
160
  WireUserContext,
158
161
  ResolvedUserContext,
159
162
  ResolveUserContextOptions,
163
+ ClearUserContextOptions,
160
164
  } from "./context/userContext";
161
165
  export { mintDeviceId, deviceIdStorageKey, AUTO_DEVICE_ID_PREFIX } from "./context/deviceId";
162
166
 
@@ -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;
@@ -64,7 +64,14 @@ export const resolveRules = (config: {
64
64
  oncePerVersion?: boolean;
65
65
  }): GateRules => ({
66
66
  enabled: config.enabled ?? true,
67
- minSessions: config.minSessions ?? 0,
67
+ // FAIL-CLOSED on the first session (behavior change, 0.11.0). An unconfigured host with no
68
+ // server decision must NOT prompt on the very first mount: `minSessions` defaults to 2 so the
69
+ // gate needs at least a second session before the LOCAL rules can fire. This is the client-side
70
+ // floor only — a server `decision` (from `fetchReviewDecision`) still OVERRIDES everything, and a
71
+ // host that genuinely wants first-session prompting can set `minSessions: 1` (or `0`) explicitly.
72
+ // Mirrors the 2026-07-16 incident: a first-session user, no server decision, got the prompt and
73
+ // left 1 star. See `evaluateGate` (`min_sessions` reason) and the CHANGELOG.
74
+ minSessions: config.minSessions ?? 2,
68
75
  minEvents: config.minEvents ?? 0,
69
76
  cooldownDays: config.cooldownDays ?? 0,
70
77
  oncePerVersion: config.oncePerVersion ?? true,
@@ -12,6 +12,7 @@
12
12
  * question_key=<name>), which is what the backend review-firing rules evaluate on — and
13
13
  * it seeds the broader app-analytics stream. Keep payloads minimal + non-PII.
14
14
  */
15
+ import { buildEventsRequest, type ClientEvent } from "../analytics/reportClientEvent";
15
16
  import type { ReviewDecisionResponse, ReviewSubmission, ReviewTarget } from "./types";
16
17
 
17
18
  /**
@@ -178,9 +179,6 @@ export const reportAppEvent = (
178
179
  ): void => {
179
180
  if (!target?.serverUrl || !name) return;
180
181
  try {
181
- const url = `${target.serverUrl.replace(/\/$/, "")}/v1/events`;
182
- const headers: Record<string, string> = { "Content-Type": "application/json" };
183
- if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
184
182
  const event: Record<string, unknown> = {
185
183
  event_type: "app_event",
186
184
  question_key: name,
@@ -192,11 +190,11 @@ export const reportAppEvent = (
192
190
  if (options.meta && Object.keys(options.meta).length > 0) {
193
191
  event.meta = JSON.stringify(options.meta);
194
192
  }
195
- void fetch(url, {
196
- method: "POST",
197
- headers,
198
- body: JSON.stringify({ events: [event] }),
199
- }).catch(() => {
193
+ // Route through the ONE canonical /v1/events builder (url + headers + body) instead of
194
+ // re-describing the endpoint here; still fire-and-forget.
195
+ const req = buildEventsRequest(target, [event as unknown as ClientEvent]);
196
+ if (!req) return;
197
+ void fetch(req.url, req.init).catch(() => {
200
198
  /* best-effort, swallow */
201
199
  });
202
200
  } catch {
@@ -34,7 +34,7 @@
34
34
  * FIRE-AND-FORGET: like every analytics path in the kit, nothing here throws into the UI, awaits
35
35
  * in the caller, or hangs the app — storage reads race a short timeout, writes swallow errors.
36
36
  */
37
- import { makeSessionId, type ClientEvent, type ClientEventTarget } from "../analytics/reportClientEvent";
37
+ import { buildEventsRequest, makeSessionId, type ClientEvent, type ClientEventTarget } from "../analytics/reportClientEvent";
38
38
  import type { DeviceContext } from "../device/deviceContext";
39
39
  import { sanitizeUserId } from "../identity/userIdentity";
40
40
  import type { WireOnboardingStorage } from "../session/persistedSession";
@@ -141,16 +141,11 @@ const routeLifecycleEvent = (event: Record<string, unknown>, opts: LifecycleEven
141
141
  opts.sink(event as ClientEvent);
142
142
  return;
143
143
  }
144
- const target = opts.target;
145
- if (!target?.serverUrl) return;
146
- const url = `${target.serverUrl.replace(/\/$/, "")}/v1/events`;
147
- const headers: Record<string, string> = { "Content-Type": "application/json" };
148
- if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
149
- void fetch(url, {
150
- method: "POST",
151
- headers,
152
- body: JSON.stringify({ events: [event] }),
153
- }).catch(() => {
144
+ // Route the direct-POST fallback through the ONE canonical /v1/events builder (url + headers +
145
+ // body) instead of re-describing the endpoint here; still fire-and-forget.
146
+ const req = buildEventsRequest(opts.target, [event as unknown as ClientEvent]);
147
+ if (!req) return;
148
+ void fetch(req.url, req.init).catch(() => {
154
149
  // Network/transport error — analytics is best-effort, swallow.
155
150
  });
156
151
  } catch {
@@ -28,7 +28,7 @@ import { useEffect, useRef } from "react";
28
28
  import { AppState, Platform, type AppStateStatus } from "react-native";
29
29
 
30
30
  import { createEventQueue, type EnvelopeSource, type EventQueue } from "../analytics/eventQueue";
31
- import type { ClientEvent, ClientEventTarget } from "../analytics/reportClientEvent";
31
+ import { makeSessionId, type ClientEvent, type ClientEventTarget } from "../analytics/reportClientEvent";
32
32
  import { collectDeviceContext } from "../device/deviceContext";
33
33
  import type { WireOnboardingStorage } from "../session/persistedSession";
34
34
  import { reportFirstOpen } from "./lifecycle";
@@ -114,7 +114,45 @@ export const useLifecycleEvents = (
114
114
  const targetOf = (cfg: LifecycleConfig | undefined): ClientEventTarget | undefined =>
115
115
  cfg?.serverUrl ? { serverUrl: cfg.serverUrl, apiKey: cfg.apiKey ?? "" } : undefined;
116
116
 
117
- // 1) first_open once ever (persisted flag + in-memory latch inside reportFirstOpen).
117
+ // ONE per-open session id for the MOUNT open, shared by first_open AND session_started below.
118
+ // WHY: `first_open` used to default to its OWN fresh `makeSessionId()` (via `buildLifecycleEvent`)
119
+ // while `session_started` minted a different one — so every install's `first_open` carried a
120
+ // session id the server never got a `session_started` for (a PHANTOM session the server
121
+ // back-filled). Minting one id here and handing it to both makes them agree, so the server sees
122
+ // `first_open` under the same session it saw `session_started` for. A later foreground open
123
+ // (below) mints its OWN fresh id — a new open is a new session.
124
+ const mountOpenSessionId = makeSessionId();
125
+
126
+ // session_start — the EXISTING emitter routed through the sink (offline-buffered, one guard).
127
+ // Accepts the per-open id so the mount open reuses `mountOpenSessionId`; omit it and the emitter
128
+ // mints a fresh id (a genuinely new open). Either way the once-guard dedupes within the open.
129
+ const fireSession = (sessionId?: string) => {
130
+ const { config: cfg, options: opts } = latest.current;
131
+ if (opts.enabled === false) return;
132
+ if (!cfg?.serverUrl && !opts.sink) return;
133
+ const device = collectDeviceContext();
134
+ // `device.appVersion` is auto-detected best-effort; an explicit host version always wins.
135
+ if (cfg?.appVersion) device.appVersion = cfg.appVersion;
136
+ reportSessionStart({
137
+ target: targetOf(cfg),
138
+ sink: resolveSink(),
139
+ sessionId,
140
+ userId: opts.userId,
141
+ deviceKey: opts.deviceKey,
142
+ sessionCount: opts.sessionCount,
143
+ appVersion: cfg?.appVersion ?? device.appVersion,
144
+ platform: Platform.OS,
145
+ device,
146
+ meta: opts.meta,
147
+ });
148
+ };
149
+
150
+ // 1) session_start FIRST (mount = an app-open), so the server has ingested `session_started`
151
+ // for `mountOpenSessionId` before first_open references the same id.
152
+ fireSession(mountOpenSessionId);
153
+
154
+ // 2) first_open — once ever (persisted flag + in-memory latch inside reportFirstOpen), pinned to
155
+ // the SAME per-open id so it is never a phantom session.
118
156
  {
119
157
  const { config: cfg, options: opts } = latest.current;
120
158
  if (opts.enabled !== false) {
@@ -124,6 +162,7 @@ export const useLifecycleEvents = (
124
162
  reportFirstOpen({
125
163
  target: targetOf(cfg),
126
164
  sink: resolveSink(),
165
+ sessionId: mountOpenSessionId,
127
166
  storage: cfg?.storage,
128
167
  appId: cfg?.appId,
129
168
  userId: opts.userId,
@@ -137,31 +176,6 @@ export const useLifecycleEvents = (
137
176
  }
138
177
  }
139
178
 
140
- // 2) session_start — the EXISTING emitter routed through the sink (offline-buffered, one guard).
141
- const fireSession = () => {
142
- const { config: cfg, options: opts } = latest.current;
143
- if (opts.enabled === false) return;
144
- if (!cfg?.serverUrl && !opts.sink) return;
145
- const device = collectDeviceContext();
146
- // `device.appVersion` is auto-detected best-effort; an explicit host version always wins.
147
- if (cfg?.appVersion) device.appVersion = cfg.appVersion;
148
- reportSessionStart({
149
- target: targetOf(cfg),
150
- sink: resolveSink(),
151
- // A fresh per-open id each fire; the emitter's once-guard dedupes within the open.
152
- userId: opts.userId,
153
- deviceKey: opts.deviceKey,
154
- sessionCount: opts.sessionCount,
155
- appVersion: cfg?.appVersion ?? device.appVersion,
156
- platform: Platform.OS,
157
- device,
158
- meta: opts.meta,
159
- });
160
- };
161
-
162
- // Mount = an app-open.
163
- fireSession();
164
-
165
179
  // Foreground after a real background = a new app-open.
166
180
  let backgroundedAt: number | null = null;
167
181
  const onChange = (state: AppStateStatus) => {