@wireai/activation 0.4.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wireai/activation",
3
- "version": "0.4.0",
3
+ "version": "0.8.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>",
@@ -61,9 +61,10 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
61
61
  const boundUserId = useMemo(() => sanitizeUserId(userId), [userId]);
62
62
 
63
63
  // Privacy-label-neutral device snapshot, collected once per mount (no advertising IDs, no
64
- // fingerprinting — see device/deviceContext.ts). `config.appVersion` is host-injected and
65
- // merged in here (collectDeviceContext never sets it). Attached to BOTH the A2A session
66
- // metadata and every client event so the backend can segment the funnel by device.
64
+ // fingerprinting — see device/deviceContext.ts). `collectDeviceContext` already carries a
65
+ // best-effort auto-detected `appVersion`; an explicit host `config.appVersion` overrides it.
66
+ // Attached to BOTH the A2A session metadata and every client event so the backend can segment
67
+ // the funnel by device (and by app version).
67
68
  const device = useMemo<DeviceContext>(() => {
68
69
  const collected = collectDeviceContext();
69
70
  return config.appVersion ? { ...collected, appVersion: config.appVersion } : collected;
@@ -27,8 +27,11 @@
27
27
  * FIRE-AND-FORGET: no method throws into the UI or blocks — the queue already guarantees that.
28
28
  */
29
29
  import { buildContextEnvelope, type ContextEnvelope } from "./contextEnvelope";
30
+ import { getCurrentSessionId } from "./currentSession";
30
31
  import { createEventQueue, type EventQueue, type EventQueueOptions } from "./eventQueue";
31
32
  import { makeSessionId, type ClientEvent } from "./reportClientEvent";
33
+ import { resolveUserContext, type WireUserContext } from "../context/userContext";
34
+ import { mintDeviceId, deviceIdStorageKey } from "../context/deviceId";
32
35
  import { sanitizeUserId } from "../identity/userIdentity";
33
36
 
34
37
  /** Arbitrary non-PII event properties. Serialized to the event's `meta` (a JSON string) on the wire. */
@@ -62,6 +65,17 @@ export type CreateAnalyticsConfig = {
62
65
  appBuild?: string;
63
66
  /** Host connectivity signal, e.g. "wifi" | "cellular" — read fresh per event via the provider. */
64
67
  networkType?: string;
68
+ /**
69
+ * The rich {@link WireUserContext} to stamp onto every event's `user_context` (device key, opaque
70
+ * user id, opt-in email, arbitrary `extra`). Passed ONCE here at init; updatable post-mount via
71
+ * {@link Analytics.setUserContext} (e.g. attach `userId`/`userEmail` at login). Optional.
72
+ *
73
+ * NOTE on `deviceKey`: you do NOT need to supply one. When omitted, the kit auto-mints a stable,
74
+ * non-PII per-install `device_key`, persists it via `storage`, and reuses it every open (in-memory
75
+ * fallback without storage) — so `user_context.device_key` is ALWAYS present for the server's
76
+ * review/questionnaire gating + A/B stickiness. Supply `deviceKey` only to use your OWN id (it wins).
77
+ */
78
+ userContext?: WireUserContext;
65
79
  };
66
80
 
67
81
  /** Optional queue tuning knobs, forwarded verbatim to {@link createEventQueue}. */
@@ -81,6 +95,12 @@ export type Analytics = {
81
95
  screen(name: string, props?: AnalyticsProps): void;
82
96
  /** Bind the host's opaque user id (per-session, in-memory) and emit an `identify` event. */
83
97
  identify(userId: string, traits?: AnalyticsProps): void;
98
+ /**
99
+ * Update the {@link WireUserContext} after init (e.g. attach `userId`/`userEmail` at login). Shallow
100
+ * merges the partial over the current context (`extra` is deep-merged); a supplied `userId` also
101
+ * binds like {@link identify}. Takes effect on subsequent events. Fire-and-forget.
102
+ */
103
+ setUserContext(partial: Partial<WireUserContext>): void;
84
104
  /** Attempt an immediate drain of the pending buffer. Fire-and-forget. */
85
105
  flush(): void;
86
106
  /** Host reconnect signal: reset backoff and drain now. Fire-and-forget. */
@@ -98,13 +118,58 @@ export const createAnalytics = (
98
118
  config: CreateAnalyticsConfig,
99
119
  options: AnalyticsOptions = {},
100
120
  ): Analytics => {
101
- const sessionId = config.sessionId ?? makeSessionId();
121
+ // A STABLE per-instance fallback id, used only when no explicit `config.sessionId` was given AND
122
+ // no per-open session has been registered yet (see `resolveSessionId`).
123
+ const instanceSessionId = config.sessionId ?? makeSessionId();
124
+
125
+ // The session id every event correlates to. Precedence: an explicit `config.sessionId` freezes the
126
+ // id (opt-out of the reuse); otherwise reuse the LIVE per-open session the server saw (via
127
+ // `app.session_started`) so `identify`/app-events don't mint a fresh id the server back-fills into a
128
+ // phantom session; finally fall back to the stable per-instance id when no open is registered yet.
129
+ const resolveSessionId = (): string =>
130
+ config.sessionId ?? getCurrentSessionId() ?? instanceSessionId;
102
131
 
103
- // A provider (not a fixed value) so `networkType` is evaluated fresh on every enqueue.
132
+ // The mutable rich user-context: seeded at init, updated via `setUserContext`. Resolved fresh on
133
+ // every event so a post-mount update (login) takes effect immediately. Declared before the envelope
134
+ // provider so the provider can read the current `userContext.appVersion` (see below).
135
+ let userContext: WireUserContext = { ...(config.userContext ?? {}) };
136
+
137
+ // Auto device id (the headline: "device" fully automatic). When the host supplies NO `deviceKey`,
138
+ // the kit mints ONE stable, non-PII per-install id, PERSISTS it via the host `storage`, and reuses it
139
+ // on every subsequent open — so `user_context.device_key` is ALWAYS present (the server's
140
+ // review/questionnaire gating + A/B stickiness both key on it) with zero host wiring. A host-supplied
141
+ // `deviceKey` still wins (see `applyContext`). Falls back to an in-memory id (stable for this
142
+ // instance) when no storage is available.
143
+ const hostDeviceKeyAtInit =
144
+ typeof config.userContext?.deviceKey === "string" && config.userContext.deviceKey.trim()
145
+ ? config.userContext.deviceKey.trim()
146
+ : undefined;
147
+ // Minted synchronously so `device_key` is never missing, even before the async storage read resolves.
148
+ let autoDeviceKey = mintDeviceId();
149
+ if (config.storage && !hostDeviceKeyAtInit) {
150
+ const storage = config.storage;
151
+ const deviceKey = deviceIdStorageKey(config.appId);
152
+ void storage
153
+ .getItem(deviceKey)
154
+ .then((saved) => {
155
+ const persisted = typeof saved === "string" && saved.trim() ? saved.trim() : undefined;
156
+ // Reuse the persisted per-install id across opens; on first run persist the freshly minted one.
157
+ if (persisted) autoDeviceKey = persisted;
158
+ else void storage.setItem(deviceKey, autoDeviceKey).catch(() => {});
159
+ })
160
+ .catch(() => {});
161
+ }
162
+
163
+ // A provider (not a fixed value) so `networkType`, the current session id, AND the effective app
164
+ // version are evaluated fresh on every enqueue. An explicit `WireUserContext.appVersion` (a host that
165
+ // set the version ONLY inside `userContext`) now flows into `device.appVersion` too — not just
166
+ // `user_context.app_version` — so the server's `by_app_version` breakdown (which reads
167
+ // `device.appVersion`) agrees. Explicit wins over the auto-detected device version;
168
+ // `buildContextEnvelope` keeps the auto value when neither is set.
104
169
  const envelope = (): ContextEnvelope =>
105
170
  buildContextEnvelope({
106
- sessionId,
107
- appVersion: config.appVersion,
171
+ sessionId: resolveSessionId(),
172
+ appVersion: userContext.appVersion ?? config.appVersion,
108
173
  appBuild: config.appBuild,
109
174
  networkType: config.networkType,
110
175
  });
@@ -117,28 +182,67 @@ export const createAnalytics = (
117
182
  ...options,
118
183
  });
119
184
 
120
- // Per-session, in-memory user binding. Persisted across launches when storage is provided.
121
- let boundUserId: string | undefined;
185
+ // Per-session, in-memory user binding. Seeded from the init context, then persisted across
186
+ // launches when storage is provided.
187
+ let boundUserId: string | undefined = sanitizeUserId(config.userContext?.userId);
122
188
  const storageKey = `wireai:analytics:userId:${config.appId ?? "default"}`;
123
189
 
124
190
  if (config.storage) {
125
191
  void config.storage
126
192
  .getItem(storageKey)
127
193
  .then((saved) => {
128
- if (saved) boundUserId = saved;
194
+ // Don't clobber an explicit init-context user id with a stale persisted one.
195
+ if (saved && !boundUserId) boundUserId = saved;
129
196
  })
130
197
  .catch(() => {});
131
198
  }
132
199
 
200
+ // Stamp the resolved rich context onto an event: the `user_context` bucket (device_key, app_version,
201
+ // opt-in user_email, namespaced `custom.*`) and the top-level opaque `user_id`. Never overwrites a
202
+ // key the caller already set (so `identify`'s explicit `user_id` and any caller `user_context` win).
203
+ const applyContext = (event: ClientEvent): void => {
204
+ // Host `deviceKey` wins; otherwise the auto-minted/persisted per-install id fills it in so
205
+ // `user_context.device_key` is always present.
206
+ const hostDeviceKey =
207
+ typeof userContext.deviceKey === "string" && userContext.deviceKey.trim()
208
+ ? userContext.deviceKey
209
+ : undefined;
210
+ const resolved = resolveUserContext(
211
+ { ...userContext, deviceKey: hostDeviceKey ?? autoDeviceKey },
212
+ { autoAppVersion: config.appVersion },
213
+ );
214
+ if (resolved.userContext) {
215
+ event.user_context = { ...resolved.userContext, ...(event.user_context ?? {}) };
216
+ }
217
+ if (boundUserId && !event.user_id) event.user_id = boundUserId;
218
+ };
219
+
220
+ const setUserContext = (partial: Partial<WireUserContext>): void => {
221
+ if (!partial || typeof partial !== "object") return;
222
+ // Deep-merge `extra` so a partial update adds keys instead of replacing the whole map.
223
+ const mergedExtra =
224
+ partial.extra || userContext.extra
225
+ ? { ...(userContext.extra ?? {}), ...(partial.extra ?? {}) }
226
+ : undefined;
227
+ userContext = { ...userContext, ...partial };
228
+ if (mergedExtra) userContext.extra = mergedExtra;
229
+ // A user id supplied here binds like `identify` so subsequent events carry `user_id`.
230
+ const uid = sanitizeUserId(partial.userId);
231
+ if (uid) {
232
+ boundUserId = uid;
233
+ if (config.storage) void config.storage.setItem(storageKey, uid).catch(() => {});
234
+ }
235
+ };
236
+
133
237
  const track = (event: string, props?: AnalyticsProps): void => {
134
238
  if (!event) return;
135
239
  const clientEvent: ClientEvent = {
136
240
  event_type: "app_event",
137
- session_id: sessionId,
241
+ session_id: resolveSessionId(),
138
242
  question_key: event,
139
243
  };
140
244
  if (props && Object.keys(props).length > 0) clientEvent.meta = JSON.stringify(props);
141
- if (boundUserId) clientEvent.user_id = boundUserId;
245
+ applyContext(clientEvent);
142
246
  queue.enqueue(clientEvent);
143
247
  };
144
248
 
@@ -148,11 +252,11 @@ export const createAnalytics = (
148
252
  const meta = { screen: name, ...(props ?? {}) };
149
253
  const clientEvent: ClientEvent = {
150
254
  event_type: "app_event",
151
- session_id: sessionId,
255
+ session_id: resolveSessionId(),
152
256
  question_key: "screen",
153
257
  meta: JSON.stringify(meta),
154
258
  };
155
- if (boundUserId) clientEvent.user_id = boundUserId;
259
+ applyContext(clientEvent);
156
260
  queue.enqueue(clientEvent);
157
261
  };
158
262
 
@@ -166,10 +270,13 @@ export const createAnalytics = (
166
270
  }
167
271
  const clientEvent: ClientEvent = {
168
272
  event_type: "identify",
169
- session_id: sessionId,
273
+ // Reuse the LIVE per-open session id (see `resolveSessionId`) so the server binds identity to
274
+ // the session it already saw instead of back-filling a phantom `session_started`.
275
+ session_id: resolveSessionId(),
170
276
  user_id: clean,
171
277
  };
172
278
  if (traits && Object.keys(traits).length > 0) clientEvent.meta = JSON.stringify(traits);
279
+ applyContext(clientEvent);
173
280
  queue.enqueue(clientEvent);
174
281
  };
175
282
 
@@ -177,6 +284,7 @@ export const createAnalytics = (
177
284
  track,
178
285
  screen,
179
286
  identify,
287
+ setUserContext,
180
288
  flush: queue.flush,
181
289
  notifyOnline: queue.notifyOnline,
182
290
  size: queue.size,
@@ -30,7 +30,7 @@ export type ContextEnvelope = {
30
30
  device: DeviceContext;
31
31
  /** Correlation id for this app-open / flow (caller-supplied). */
32
32
  sessionId?: string;
33
- /** Host app version, e.g. "1.4.2" (mirrors `device.appVersion`; host-injected). */
33
+ /** App version, e.g. "1.4.2" (mirrors `device.appVersion`; host-injected, else auto-detected). */
34
34
  appVersion?: string;
35
35
  /** Host native build number, e.g. "412" (from `expo-constants` `nativeBuildVersion`). */
36
36
  appBuild?: string;
@@ -47,9 +47,10 @@ export type ContextEnvelopeInput = {
47
47
  };
48
48
 
49
49
  /**
50
- * Build a fresh context envelope. Reuses `collectDeviceContext()` for the device block and layers
51
- * the host-injected scalars on top. `appVersion` is additionally merged onto `device.appVersion`
52
- * when the device block lacks it (mirroring how `useSessionStart` back-fills the host version).
50
+ * Build a fresh context envelope. Reuses `collectDeviceContext()` for the device block (which
51
+ * already carries a best-effort auto-detected `appVersion`) and layers the host-injected scalars
52
+ * on top. An explicit `input.appVersion` overrides the auto-detected `device.appVersion`, and the
53
+ * outer `appVersion` scalar mirrors whichever version is effective.
53
54
  *
54
55
  * Returns a NEW object on every call (no shared mutable reference), so a caller can hold or mutate
55
56
  * the result without leaking into the next envelope. Never throws — `collectDeviceContext` is
@@ -59,12 +60,17 @@ export const buildContextEnvelope = (input: ContextEnvelopeInput = {}): ContextE
59
60
  // Fresh copy so the returned envelope never aliases a cached device snapshot.
60
61
  const device: DeviceContext = { ...collectDeviceContext() };
61
62
 
62
- // Mirror useSessionStart: fill the host app version onto the device block when it lacks one.
63
- if (input.appVersion && !device.appVersion) device.appVersion = input.appVersion;
63
+ // `device.appVersion` is auto-detected best-effort by `collectDeviceContext`; an explicit
64
+ // host `input.appVersion` always wins.
65
+ if (input.appVersion) device.appVersion = input.appVersion;
66
+
67
+ // The outer scalar mirrors the effective version (host-supplied, else auto-detected) so the
68
+ // queue can stamp `user_context.app_version` even when the host never passed one.
69
+ const effectiveAppVersion = input.appVersion ?? device.appVersion;
64
70
 
65
71
  const envelope: ContextEnvelope = { device };
66
72
  if (input.sessionId) envelope.sessionId = input.sessionId;
67
- if (input.appVersion) envelope.appVersion = input.appVersion;
73
+ if (effectiveAppVersion) envelope.appVersion = effectiveAppVersion;
68
74
  if (input.appBuild) envelope.appBuild = input.appBuild;
69
75
  if (input.networkType) envelope.networkType = input.networkType;
70
76
 
@@ -0,0 +1,35 @@
1
+ /**
2
+ * currentSession — a tiny module-level registry of the CURRENT per-open `session_id`.
3
+ *
4
+ * WHY it exists (kills the phantom-session): the per-open emitters (`reportSessionStart` and the
5
+ * `useSessionStart` / `useLifecycleEvents` hooks) mint a fresh `session_id` for each app-open and
6
+ * post `app.session_started` with it — so the SERVER knows that id. But other client paths
7
+ * (`identify`, host `app_event`s through the analytics façade) used to reference a DIFFERENT id
8
+ * (a frozen per-instance id), which the server had never seen, so it back-filled a synthetic
9
+ * `session_started` — inflating session counts (the Morrow/Myelino "phantom-session" bug).
10
+ *
11
+ * This registry is the single seam that lets those paths reuse the LIVE per-open session id the
12
+ * server already ingested. `reportSessionStart` writes the current id here on every open; the façade
13
+ * reads it so `identify`/app-events correlate to the real session instead of minting a phantom.
14
+ *
15
+ * DEPENDENCY-FREE + PROCESS-LOCAL: a plain module variable. It is intentionally NOT persisted — it
16
+ * tracks the CURRENT process's open, and a fresh open always overwrites it. No cross-launch state.
17
+ */
18
+
19
+ let _currentSessionId: string | undefined;
20
+
21
+ /**
22
+ * Record the current per-open `session_id`. Called by `reportSessionStart` when it emits an
23
+ * app-open. A blank / non-string id is ignored (the previous id stays current). Idempotent.
24
+ */
25
+ export const setCurrentSessionId = (id: string | undefined): void => {
26
+ if (typeof id === "string" && id.length > 0) _currentSessionId = id;
27
+ };
28
+
29
+ /** The current per-open `session_id`, or `undefined` when no app-open has been registered yet. */
30
+ export const getCurrentSessionId = (): string | undefined => _currentSessionId;
31
+
32
+ /** Test-only: forget the current session id so a unit test starts from a clean registry. */
33
+ export const resetCurrentSessionId = (): void => {
34
+ _currentSessionId = undefined;
35
+ };
@@ -57,3 +57,6 @@ export type {
57
57
 
58
58
  // ─── The thin optional React hook over the façade ─────────────────────────────
59
59
  export { useAnalytics } from "./useAnalytics";
60
+
61
+ // ─── Current per-open session registry (identify/app-events reuse the live session) ───
62
+ export { getCurrentSessionId, setCurrentSessionId, resetCurrentSessionId } from "./currentSession";
@@ -0,0 +1,43 @@
1
+ /**
2
+ * deviceId — mint a stable, NON-PII, per-install device id the kit owns when the host supplies
3
+ * none. This is the headline of "device fully automatic": the analytics façade auto-mints ONE id,
4
+ * persists it via the host's `storage` abstraction, and reuses it on every subsequent open — so
5
+ * `user_context.device_key` is ALWAYS present and the server's review/questionnaire gating +
6
+ * A/B stickiness (both key on `device_key`) work out of the box, with zero host wiring.
7
+ *
8
+ * WHY it is NOT PII and adds NO dependency (the kit's hard rules):
9
+ * The id is a random token generated from `Date.now()` + `Math.random()` — it carries NO hardware
10
+ * identifier, NO IDFA/GAID, NO fingerprint. It is a first-party per-install correlation key, the
11
+ * same privacy category as a first-party cookie: it groups a single install's sessions and cannot
12
+ * identify a person or be joined across apps. There is NO `uuid` (or any) dependency — a
13
+ * time+random scheme is sufficient because the id is minted ONCE and then persisted, so global
14
+ * uniqueness across the fleet is not required (a per-install collision is astronomically unlikely
15
+ * and inconsequential — worst case two installs share a bucket).
16
+ *
17
+ * A host that wants its OWN device id still wins: pass `WireUserContext.deviceKey` and the kit uses
18
+ * that verbatim and never mints/persists an auto id.
19
+ */
20
+
21
+ /** Prefix so an auto-minted id is visibly the kit's (distinguishable from a host-supplied `deviceKey`). */
22
+ export const AUTO_DEVICE_ID_PREFIX = "wdev_";
23
+
24
+ /** The storage key the façade persists the auto-minted id under (namespaced per `appId`). */
25
+ export const deviceIdStorageKey = (appId?: string): string =>
26
+ `wireai:analytics:deviceKey:${appId ?? "default"}`;
27
+
28
+ /** One 32-bit base-36 chunk of randomness. Two chunks are concatenated for a wider token. */
29
+ const randomChunk = (): string =>
30
+ Math.floor(Math.random() * 0x100000000)
31
+ .toString(36)
32
+ .padStart(6, "0");
33
+
34
+ /**
35
+ * Mint a fresh per-install device id. Dependency-free (`Date.now()` + `Math.random()`), never
36
+ * throws, and returns a NEW value on every call — the façade mints ONCE and persists, so this is
37
+ * called at most once per install (then the persisted value is reused). Two random chunks plus the
38
+ * timestamp keep the token wide enough that a per-install collision is not a practical concern.
39
+ */
40
+ export const mintDeviceId = (): string => {
41
+ const time = Date.now().toString(36);
42
+ return `${AUTO_DEVICE_ID_PREFIX}${time}_${randomChunk()}${randomChunk()}`;
43
+ };
@@ -0,0 +1,210 @@
1
+ /**
2
+ * userContext — the ONE extensible object a host passes once and the kit flows into every
3
+ * analytics event's `user_context` (plus the top-level opaque `user_id`).
4
+ *
5
+ * WHY it exists: hosts already hand the kit fragments of "who this user is" — `config.appVersion`,
6
+ * `useSessionStart({ deviceKey, userId })`, `<WireOnboarding userContext={…} />` — but there was no
7
+ * single object that carries app version + device key + user id + (opt-in) email + arbitrary extras
8
+ * together, with one precedence rule, into every event. `WireUserContext` is that object;
9
+ * `resolveUserContext` is the pure merge that turns it into the wire shape.
10
+ *
11
+ * PRECEDENCE (the one rule): an explicit `WireUserContext` field WINS over the #42 auto-detected
12
+ * `device`/`appVersion`. A missing field is OMITTED, never sent empty.
13
+ *
14
+ * WHERE EACH FIELD LANDS (deliberate separation so nothing leaks across buckets):
15
+ * • `userId` → the event's TOP-LEVEL opaque `user_id` (via `sanitizeUserId`). NEVER the bucket.
16
+ * • `userEmail` → its OWN key `user_context.user_email`. NEVER merged into `userId`. OPT-IN PII.
17
+ * • `deviceKey` → `user_context.device_key` (the server's `_event_device_key` reads it there).
18
+ * • `appVersion`→ `user_context.app_version` (and returned as `appVersion` for `device.appVersion`).
19
+ * • `extra` → NAMESPACED under a `custom.` key prefix, coerced to scalars, so a host extra can
20
+ * never collide with a reserved `user_context` key.
21
+ *
22
+ * DEPENDENCY-FREE: the only import is the kit's own `sanitizeUserId`. The optional email hash is a
23
+ * dependency-free FNV-1a fold (see {@link hashEmailFnv1a}) — no crypto library, no async.
24
+ */
25
+ import { sanitizeUserId } from "../identity/userIdentity";
26
+
27
+ /**
28
+ * The single, extensible user-context object. A host passes it ONCE (at analytics init) and may
29
+ * update it post-mount (e.g. attach `userId`/`userEmail` at login) via `setUserContext(partial)`.
30
+ * Every field is optional; missing fields are omitted from the wire payload.
31
+ */
32
+ export interface WireUserContext {
33
+ /**
34
+ * Host app version, e.g. "1.4.2". EXPLICIT — wins over the #42 auto-detected `device.appVersion`.
35
+ * Lands in `user_context.app_version`. Omitted when neither this nor auto-detect yields a version.
36
+ */
37
+ appVersion?: string;
38
+ /**
39
+ * A stable, non-PII device id the host owns. Lands in `user_context.device_key` (NOT `session_id`),
40
+ * where the server groups a device's sessions. Host-supplied; the kit never mints or reads one.
41
+ */
42
+ deviceKey?: string;
43
+ /**
44
+ * The host's OPAQUE PSEUDONYMOUS user id (their internal id — NOT an email/name/phone). Sanitized +
45
+ * capped (see `sanitizeUserId`) and placed on the event's top-level `user_id`. NEVER the bucket.
46
+ */
47
+ userId?: string;
48
+ /**
49
+ * OPT-IN PII. The user's email, its OWN field (`user_context.user_email`) — NEVER merged into
50
+ * `userId`. The kit NEVER auto-collects this; a host passes it only WITH the user's consent (EU
51
+ * users: treat as personal data). For a non-reversible form, set {@link hashEmail} `true` (the kit
52
+ * folds it with a dependency-free hash and stamps `user_context.user_email_hashed: true`), OR
53
+ * pre-hash host-side with a cryptographic digest and pass that here with `hashEmail` falsy.
54
+ */
55
+ userEmail?: string;
56
+ /**
57
+ * When `true`, {@link userEmail} is folded with the kit's dependency-free {@link hashEmailFnv1a}
58
+ * before it leaves the device, and `user_context.user_email_hashed` is set `true`. NOTE: FNV-1a is
59
+ * a lightweight NON-cryptographic fold (obfuscation, not a secure digest). For a cryptographic
60
+ * hash, compute it host-side (e.g. SHA-256 via `expo-crypto`) and pass the digest as `userEmail`
61
+ * with `hashEmail` falsy. Default: raw email is sent as-is (opt-in already gated it upstream).
62
+ */
63
+ hashEmail?: boolean;
64
+ /**
65
+ * Arbitrary host context (signup method, referral, plan tier…). Each value is coerced to a scalar
66
+ * (`string | number | boolean`; non-scalars and non-finite numbers are DROPPED) and NAMESPACED
67
+ * under a `custom.` key prefix in `user_context` (e.g. `user_context["custom.referral"]`) so it can
68
+ * never collide with a reserved key. No raw PII — use {@link userEmail} for email.
69
+ */
70
+ extra?: Record<string, string | number | boolean>;
71
+ }
72
+
73
+ /**
74
+ * The wire-shaped result of {@link resolveUserContext}. `userContext` is the non-PII/opt-in-PII
75
+ * bucket stamped onto the event; `userId` is the top-level opaque id; `appVersion`/`deviceKey` are
76
+ * echoed for callers that also place them elsewhere (e.g. `device.appVersion`). Absent fields are
77
+ * omitted so a caller can spread this without sending empties.
78
+ */
79
+ export interface ResolvedUserContext {
80
+ /** The opaque, sanitized user id → the event's top-level `user_id`. Omitted when unset/blank. */
81
+ userId?: string;
82
+ /** The stable device id → `user_context.device_key`. Omitted when unset. */
83
+ deviceKey?: string;
84
+ /** The effective app version (explicit > auto-detected) → `user_context.app_version`. */
85
+ appVersion?: string;
86
+ /** The `user_context` bucket (device_key, app_version, user_email[+ _hashed], custom.*). */
87
+ userContext?: Record<string, string | number | boolean>;
88
+ }
89
+
90
+ /** Reserved `user_context` keys the kit itself writes; host `extra` is namespaced away from these. */
91
+ export const RESERVED_USER_CONTEXT_KEYS = [
92
+ "device_key",
93
+ "app_version",
94
+ "app_build",
95
+ "network_type",
96
+ "session_count",
97
+ "returning",
98
+ "platform",
99
+ "user_email",
100
+ "user_email_hashed",
101
+ ] as const;
102
+
103
+ /** The prefix applied to every host `extra` key so it can never collide with a reserved key. */
104
+ export const EXTRA_KEY_PREFIX = "custom." as const;
105
+
106
+ /** A finite scalar the wire accepts. Non-finite numbers (NaN/Infinity) are NOT scalars here. */
107
+ export const isWireScalar = (value: unknown): value is string | number | boolean => {
108
+ const t = typeof value;
109
+ if (t === "string" || t === "boolean") return true;
110
+ if (t === "number") return Number.isFinite(value as number);
111
+ return false;
112
+ };
113
+
114
+ /**
115
+ * Fold an email to a stable, dependency-free 32-bit FNV-1a hex token (lowercased + trimmed first so
116
+ * the same address always folds identically). This is OBFUSCATION, not a cryptographic digest — it
117
+ * is not collision-resistant. For a real hash, pre-hash host-side and pass the digest as `userEmail`.
118
+ */
119
+ export const hashEmailFnv1a = (email: string): string => {
120
+ const normalized = email.trim().toLowerCase();
121
+ let hash = 0x811c9dc5; // FNV offset basis (32-bit)
122
+ for (let i = 0; i < normalized.length; i++) {
123
+ hash ^= normalized.charCodeAt(i);
124
+ hash = Math.imul(hash, 0x01000193); // FNV prime (32-bit), kept in 32-bit via imul
125
+ }
126
+ return (hash >>> 0).toString(16).padStart(8, "0");
127
+ };
128
+
129
+ /** Trim a candidate string; return `undefined` for a non-string / blank so callers can `if`-gate. */
130
+ const cleanString = (value: unknown): string | undefined => {
131
+ if (typeof value !== "string") return undefined;
132
+ const trimmed = value.trim();
133
+ return trimmed.length > 0 ? trimmed : undefined;
134
+ };
135
+
136
+ /**
137
+ * Coerce a host `extra` map into the namespaced, scalar-only bucket shape. Every kept value is
138
+ * placed under `custom.<key>`; non-scalar values (objects, arrays, null, functions, NaN/Infinity)
139
+ * are DROPPED. Returns an object (possibly empty).
140
+ */
141
+ export const namespaceExtra = (
142
+ extra: Record<string, unknown> | undefined,
143
+ ): Record<string, string | number | boolean> => {
144
+ const out: Record<string, string | number | boolean> = {};
145
+ if (!extra || typeof extra !== "object") return out;
146
+ for (const [key, value] of Object.entries(extra)) {
147
+ const cleanKey = cleanString(key);
148
+ if (!cleanKey) continue;
149
+ if (!isWireScalar(value)) continue; // drop anything that isn't a finite scalar
150
+ out[`${EXTRA_KEY_PREFIX}${cleanKey}`] = value;
151
+ }
152
+ return out;
153
+ };
154
+
155
+ /** Options for {@link resolveUserContext}. */
156
+ export interface ResolveUserContextOptions {
157
+ /**
158
+ * The kit's best-effort auto-detected app version (#42; from `detectAppVersion()`/the device
159
+ * snapshot). Used ONLY when the explicit `WireUserContext.appVersion` is absent — explicit wins.
160
+ */
161
+ autoAppVersion?: string;
162
+ }
163
+
164
+ /**
165
+ * Merge a {@link WireUserContext} into the wire shape with the precedence rule (explicit field >
166
+ * auto-detected). Pure, never throws. Missing fields are omitted so the result can be spread onto an
167
+ * event without sending empties.
168
+ */
169
+ export const resolveUserContext = (
170
+ ctx: WireUserContext = {},
171
+ opts: ResolveUserContextOptions = {},
172
+ ): ResolvedUserContext => {
173
+ const result: ResolvedUserContext = {};
174
+ const bucket: Record<string, string | number | boolean> = {};
175
+
176
+ // userId → top-level opaque id (NEVER the bucket). Sanitized + capped host-side.
177
+ const userId = sanitizeUserId(ctx.userId);
178
+ if (userId) result.userId = userId;
179
+
180
+ // deviceKey → user_context.device_key (NOT session_id).
181
+ const deviceKey = cleanString(ctx.deviceKey);
182
+ if (deviceKey) {
183
+ result.deviceKey = deviceKey;
184
+ bucket.device_key = deviceKey;
185
+ }
186
+
187
+ // appVersion → explicit wins over auto-detected (#42); echoed for device.appVersion callers.
188
+ const appVersion = cleanString(ctx.appVersion) ?? cleanString(opts.autoAppVersion);
189
+ if (appVersion) {
190
+ result.appVersion = appVersion;
191
+ bucket.app_version = appVersion;
192
+ }
193
+
194
+ // userEmail → its OWN key. OPT-IN PII, optionally folded. NEVER touches userId.
195
+ const email = cleanString(ctx.userEmail);
196
+ if (email) {
197
+ if (ctx.hashEmail) {
198
+ bucket.user_email = hashEmailFnv1a(email);
199
+ bucket.user_email_hashed = true;
200
+ } else {
201
+ bucket.user_email = email;
202
+ }
203
+ }
204
+
205
+ // extra → namespaced + scalar-coerced.
206
+ Object.assign(bucket, namespaceExtra(ctx.extra));
207
+
208
+ if (Object.keys(bucket).length > 0) result.userContext = bucket;
209
+ return result;
210
+ };