@wireai/activation 0.12.2 → 0.13.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/CHANGELOG.md +145 -0
- package/README.md +5 -3
- package/dist/analytics/index.d.mts +2 -2
- package/dist/analytics/index.d.ts +2 -2
- package/dist/analytics/index.js +114 -35
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs +114 -36
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/{currentSession-BxEB37xt.d.ts → currentSession-D7zabMXK.d.ts} +161 -9
- package/dist/{currentSession-BlCeDP0f.d.mts → currentSession-_GynvhzT.d.mts} +161 -9
- package/dist/index.d.mts +5 -15
- package/dist/index.d.ts +5 -15
- package/dist/index.js +232 -135
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +229 -134
- package/dist/index.mjs.map +1 -1
- package/dist/questionnaire/index.js.map +1 -1
- package/dist/questionnaire/index.mjs.map +1 -1
- package/dist/reviews/index.js +8 -6
- package/dist/reviews/index.js.map +1 -1
- package/dist/reviews/index.mjs +8 -6
- package/dist/reviews/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/OnboardingFlow.tsx +10 -3
- package/src/WireOnboarding.tsx +115 -32
- package/src/activation/wireActivation.ts +13 -7
- package/src/analytics/analyticsFacade.ts +11 -10
- package/src/analytics/currentSession.ts +6 -20
- package/src/analytics/eventQueue.ts +69 -1
- package/src/analytics/index.ts +1 -1
- package/src/analytics/reportClientEvent.ts +92 -29
- package/src/config/wireConfigFromEnv.ts +1 -10
- package/src/context/deviceId.ts +77 -16
- package/src/context/userContext.ts +4 -15
- package/src/identity/identityRecord.ts +123 -0
- package/src/identity/userIdentity.ts +45 -9
- package/src/index.ts +6 -4
- package/src/session-analytics/useLifecycleEvents.ts +10 -1
- package/src/types.ts +14 -0
- package/src/utils/deriveAnswers.ts +6 -2
- package/src/utils/readProgress.ts +4 -0
- package/src/utils/warnInDev.ts +33 -0
- package/src/components/DoneBlock.tsx +0 -37
|
@@ -128,16 +128,71 @@ export const buildEventsRequest = (
|
|
|
128
128
|
}
|
|
129
129
|
};
|
|
130
130
|
|
|
131
|
+
/** What the `/v1/events` endpoint says in its 200 body. `errors` is the server's planned per-event
|
|
132
|
+
* reason list; it is read defensively because no deployed server sends it yet. */
|
|
133
|
+
type EventsAck = { written?: number; skipped: number; reasons: string[] };
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Read the `/v1/events` ACK body. Resolves `undefined` when there is nothing readable — no `.json`
|
|
137
|
+
* (an old server, a bare test mock), an already-consumed body, or a hostile response object. NEVER
|
|
138
|
+
* throws, and NEVER dev-gated: the `skipped` count is a RETURN VALUE for the awaitable path, not only
|
|
139
|
+
* a warning, so it has to be read in production too.
|
|
140
|
+
*
|
|
141
|
+
* ⚠️ The body can be read exactly once, so a caller that both decides on `skipped` AND warns must do
|
|
142
|
+
* both from ONE call to this.
|
|
143
|
+
*/
|
|
144
|
+
const readEventsAck = async (res: unknown): Promise<EventsAck | undefined> => {
|
|
145
|
+
try {
|
|
146
|
+
const json = (res as { json?: () => Promise<unknown> } | null | undefined)?.json;
|
|
147
|
+
if (typeof json !== "function") return undefined;
|
|
148
|
+
const body = (await Promise.resolve(json.call(res))) as
|
|
149
|
+
| { written?: unknown; skipped?: unknown; errors?: unknown }
|
|
150
|
+
| null
|
|
151
|
+
| undefined;
|
|
152
|
+
const skipped = body?.skipped;
|
|
153
|
+
if (typeof skipped !== "number" || !Number.isFinite(skipped)) return undefined;
|
|
154
|
+
const reasons = Array.isArray(body?.errors)
|
|
155
|
+
? body.errors
|
|
156
|
+
.map((e) => (e as { reason?: unknown } | null)?.reason)
|
|
157
|
+
.filter((r): r is string => typeof r === "string")
|
|
158
|
+
: [];
|
|
159
|
+
return { written: typeof body?.written === "number" ? body.written : undefined, skipped, reasons };
|
|
160
|
+
} catch {
|
|
161
|
+
// Unreadable / already-consumed body / hostile object — best-effort, treat as "no ack".
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The DISCARDED warning text, built from what the server ACTUALLY said.
|
|
168
|
+
*
|
|
169
|
+
* It used to staple a cause onto a bare integer — "the usual cause is a missing or empty session_id".
|
|
170
|
+
* Since `ensureCurrentSessionId` shipped, no kit path can emit an event without a `session_id`, so
|
|
171
|
+
* that is now the LEAST likely explanation, and naming it sent every reader looking in the one place
|
|
172
|
+
* the problem is not. The endpoint folds four real failures and two idempotent no-ops into one
|
|
173
|
+
* integer, so unless the server volunteers reasons, the honest thing to report is the count and where
|
|
174
|
+
* the reason lives.
|
|
175
|
+
*/
|
|
176
|
+
const describeDiscarded = (ack: EventsAck): string =>
|
|
177
|
+
`[wireai] the server ACCEPTED the /v1/events POST but DISCARDED ${ack.skipped} event(s) ` +
|
|
178
|
+
"(skipped in the response body) — they are gone, not retried. The server reported: " +
|
|
179
|
+
`skipped=${ack.skipped}${ack.written !== undefined ? `, written=${ack.written}` : ""}` +
|
|
180
|
+
(ack.reasons.length > 0
|
|
181
|
+
? `, reasons: ${ack.reasons.join(", ")}.`
|
|
182
|
+
: ". It gave no reason (the endpoint folds every rejection into one count), so check the " +
|
|
183
|
+
"server's ingest log for this request rather than guessing.");
|
|
184
|
+
|
|
131
185
|
/** RN sets this global; absent under node/SSR. Read defensively inside {@link warnOnSkippedEvents}. */
|
|
132
186
|
declare const __DEV__: boolean | undefined;
|
|
133
187
|
|
|
134
188
|
/**
|
|
135
189
|
* Read the `/v1/events` ACK body and warn (dev builds only) when the server DISCARDED events.
|
|
136
190
|
*
|
|
137
|
-
* The endpoint answers HTTP **200** with `{ ok, written, skipped }` — an event it refuses
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
191
|
+
* The endpoint answers HTTP **200** with `{ ok, written, skipped }` — an event it refuses is counted
|
|
192
|
+
* in `skipped`, never surfaced in the status code. Every send path here reads `res.ok` alone, so a
|
|
193
|
+
* whole batch can evaporate behind a green response. As of 0.13.0 ALL FOUR send paths consume the ack:
|
|
194
|
+
* the offline queue, session-start, the fire-and-forget POST, and the awaitable one (which also acts
|
|
195
|
+
* on it — see {@link reportClientEventsAwait}).
|
|
141
196
|
*
|
|
142
197
|
* LOG ONLY: returns immediately, never throws, and never influences retry / dequeue / return values.
|
|
143
198
|
* A response with no usable `.json` (an old server, a test mock) is silently ignored.
|
|
@@ -149,25 +204,10 @@ declare const __DEV__: boolean | undefined;
|
|
|
149
204
|
export const warnOnSkippedEvents = (res: unknown): void => {
|
|
150
205
|
if (typeof __DEV__ === "undefined" || !__DEV__) return;
|
|
151
206
|
if (typeof console === "undefined" || !console.warn) return;
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
.then((body) => {
|
|
157
|
-
const skipped = (body as { skipped?: unknown } | null | undefined)?.skipped;
|
|
158
|
-
if (typeof skipped !== "number" || skipped <= 0) return;
|
|
159
|
-
console.warn(
|
|
160
|
-
`[wireai] the server ACCEPTED the /v1/events POST but DISCARDED ${skipped} event(s) ` +
|
|
161
|
-
"(skipped in the response body) — they are gone, not retried. The usual cause is an " +
|
|
162
|
-
"event with a missing or empty session_id.",
|
|
163
|
-
);
|
|
164
|
-
})
|
|
165
|
-
.catch(() => {
|
|
166
|
-
// Unreadable / already-consumed body — best-effort logging, swallow.
|
|
167
|
-
});
|
|
168
|
-
} catch {
|
|
169
|
-
// A hostile response object — swallow.
|
|
170
|
-
}
|
|
207
|
+
void readEventsAck(res).then((ack) => {
|
|
208
|
+
if (!ack || ack.skipped <= 0) return;
|
|
209
|
+
console.warn(describeDiscarded(ack));
|
|
210
|
+
});
|
|
171
211
|
};
|
|
172
212
|
|
|
173
213
|
/**
|
|
@@ -182,9 +222,16 @@ export const reportClientEvents = (
|
|
|
182
222
|
try {
|
|
183
223
|
const req = buildEventsRequest(target, events);
|
|
184
224
|
if (!req) return;
|
|
185
|
-
void fetch(req.url, req.init)
|
|
186
|
-
|
|
187
|
-
|
|
225
|
+
void fetch(req.url, req.init)
|
|
226
|
+
.then((res) => {
|
|
227
|
+
// Still fire-and-forget — nothing is retried, nothing is returned. But a batch that
|
|
228
|
+
// evaporated behind a 200 is exactly what this path used to make invisible, so in dev it
|
|
229
|
+
// now says so, like the queue and session-start paths already did.
|
|
230
|
+
warnOnSkippedEvents(res);
|
|
231
|
+
})
|
|
232
|
+
.catch(() => {
|
|
233
|
+
// Network/transport error — analytics is best-effort, swallow.
|
|
234
|
+
});
|
|
188
235
|
} catch {
|
|
189
236
|
// A missing `fetch` — swallow.
|
|
190
237
|
}
|
|
@@ -201,8 +248,17 @@ export const reportClientEvent = (
|
|
|
201
248
|
* `/v1/events` path, but resolve only once the server has RESPONDED — so a decision re-fetch fired
|
|
202
249
|
* immediately after is guaranteed to see the event in the session stream (this is the guarantee
|
|
203
250
|
* `wire.track` needs before it triggers decision revalidation). Never throws: a missing/invalid
|
|
204
|
-
* target, a missing `fetch`, a network error, or a non-2xx status all resolve to `false`.
|
|
205
|
-
*
|
|
251
|
+
* target, a missing `fetch`, a network error, or a non-2xx status all resolve to `false`.
|
|
252
|
+
*
|
|
253
|
+
* ⚠️ 0.13.0: a 2xx is NO LONGER SUFFICIENT. The endpoint answers HTTP 200 with `{ ok, written,
|
|
254
|
+
* skipped }` and counts an event it refuses in `skipped`, so this used to resolve `true` for an event
|
|
255
|
+
* the server had thrown away — and `wire.track` then bumped decision revalidation, making every
|
|
256
|
+
* subscribed gate re-fetch against a stream the action never entered. It now reads the ack and
|
|
257
|
+
* resolves `false` when the server reports a positive `skipped`.
|
|
258
|
+
*
|
|
259
|
+
* BACKWARD COMPATIBLE BY CONSTRUCTION: only an explicit positive `skipped` demotes a 200. An old
|
|
260
|
+
* server that sends no such field, a body that cannot be parsed, or a response with no `.json` at all
|
|
261
|
+
* resolves `true` exactly as before — the change can produce no false negatives.
|
|
206
262
|
*/
|
|
207
263
|
export const reportClientEventsAwait = async (
|
|
208
264
|
target: ClientEventTarget | undefined,
|
|
@@ -212,7 +268,14 @@ export const reportClientEventsAwait = async (
|
|
|
212
268
|
const req = buildEventsRequest(target, events);
|
|
213
269
|
if (!req) return false;
|
|
214
270
|
const res = await fetch(req.url, req.init);
|
|
215
|
-
|
|
271
|
+
if (!res || !res.ok) return false;
|
|
272
|
+
// ONE body read, used for both the verdict and the dev warning — it can only be read once.
|
|
273
|
+
const ack = await readEventsAck(res);
|
|
274
|
+
if (!ack || ack.skipped <= 0) return true;
|
|
275
|
+
if (typeof __DEV__ !== "undefined" && __DEV__ && typeof console !== "undefined" && console.warn) {
|
|
276
|
+
console.warn(describeDiscarded(ack));
|
|
277
|
+
}
|
|
278
|
+
return false;
|
|
216
279
|
} catch {
|
|
217
280
|
// Unreachable / missing-fetch / network — best-effort, report failure.
|
|
218
281
|
return false;
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { detectAppVersion } from "../device/appVersion";
|
|
19
19
|
import type { WireOnboardingConfig } from "../types";
|
|
20
|
+
import { warnInDev } from "../utils/warnInDev";
|
|
20
21
|
|
|
21
22
|
export type WireConfigOverrides = Partial<WireOnboardingConfig>;
|
|
22
23
|
|
|
@@ -27,16 +28,6 @@ export const WIRE_ENV_VARS = [
|
|
|
27
28
|
"EXPO_PUBLIC_WIREAI_APP_ID",
|
|
28
29
|
] as const;
|
|
29
30
|
|
|
30
|
-
/** RN sets this global; absent under node/SSR. Read defensively via {@link warnInDev}. */
|
|
31
|
-
declare const __DEV__: boolean | undefined;
|
|
32
|
-
|
|
33
|
-
/** Emit a one-line developer warning, but ONLY in a dev build (RN `__DEV__`). No-op in prod/tests. */
|
|
34
|
-
const warnInDev = (message: string): void => {
|
|
35
|
-
if (typeof __DEV__ !== "undefined" && __DEV__ && typeof console !== "undefined" && console.warn) {
|
|
36
|
-
console.warn(message);
|
|
37
|
-
}
|
|
38
|
-
};
|
|
39
|
-
|
|
40
31
|
/**
|
|
41
32
|
* Minimal `process.env` declaration so the kit stays RN-pure (no `@types/node`).
|
|
42
33
|
* In RN/Expo, `process.env.EXPO_PUBLIC_*` is provided/inlined by Metro at build
|
package/src/context/deviceId.ts
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
* that verbatim and never mints/persists an auto id.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
import { resolveIdentity, type IdentityRecord } from "../identity/identityRecord";
|
|
22
|
+
|
|
21
23
|
/** Prefix so an auto-minted id is visibly the kit's (distinguishable from a host-supplied `deviceKey`). */
|
|
22
24
|
export const AUTO_DEVICE_ID_PREFIX = "wdev_";
|
|
23
25
|
|
|
@@ -69,12 +71,21 @@ export const mintDeviceId = (): string => {
|
|
|
69
71
|
/** Well-known key into the runtime-global symbol registry — one auto-id registry across every bundle. */
|
|
70
72
|
const AUTO_DEVICE_KEY_SLOT: unique symbol = Symbol.for("@wireai/activation:autoDeviceKeys");
|
|
71
73
|
|
|
74
|
+
/** What a hydration settled on: the id, and whether persistence actually CONFIRMED it.
|
|
75
|
+
*
|
|
76
|
+
* `durable: false` means the id lives only in this process's memory — the adapter rejected, threw,
|
|
77
|
+
* or there was no adapter at all. That distinction is the whole of K1: a string is a string, so
|
|
78
|
+
* before 0.13.0 a caller could not tell a persisted id from a per-launch mint, and the auto-join
|
|
79
|
+
* gate (`Boolean(storage)`) was reading the PRESENCE of the prop rather than the SUCCESS of the
|
|
80
|
+
* write. See {@link hydrateDeviceIdentity}. */
|
|
81
|
+
type HydrationOutcome = { value: string; durable: boolean };
|
|
82
|
+
|
|
72
83
|
/** The shared registry: the live id per `appId`, the set of appIds whose hydration already started,
|
|
73
84
|
* and the in-flight (or settled) hydration promise per `appId` so a waiter can join it. */
|
|
74
85
|
type AutoDeviceKeyRegistry = {
|
|
75
86
|
keys: Map<string, string>;
|
|
76
87
|
hydrating: Set<string>;
|
|
77
|
-
pending?: Map<string, Promise<
|
|
88
|
+
pending?: Map<string, Promise<HydrationOutcome>>;
|
|
78
89
|
};
|
|
79
90
|
|
|
80
91
|
type GlobalWithDeviceKeys = typeof globalThis & {
|
|
@@ -108,40 +119,65 @@ export interface ResolveAutoDeviceKeyOptions {
|
|
|
108
119
|
}
|
|
109
120
|
|
|
110
121
|
/**
|
|
111
|
-
* Start (or join) the SINGLE-FLIGHT storage read for `appId` and resolve to the
|
|
112
|
-
* The promise is parked on the registry so a later `hydrateAutoDeviceKey` awaits the
|
|
113
|
-
* instead of starting a second one. Never rejects: any storage failure resolves to the live
|
|
122
|
+
* Start (or join) the SINGLE-FLIGHT storage read for `appId` and resolve to the {@link HydrationOutcome}
|
|
123
|
+
* it settles on. The promise is parked on the registry so a later `hydrateAutoDeviceKey` awaits the
|
|
124
|
+
* SAME read instead of starting a second one. Never rejects: any storage failure resolves to the live
|
|
125
|
+
* id with `durable: false`.
|
|
126
|
+
*
|
|
127
|
+
* TWO OUTCOMES, NOT ONE STRING:
|
|
128
|
+
* • ADOPTED (`durable: true`) — a persisted id was read back, or the freshly minted one was
|
|
129
|
+
* written successfully. The next launch will see the same id.
|
|
130
|
+
* • DEGRADED (`durable: false`) — the adapter rejected, threw, or returned nothing and then failed
|
|
131
|
+
* the write. The id is real but PROCESS-scoped, so anything that
|
|
132
|
+
* counts a device across launches must refuse it.
|
|
133
|
+
*
|
|
134
|
+
* A DEGRADED outcome also drops the registry latches so the NEXT caller starts a fresh read (K8). A
|
|
135
|
+
* cold-boot storage lock is transient; caching it as a verdict for the process lifetime turned a
|
|
136
|
+
* one-second problem into a whole-launch one, and nothing ever retried.
|
|
114
137
|
*/
|
|
115
138
|
const startHydration = (
|
|
116
139
|
registry: AutoDeviceKeyRegistry,
|
|
117
140
|
appId: string,
|
|
118
141
|
storage: DeviceKeyStorage,
|
|
119
142
|
minted: string,
|
|
120
|
-
): Promise<
|
|
143
|
+
): Promise<HydrationOutcome> => {
|
|
121
144
|
if (!registry.pending) registry.pending = new Map();
|
|
122
145
|
const existing = registry.pending.get(appId);
|
|
123
146
|
if (existing) return existing;
|
|
124
147
|
|
|
125
148
|
const slot = deviceIdStorageKey(appId);
|
|
126
|
-
const
|
|
127
|
-
|
|
149
|
+
const degraded = (): HydrationOutcome => ({ value: registry.keys.get(appId) ?? minted, durable: false });
|
|
150
|
+
const adopted = (value: string): HydrationOutcome => ({ value, durable: true });
|
|
151
|
+
let run: Promise<HydrationOutcome>;
|
|
128
152
|
try {
|
|
129
153
|
run = Promise.resolve(storage.getItem(slot))
|
|
130
154
|
.then((saved) => {
|
|
131
155
|
const persisted = typeof saved === "string" && saved.trim() ? saved.trim() : undefined;
|
|
132
156
|
if (persisted) {
|
|
133
157
|
registry.keys.set(appId, persisted);
|
|
134
|
-
return persisted;
|
|
158
|
+
return adopted(persisted);
|
|
135
159
|
}
|
|
136
160
|
// First run on this install: persist the id we just minted so the next launch adopts it.
|
|
137
|
-
|
|
161
|
+
// ONLY a resolved write earns `durable` — a rejected one leaves the id in memory alone.
|
|
162
|
+
return Promise.resolve(storage.setItem(slot, minted)).then(
|
|
163
|
+
() => adopted(registry.keys.get(appId) ?? minted),
|
|
164
|
+
degraded,
|
|
165
|
+
);
|
|
138
166
|
})
|
|
139
|
-
.catch(
|
|
167
|
+
.catch(degraded);
|
|
140
168
|
} catch {
|
|
141
169
|
// A storage adapter that throws synchronously — degrade to the in-memory id.
|
|
142
|
-
run = Promise.resolve(
|
|
170
|
+
run = Promise.resolve(degraded());
|
|
143
171
|
}
|
|
144
172
|
registry.pending.set(appId, run);
|
|
173
|
+
// Retry-on-failure (K8): release the latches once a degraded outcome settles, so a later caller
|
|
174
|
+
// is not permanently bound to one bad read. Registered AFTER the `set` above, so the clean-up can
|
|
175
|
+
// never race ahead of the entry it is clearing.
|
|
176
|
+
void run.then((outcome) => {
|
|
177
|
+
if (outcome.durable) return;
|
|
178
|
+
registry.pending?.delete(appId);
|
|
179
|
+
registry.hydrating.delete(appId);
|
|
180
|
+
});
|
|
145
181
|
return run;
|
|
146
182
|
};
|
|
147
183
|
|
|
@@ -196,15 +232,40 @@ export const resolveAutoDeviceKey = (opts: ResolveAutoDeviceKeyOptions = {}): st
|
|
|
196
232
|
*/
|
|
197
233
|
export const hydrateAutoDeviceKey = async (
|
|
198
234
|
opts: ResolveAutoDeviceKeyOptions = {},
|
|
199
|
-
): Promise<string> =>
|
|
235
|
+
): Promise<string> => (await hydrateDeviceIdentity(opts))?.value ?? resolveAutoDeviceKey(opts);
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The PROVENANCE-CARRYING sibling of {@link hydrateAutoDeviceKey}: the same awaited read, but it
|
|
239
|
+
* answers "is this id one this install will KEEP?" instead of only "what is the id?".
|
|
240
|
+
*
|
|
241
|
+
* WHY IT EXISTS (K1). `<WireOnboarding>` gated auto-injection on `Boolean(storage)` — the presence of
|
|
242
|
+
* the prop — because a string carries no provenance and there was nothing better to gate on. A
|
|
243
|
+
* REJECTING adapter therefore injected a fresh `wdev_*` on every launch: strictly worse than
|
|
244
|
+
* injecting nothing, since the server counts `min_sessions` by distinct opens grouped on `device_key`,
|
|
245
|
+
* so a per-launch key corrupts that counter AND inflates distinct-device counts. Callers that write a
|
|
246
|
+
* key onto the wire as a cross-launch join must read `durable` and refuse a `false`.
|
|
247
|
+
*
|
|
248
|
+
* Resolves `undefined` only when there is no usable id at all. With no `storage` it resolves
|
|
249
|
+
* immediately with `durable: false` — a process-scoped id is exactly what "no persistence" means.
|
|
250
|
+
* Never throws or rejects.
|
|
251
|
+
*/
|
|
252
|
+
export const hydrateDeviceIdentity = async (
|
|
253
|
+
opts: ResolveAutoDeviceKeyOptions = {},
|
|
254
|
+
): Promise<IdentityRecord | undefined> => {
|
|
200
255
|
// Mint + register synchronously first, so a waiter and a concurrent sync caller share ONE id.
|
|
201
256
|
const id = resolveAutoDeviceKey(opts);
|
|
202
|
-
if (!opts.storage) return id;
|
|
203
|
-
const registry = autoDeviceKeyRegistry();
|
|
204
257
|
const appId = opts.appId ?? "default";
|
|
258
|
+
const record = (value: string, durable: boolean): IdentityRecord | undefined =>
|
|
259
|
+
resolveIdentity({ value, space: "device", source: "auto", durable, scope: appId });
|
|
260
|
+
|
|
261
|
+
if (!opts.storage) return record(id, false);
|
|
262
|
+
const registry = autoDeviceKeyRegistry();
|
|
205
263
|
const pending = registry.pending?.get(appId);
|
|
206
|
-
|
|
207
|
-
|
|
264
|
+
// No pending entry means a previous hydration already settled DEGRADED and released its latches
|
|
265
|
+
// (see `startHydration`), so the live id is the in-memory one — real, but not durable.
|
|
266
|
+
if (!pending) return record(registry.keys.get(appId) ?? id, false);
|
|
267
|
+
const outcome = await pending;
|
|
268
|
+
return record(outcome.value, outcome.durable);
|
|
208
269
|
};
|
|
209
270
|
|
|
210
271
|
/** Test-only: forget every auto id + hydration flag so a unit test starts from a clean registry. */
|
|
@@ -88,19 +88,6 @@ export interface ResolvedUserContext {
|
|
|
88
88
|
userContext?: Record<string, string | number | boolean>;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
/** Reserved `user_context` keys the kit itself writes; host `extra` is namespaced away from these. */
|
|
92
|
-
export const RESERVED_USER_CONTEXT_KEYS = [
|
|
93
|
-
"device_key",
|
|
94
|
-
"app_version",
|
|
95
|
-
"app_build",
|
|
96
|
-
"network_type",
|
|
97
|
-
"session_count",
|
|
98
|
-
"returning",
|
|
99
|
-
"platform",
|
|
100
|
-
"user_email",
|
|
101
|
-
"user_email_hashed",
|
|
102
|
-
] as const;
|
|
103
|
-
|
|
104
91
|
/** The prefix applied to every host `extra` key so it can never collide with a reserved key. */
|
|
105
92
|
export const EXTRA_KEY_PREFIX = "custom." as const;
|
|
106
93
|
|
|
@@ -127,8 +114,10 @@ export const hashEmailFnv1a = (email: string): string => {
|
|
|
127
114
|
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
128
115
|
};
|
|
129
116
|
|
|
130
|
-
/** Trim a candidate string; return `undefined` for a non-string / blank so callers can `if`-gate.
|
|
131
|
-
|
|
117
|
+
/** Trim a candidate string; return `undefined` for a non-string / blank so callers can `if`-gate.
|
|
118
|
+
* Shared with `activation/wireActivation`, which carried a byte-identical private copy named `clean`.
|
|
119
|
+
* Not re-exported from the package barrel — this is an internal helper, not public surface. */
|
|
120
|
+
export const cleanString = (value: unknown): string | undefined => {
|
|
132
121
|
if (typeof value !== "string") return undefined;
|
|
133
122
|
const trimmed = value.trim();
|
|
134
123
|
return trimmed.length > 0 ? trimmed : undefined;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* identityRecord — the ONE provenance-carrying shape for an id the kit puts on the wire.
|
|
3
|
+
*
|
|
4
|
+
* WHY IT EXISTS. `session_id` and `device_key` are bare `string`s minted independently by four
|
|
5
|
+
* subsystems, and nothing anywhere recorded WHERE a given id came from. Every id-layer defect this
|
|
6
|
+
* release fixes is a direct consequence of that one omission:
|
|
7
|
+
*
|
|
8
|
+
* • a rejecting storage adapter's in-memory id was indistinguishable from a persisted one, so the
|
|
9
|
+
* kit injected a fresh per-launch join key on every launch — nothing carried `durable`.
|
|
10
|
+
* • an app-OPEN session id could be posted into a field that means the ONBOARDING session, and the
|
|
11
|
+
* caller was told `true` — nothing carried `space`.
|
|
12
|
+
* • an auto-minted `wdev_*` could be injected beside a device id the host demonstrably owns on
|
|
13
|
+
* another surface, silently — nothing carried `source`.
|
|
14
|
+
*
|
|
15
|
+
* WHAT THIS IS, AND WHAT IT DELIBERATELY IS NOT. It is a small record plus a process-wide registry of
|
|
16
|
+
* the ids a HOST supplied. It is NOT a branded-type refactor (`OnboardingSessionId` / `AppSessionId` /
|
|
17
|
+
* `DeviceKey` across every signature) — that is real value and it is deferred, because it touches
|
|
18
|
+
* every file and is not what makes a number correct this week. Nothing here changes the wire.
|
|
19
|
+
*
|
|
20
|
+
* WHY A `Symbol.for` REGISTRY. Same reason as `analytics/currentSession` and `context/deviceId`: tsup
|
|
21
|
+
* inlines a separate copy of a module into each bundle (`.` and `./analytics`), so a plain module
|
|
22
|
+
* `let` would give every bundle its own registry and the cross-surface question this exists to answer
|
|
23
|
+
* ("did ANY surface in this process get a host-supplied device key?") would read `no` from the wrong
|
|
24
|
+
* copy. `Symbol.for` resolves to one slot on `globalThis` no matter how many copies exist.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Which id space a value belongs to. These are NOT interchangeable, and the whole point of naming
|
|
29
|
+
* them is that a value from one space must never be posted into a field that means another:
|
|
30
|
+
* • `onboarding-session` — the A2A `contextId` for ONE onboarding run.
|
|
31
|
+
* • `app-session` — the per-app-open session id (`app.session_started`).
|
|
32
|
+
* • `device` — the per-install `device_key`; the only cross-family join key.
|
|
33
|
+
*/
|
|
34
|
+
export type IdentitySpace = "onboarding-session" | "app-session" | "device";
|
|
35
|
+
|
|
36
|
+
/** Where the value came from: the host handed it over, or the kit minted it. */
|
|
37
|
+
export type IdentitySource = "host" | "auto";
|
|
38
|
+
|
|
39
|
+
/** An id plus everything a consumer needs to decide whether it may use it. */
|
|
40
|
+
export type IdentityRecord = {
|
|
41
|
+
/** The id itself, trimmed. Never empty (a blank input yields no record at all). */
|
|
42
|
+
value: string;
|
|
43
|
+
/** Which id space {@link value} belongs to. */
|
|
44
|
+
space: IdentitySpace;
|
|
45
|
+
/** `host` = the integrator supplied it; `auto` = the kit minted it. */
|
|
46
|
+
source: IdentitySource;
|
|
47
|
+
/**
|
|
48
|
+
* Whether the value was actually PERSISTED (or adopted from persistence), as opposed to living
|
|
49
|
+
* only in this process's memory. A non-durable auto id is a DIFFERENT id on the next launch, which
|
|
50
|
+
* for a `device` value is worse than no value at all: the server counts `min_sessions` by distinct
|
|
51
|
+
* opens grouped on `device_key`, so a per-launch key corrupts the counter rather than leaving it
|
|
52
|
+
* empty. A host-supplied value is durable by definition — the host owns its lifetime.
|
|
53
|
+
*/
|
|
54
|
+
durable: boolean;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Input to {@link resolveIdentity}. `value` is `unknown` so callers can pass a raw prop through. */
|
|
58
|
+
export type ResolveIdentityInput = {
|
|
59
|
+
value: unknown;
|
|
60
|
+
space: IdentitySpace;
|
|
61
|
+
source: IdentitySource;
|
|
62
|
+
/** Defaults to `true` for a host value (the host owns its lifetime) and `false` otherwise. */
|
|
63
|
+
durable?: boolean;
|
|
64
|
+
/** Tenant/app id — two tenants in one process never share a provenance entry. */
|
|
65
|
+
scope?: string;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Well-known key into the runtime-global symbol registry — one provenance registry per process. */
|
|
69
|
+
const IDENTITY_PROVENANCE_SLOT: unique symbol = Symbol.for("@wireai/activation:identityProvenance");
|
|
70
|
+
|
|
71
|
+
/** `"<space>:<scope>"` → the HOST-supplied value seen for it. Auto values are never recorded. */
|
|
72
|
+
type ProvenanceRegistry = { host: Map<string, string> };
|
|
73
|
+
|
|
74
|
+
type GlobalWithProvenance = typeof globalThis & {
|
|
75
|
+
[IDENTITY_PROVENANCE_SLOT]?: ProvenanceRegistry;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const provenanceGlobal = globalThis as GlobalWithProvenance;
|
|
79
|
+
|
|
80
|
+
const provenanceRegistry = (): ProvenanceRegistry => {
|
|
81
|
+
const existing = provenanceGlobal[IDENTITY_PROVENANCE_SLOT];
|
|
82
|
+
if (existing) return existing;
|
|
83
|
+
const created: ProvenanceRegistry = { host: new Map() };
|
|
84
|
+
provenanceGlobal[IDENTITY_PROVENANCE_SLOT] = created;
|
|
85
|
+
return created;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const provenanceKey = (space: IdentitySpace, scope?: string): string =>
|
|
89
|
+
`${space}:${scope ?? "default"}`;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Build an {@link IdentityRecord} from a candidate value, or `undefined` when there is nothing usable
|
|
93
|
+
* (a non-string, or blank after trimming) — so a caller can `if (record)`-gate instead of guessing
|
|
94
|
+
* whether an empty string means "none" or "not yet".
|
|
95
|
+
*
|
|
96
|
+
* SIDE EFFECT, deliberate and the reason this is a function and not an object literal: a `host`-sourced
|
|
97
|
+
* record is RECORDED on the process registry, so a later surface can ask {@link hostIdentity} whether
|
|
98
|
+
* this process demonstrably owns a host id in that space. That is what turns "the kit injected its own
|
|
99
|
+
* key" from a silent third id space into a warnable condition. Never throws.
|
|
100
|
+
*/
|
|
101
|
+
export const resolveIdentity = (input: ResolveIdentityInput): IdentityRecord | undefined => {
|
|
102
|
+
if (typeof input.value !== "string") return undefined;
|
|
103
|
+
const value = input.value.trim();
|
|
104
|
+
if (!value) return undefined;
|
|
105
|
+
const durable = input.durable ?? input.source === "host";
|
|
106
|
+
if (input.source === "host") {
|
|
107
|
+
provenanceRegistry().host.set(provenanceKey(input.space, input.scope), value);
|
|
108
|
+
}
|
|
109
|
+
return { value, space: input.space, source: input.source, durable };
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The HOST-supplied id this process has seen for a space, or `undefined` when every surface so far
|
|
114
|
+
* let the kit mint its own. Answers the cross-surface question no single mount can answer alone:
|
|
115
|
+
* "does this app own a device id that this particular mount was not given?"
|
|
116
|
+
*/
|
|
117
|
+
export const hostIdentity = (space: IdentitySpace, scope?: string): string | undefined =>
|
|
118
|
+
provenanceRegistry().host.get(provenanceKey(space, scope));
|
|
119
|
+
|
|
120
|
+
/** Test-only: forget every recorded host identity so a unit test starts from a clean registry. */
|
|
121
|
+
export const resetIdentityProvenance = (): void => {
|
|
122
|
+
provenanceRegistry().host.clear();
|
|
123
|
+
};
|
|
@@ -76,20 +76,49 @@ export type IdentifyOnboardingOptions = {
|
|
|
76
76
|
appId?: string;
|
|
77
77
|
/** Storage key override — pass the same `persistKey` you gave `<WireOnboarding>`, if any. */
|
|
78
78
|
persistKey?: string;
|
|
79
|
+
/**
|
|
80
|
+
* OPT-IN LAST RESORT, default `false`. When no ONBOARDING session can be resolved (no `contextId`,
|
|
81
|
+
* nothing in `storage`), bind the user to the LIVE PER-OPEN app session instead and return
|
|
82
|
+
* `"app_session"`.
|
|
83
|
+
*
|
|
84
|
+
* ⚠️ These are two different id spaces sharing one wire field. An onboarding session id is the A2A
|
|
85
|
+
* `contextId`; a per-open id is what `app.session_started` registers. The server's onboarding funnel
|
|
86
|
+
* groups by `session_id`, so a per-open id posted here does not attach the user to their onboarding
|
|
87
|
+
* — it writes a row nothing in that funnel can join. Until 0.13.0 this happened SILENTLY and
|
|
88
|
+
* returned `true`, in exactly the documented post-completion case (completion clears the persisted
|
|
89
|
+
* session), so the funnel stayed unattributed while the host was told it had worked.
|
|
90
|
+
*
|
|
91
|
+
* Turn it on only if binding the id to *some* session the server saw is genuinely worth more to you
|
|
92
|
+
* than knowing the onboarding bind failed — and read the return value, which now says which it was.
|
|
93
|
+
*/
|
|
94
|
+
allowAppSessionFallback?: boolean;
|
|
79
95
|
};
|
|
80
96
|
|
|
97
|
+
/**
|
|
98
|
+
* What {@link identifyOnboarding} bound, and to WHICH id space — because `true` could not say.
|
|
99
|
+
*
|
|
100
|
+
* • `"onboarding"` — bound to the A2A `contextId`. This is the one that attributes the funnel.
|
|
101
|
+
* • `"app_session"` — bound to the live per-open app session, via `allowAppSessionFallback`. The
|
|
102
|
+
* server saw that session, but it is not this user's onboarding.
|
|
103
|
+
* • `false` — nothing was dispatched (no user id, no server url, no resolvable session).
|
|
104
|
+
*
|
|
105
|
+
* ⚠️ 0.13.0 widened this from `boolean`. `"onboarding"` is truthy, so an `if (await identify…)` still
|
|
106
|
+
* behaves identically; only an explicit `: boolean` annotation needs updating.
|
|
107
|
+
*/
|
|
108
|
+
export type IdentifyOnboardingBinding = "onboarding" | "app_session" | false;
|
|
109
|
+
|
|
81
110
|
/**
|
|
82
111
|
* Attach a host user id to an onboarding session AFTER the fact (post-registration), by sending
|
|
83
112
|
* an `identify` client event to `/v1/events`. Resolves the contextId from an explicit
|
|
84
113
|
* `contextId` or, failing that, from the persisted session in the host `storage`.
|
|
85
114
|
*
|
|
86
|
-
* Fire-and-forget under the hood (never throws, never blocks onboarding). Resolves to
|
|
87
|
-
*
|
|
88
|
-
* or no resolvable
|
|
115
|
+
* Fire-and-forget under the hood (never throws, never blocks onboarding). Resolves to the
|
|
116
|
+
* {@link IdentifyOnboardingBinding} that says WHICH id space was bound, or `false` when nothing could
|
|
117
|
+
* be (no user id, no server url, or no resolvable session).
|
|
89
118
|
*/
|
|
90
119
|
export const identifyOnboarding = async (
|
|
91
120
|
opts: IdentifyOnboardingOptions,
|
|
92
|
-
): Promise<
|
|
121
|
+
): Promise<IdentifyOnboardingBinding> => {
|
|
93
122
|
const userId = sanitizeUserId(opts.userId);
|
|
94
123
|
if (!userId || !opts.config?.serverUrl) return false;
|
|
95
124
|
|
|
@@ -101,15 +130,22 @@ export const identifyOnboarding = async (
|
|
|
101
130
|
contextId = stored?.id;
|
|
102
131
|
}
|
|
103
132
|
}
|
|
104
|
-
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
|
|
133
|
+
let space: Exclude<IdentifyOnboardingBinding, false> = "onboarding";
|
|
134
|
+
// The OPT-IN last resort (K3). Until 0.13.0 this ran unconditionally: with no captured contextId
|
|
135
|
+
// it posted the LIVE PER-OPEN session id in the `session_id` field — which on this endpoint means
|
|
136
|
+
// the ONBOARDING session — and then returned `true`. Two disjoint id spaces share that field, so
|
|
137
|
+
// the row it wrote could never join the onboarding funnel, and the host got a success signal for a
|
|
138
|
+
// bind that had not happened. It is now off unless the caller asks, and when it does fire it says
|
|
139
|
+
// so in the return value instead of impersonating an onboarding bind.
|
|
140
|
+
if (!contextId && opts.allowAppSessionFallback) {
|
|
141
|
+
contextId = getCurrentSessionId();
|
|
142
|
+
space = "app_session";
|
|
143
|
+
}
|
|
108
144
|
if (!contextId) return false;
|
|
109
145
|
|
|
110
146
|
reportClientEvent(
|
|
111
147
|
{ serverUrl: opts.config.serverUrl, apiKey: opts.config.apiKey },
|
|
112
148
|
{ event_type: "identify", session_id: contextId, user_id: userId },
|
|
113
149
|
);
|
|
114
|
-
return
|
|
150
|
+
return space;
|
|
115
151
|
};
|
package/src/index.ts
CHANGED
|
@@ -30,8 +30,6 @@ export { LoadingBlock } from "./components/LoadingBlock";
|
|
|
30
30
|
export { LoadingScreen } from "./components/LoadingScreen";
|
|
31
31
|
export { AnimatedSparkle } from "./components/AnimatedSparkle";
|
|
32
32
|
export { ErrorBlock } from "./components/ErrorBlock";
|
|
33
|
-
/** @deprecated No longer used internally — the terminal screen is CompletionView. */
|
|
34
|
-
export { DoneBlock } from "./components/DoneBlock";
|
|
35
33
|
export { CompletionView } from "./components/CompletionView";
|
|
36
34
|
export { IllustrationProvider, useIllustration } from "./components/Illustration";
|
|
37
35
|
export type { IllustrationRegistry } from "./components/Illustration";
|
|
@@ -147,7 +145,9 @@ export { detectNativeModel } from "./device/deviceModel";
|
|
|
147
145
|
|
|
148
146
|
// ─── User identity (opaque pseudonymous id; late binding, dependency-free) ────
|
|
149
147
|
export { identifyOnboarding, sanitizeUserId, looksLikeEmail, USER_ID_MAX_LENGTH } from "./identity/userIdentity";
|
|
150
|
-
export type { IdentifyOnboardingOptions } from "./identity/userIdentity";
|
|
148
|
+
export type { IdentifyOnboardingOptions, IdentifyOnboardingBinding } from "./identity/userIdentity";
|
|
149
|
+
export { resolveIdentity, hostIdentity, resetIdentityProvenance } from "./identity/identityRecord";
|
|
150
|
+
export type { IdentityRecord, IdentitySpace, IdentitySource } from "./identity/identityRecord";
|
|
151
151
|
|
|
152
152
|
// ─── Rich user context (one object → every event's user_context; opt-in email PII) ────
|
|
153
153
|
export {
|
|
@@ -159,7 +159,6 @@ export {
|
|
|
159
159
|
clearPiiFromContext,
|
|
160
160
|
analyticsUserIdStorageKey,
|
|
161
161
|
activationJoinContext,
|
|
162
|
-
RESERVED_USER_CONTEXT_KEYS,
|
|
163
162
|
EXTRA_KEY_PREFIX,
|
|
164
163
|
} from "./context/userContext";
|
|
165
164
|
export type {
|
|
@@ -182,6 +181,9 @@ export {
|
|
|
182
181
|
// The awaitable sibling: resolves AFTER the persisted id has been read back, for a caller that can
|
|
183
182
|
// afford one storage read and must not stamp a key minted a millisecond ago (the lifecycle mount).
|
|
184
183
|
hydrateAutoDeviceKey,
|
|
184
|
+
// The PROVENANCE-carrying form of the same read: it also says whether the id was actually persisted,
|
|
185
|
+
// so a caller writing a cross-launch join key onto the wire can refuse a per-launch one.
|
|
186
|
+
hydrateDeviceIdentity,
|
|
185
187
|
resetAutoDeviceKeys,
|
|
186
188
|
} from "./context/deviceId";
|
|
187
189
|
export type { DeviceKeyStorage, ResolveAutoDeviceKeyOptions } from "./context/deviceId";
|
|
@@ -33,6 +33,7 @@ import { AppState, Platform, type AppStateStatus } from "react-native";
|
|
|
33
33
|
import { createEventQueue, type EnvelopeSource, type EventQueue } from "../analytics/eventQueue";
|
|
34
34
|
import { makeSessionId, type ClientEvent, type ClientEventTarget } from "../analytics/reportClientEvent";
|
|
35
35
|
import { hydrateAutoDeviceKey, resolveAutoDeviceKey } from "../context/deviceId";
|
|
36
|
+
import { resolveIdentity } from "../identity/identityRecord";
|
|
36
37
|
import { collectDeviceContext } from "../device/deviceContext";
|
|
37
38
|
import type { WireOnboardingStorage } from "../session/persistedSession";
|
|
38
39
|
import { reportFirstOpen } from "./lifecycle";
|
|
@@ -137,7 +138,15 @@ export const useLifecycleEvents = (
|
|
|
137
138
|
cfg: LifecycleConfig | undefined,
|
|
138
139
|
opts: UseLifecycleEventsOptions,
|
|
139
140
|
): string | undefined => {
|
|
140
|
-
|
|
141
|
+
// A HOST-supplied key is recorded on the process provenance registry, so a `<WireOnboarding>`
|
|
142
|
+
// mount that was not given one can tell "this app owns no device id" from "this app owns one
|
|
143
|
+
// and forgot it there" — the silent third id space (K9). Recording only; nothing reads it here.
|
|
144
|
+
const host = resolveIdentity({
|
|
145
|
+
value: opts.deviceKey,
|
|
146
|
+
space: "device",
|
|
147
|
+
source: "host",
|
|
148
|
+
scope: cfg?.appId,
|
|
149
|
+
})?.value;
|
|
141
150
|
if (host) return host;
|
|
142
151
|
if (!cfg?.storage) return undefined;
|
|
143
152
|
return resolveAutoDeviceKey({ appId: cfg.appId, storage: cfg.storage });
|