@wireai/activation 0.12.2 → 0.13.2

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 (55) hide show
  1. package/AGENTS.md +3 -1
  2. package/CHANGELOG.md +259 -1
  3. package/README.md +87 -3
  4. package/dist/analytics/index.d.mts +2 -2
  5. package/dist/analytics/index.d.ts +2 -2
  6. package/dist/analytics/index.js +174 -36
  7. package/dist/analytics/index.js.map +1 -1
  8. package/dist/analytics/index.mjs +174 -37
  9. package/dist/analytics/index.mjs.map +1 -1
  10. package/dist/{currentSession-BlCeDP0f.d.mts → currentSession-ClkLjcJ0.d.mts} +456 -19
  11. package/dist/{currentSession-BxEB37xt.d.ts → currentSession-DOVZEWJl.d.ts} +456 -19
  12. package/dist/index.d.mts +197 -16
  13. package/dist/index.d.ts +197 -16
  14. package/dist/index.js +1056 -390
  15. package/dist/index.js.map +1 -1
  16. package/dist/index.mjs +836 -193
  17. package/dist/index.mjs.map +1 -1
  18. package/dist/questionnaire/index.js.map +1 -1
  19. package/dist/questionnaire/index.mjs.map +1 -1
  20. package/dist/reviews/index.js +20 -7
  21. package/dist/reviews/index.js.map +1 -1
  22. package/dist/reviews/index.mjs +20 -7
  23. package/dist/reviews/index.mjs.map +1 -1
  24. package/package.json +1 -1
  25. package/src/OnboardingFlow.tsx +141 -3
  26. package/src/WireOnboarding.tsx +178 -34
  27. package/src/activation/wireActivation.ts +13 -7
  28. package/src/analytics/analyticsEvent.ts +16 -1
  29. package/src/analytics/analyticsFacade.ts +11 -10
  30. package/src/analytics/currentSession.ts +6 -20
  31. package/src/analytics/eventQueue.ts +71 -1
  32. package/src/analytics/index.ts +1 -1
  33. package/src/analytics/reportClientEvent.ts +157 -38
  34. package/src/cards/PermissionCard.tsx +438 -0
  35. package/src/cards/index.ts +7 -0
  36. package/src/config/wireConfigFromEnv.ts +1 -10
  37. package/src/context/deviceId.ts +77 -16
  38. package/src/context/userContext.ts +4 -15
  39. package/src/identity/identityRecord.ts +123 -0
  40. package/src/identity/userIdentity.ts +45 -9
  41. package/src/illustrations/defaultIllustrations.tsx +44 -3
  42. package/src/index.ts +44 -4
  43. package/src/permissions/index.ts +64 -0
  44. package/src/permissions/permissionCopy.ts +87 -0
  45. package/src/permissions/permissionEvents.ts +76 -0
  46. package/src/permissions/permissionMemory.ts +88 -0
  47. package/src/permissions/placement.ts +88 -0
  48. package/src/permissions/types.ts +131 -0
  49. package/src/session/persistedSession.ts +10 -3
  50. package/src/session-analytics/useLifecycleEvents.ts +10 -1
  51. package/src/types.ts +77 -1
  52. package/src/utils/deriveAnswers.ts +6 -2
  53. package/src/utils/readProgress.ts +4 -0
  54. package/src/utils/warnInDev.ts +33 -0
  55. package/src/components/DoneBlock.tsx +0 -37
@@ -0,0 +1,438 @@
1
+ /**
2
+ * PermissionCard - the priming screen that stands in front of an OS permission dialog.
3
+ *
4
+ * THE PRIMING PATTERN, which is the entire point: the OS dialog opens from the primary press
5
+ * handler and from nowhere else. There is no mount effect, no auto-fire, no timer, and no code path
6
+ * from render to `request()`. iOS grants an app exactly ONE native notification prompt for its
7
+ * whole lifetime, so this cheap in-app screen spends first and only forwards the users who said
8
+ * yes. The secondary ("Maybe later") advances the flow with the prompt still unspent.
9
+ *
10
+ * THREE PRIMARY ACTIONS, picked from the non-prompting `getStatus` probe (never from `request`):
11
+ * • ask (the default, and the only branch that can reach `request`)
12
+ * • settings (status `blocked`: the OS would show nothing, so the only route left is Settings)
13
+ * • continue (already granted, or a host that supplied no `request` at all - the kit declines to
14
+ * fabricate a prompt it has no way to open)
15
+ *
16
+ * IT IS NOT A QUESTION. It sends nothing to the backend, appends nothing to the thread, and mints
17
+ * no `key` / `slot_id`, so `deriveAnswers` and every completion semantic are untouched. Every
18
+ * outcome, including a denial, continues the flow.
19
+ *
20
+ * Motion: the same register as the other value beats. The illustration springs in
21
+ * (STATUS_POP_SPRING, the StatusCard glyph pop) and the copy rises behind it on the interstitial
22
+ * head stagger. Reduce motion: final frame at once, like every other card.
23
+ *
24
+ * Registered as a `WireAIComponent` so a later server-emitted placement (AI-chosen timing) renders
25
+ * through the same component with no rewrite. It is deliberately NOT in `onboardingComponents`:
26
+ * that array is what the device ADVERTISES as renderable, and a backend told it may emit this card
27
+ * could emit one for a host that wired no `request`.
28
+ */
29
+ import React, { useCallback, useEffect, useRef, useState } from "react";
30
+ import { Animated, Easing, StyleSheet, Text, View } from "react-native";
31
+ import { z } from "zod";
32
+ import type { InjectedProps, WireAIComponent } from "wireai-rn";
33
+ import { useOnboardingTheme } from "../theme/ThemeContext";
34
+ import { bodyStyle, headingStyle } from "../theme/typography";
35
+ import { useIllustration } from "../components/Illustration";
36
+ import { Button } from "../components/Button";
37
+ import { CardLayout } from "../components/CardLayout";
38
+ import {
39
+ INTERSTITIAL_HEAD_MS,
40
+ INTERSTITIAL_HEAD_STAGGER_MS,
41
+ STATUS_POP_SCALE_FROM,
42
+ STATUS_POP_SPRING,
43
+ WIRE_BEZIER,
44
+ scaledMs,
45
+ } from "../motion/motionSpec";
46
+ import { useReducedMotion } from "../motion/useReducedMotion";
47
+ import { normalizePermissionStatus } from "../permissions/permissionEvents";
48
+ import type {
49
+ PermissionStage,
50
+ WirePermissionOutcome,
51
+ WirePermissionStatus,
52
+ } from "../permissions/types";
53
+ import { playHaptic } from "../haptics/haptics";
54
+ import { warnInDev } from "../utils/warnInDev";
55
+
56
+ const easeWire = Easing.bezier(...WIRE_BEZIER);
57
+ const RISE_PX = 10;
58
+
59
+ /** The name the card is registered under, and the `component` stamped on its events. */
60
+ export const PERMISSION_CARD_NAME = "PermissionCard";
61
+
62
+ /**
63
+ * How long a host's `request` may stay outstanding before the kit hands the controls back.
64
+ *
65
+ * DELIBERATELY GENEROUS. This is not a race against the user: someone reading an OS permission
66
+ * dialog, switching apps mid-prompt, or hitting a slow native bridge is doing nothing wrong, and a
67
+ * short ceiling would advance the flow underneath a dialog that is still open. It exists for one
68
+ * failure only, a `request` that never settles at all (a swallowed native callback, a promise
69
+ * nobody resolves), which would otherwise leave the user on a screen whose buttons are all
70
+ * disabled. On expiry the kit records NO outcome, because a pending request is not a denial.
71
+ */
72
+ export const REQUEST_WATCHDOG_MS = 90_000;
73
+
74
+ /**
75
+ * The COPY half of the card, i.e. everything a server could legitimately author. The capability
76
+ * half (`request` / `getStatus` / `openSettings`) is host-injected and deliberately absent here:
77
+ * a schema field can only ever carry data, never a function, and the kit imports no native module.
78
+ */
79
+ const schema = z.object({
80
+ permission: z
81
+ .string()
82
+ .describe("Which OS permission this screen primes, e.g. 'notifications'"),
83
+ title: z.string().describe("Headline for the ask"),
84
+ message: z.string().describe("Why the app needs it, in the user's terms"),
85
+ primaryLabel: z.string().describe("Primary button, the only control that can open the OS dialog"),
86
+ secondaryLabel: z.string().describe("Secondary button, advances without spending the OS prompt"),
87
+ blockedTitle: z.string().optional().describe("Headline once the permission is permanently refused"),
88
+ blockedMessage: z.string().optional().describe("Rationale for the settings route"),
89
+ settingsLabel: z.string().optional().describe("Primary button label on the blocked route"),
90
+ continueLabel: z.string().optional().describe("Primary button label when there is nothing to ask"),
91
+ illustration: z
92
+ .string()
93
+ .optional()
94
+ .describe("Name of an app-provided illustration (defaults to the permission name)"),
95
+ });
96
+
97
+ export type PermissionCardProps = z.infer<typeof schema> &
98
+ Partial<InjectedProps> & {
99
+ /** THE ONLY function that can open an OS dialog. Called from the primary handler alone. */
100
+ request?: () => Promise<WirePermissionStatus>;
101
+ /** NON-PROMPTING status read. Decides which primary action is offered, nothing else. */
102
+ getStatus?: () => Promise<WirePermissionStatus>;
103
+ /** Open the OS settings page. Only reachable on the `blocked` route. */
104
+ openSettings?: () => void | Promise<void>;
105
+ /** Reports each moment for analytics. Never control flow. */
106
+ onStage?: (stage: PermissionStage, status?: WirePermissionStatus) => void;
107
+ /** Fires exactly once, with the outcome this screen produced. The flow advances on it. */
108
+ onSettled?: (outcome: WirePermissionOutcome) => void;
109
+ };
110
+
111
+ /** Which action the primary button performs. `ask` is the only one that can reach `request`. */
112
+ type PrimaryAction = "ask" | "settings" | "continue";
113
+
114
+ const _PermissionCard: React.FC<PermissionCardProps> = ({
115
+ permission,
116
+ title,
117
+ message,
118
+ primaryLabel,
119
+ secondaryLabel,
120
+ blockedTitle,
121
+ blockedMessage,
122
+ settingsLabel,
123
+ continueLabel,
124
+ illustration,
125
+ request,
126
+ getStatus,
127
+ openSettings,
128
+ onStage,
129
+ onSettled,
130
+ }) => {
131
+ const t = useOnboardingTheme();
132
+ const reduced = useReducedMotion();
133
+ const art = useIllustration(illustration ?? permission);
134
+ // The probed status. `undefined` means "not known", which is the ASK state: the kit never
135
+ // assumes a grant it has not been told about.
136
+ const [status, setStatus] = useState<WirePermissionStatus | undefined>(undefined);
137
+ const [busy, setBusy] = useState(false);
138
+ // One settle per mount. The card is keyed by screen id, so this is one settle per screen. It is
139
+ // also what makes a late `request` settlement a no-op, since `finish` reaches `settle`
140
+ // synchronously (see `handlePrimary`).
141
+ const settledRef = useRef(false);
142
+ // `accepted` is a per-screen boolean, so `accepted / shown` stays a readable rate.
143
+ const acceptedRef = useRef(false);
144
+ const watchdogRef = useRef<ReturnType<typeof setTimeout> | null>(null);
145
+ const clearWatchdog = useCallback(() => {
146
+ if (watchdogRef.current) {
147
+ clearTimeout(watchdogRef.current);
148
+ watchdogRef.current = null;
149
+ }
150
+ }, []);
151
+ // A pending watchdog must never outlive the screen (it would setState on an unmounted card).
152
+ useEffect(() => clearWatchdog, [clearWatchdog]);
153
+
154
+ // Host callbacks held in refs so the mount-once effects below stay mount-once no matter how a
155
+ // host passes them (the documented usage is an INLINE `permissionScreens={[...]}`, which mints a
156
+ // fresh closure for every one of them on every render).
157
+ const onStageRef = useRef(onStage);
158
+ onStageRef.current = onStage;
159
+ const onSettledRef = useRef(onSettled);
160
+ onSettledRef.current = onSettled;
161
+ const getStatusRef = useRef(getStatus);
162
+ getStatusRef.current = getStatus;
163
+
164
+ // `shown` fires once per mount, ref-guarded so StrictMode's dev double-invoke cannot
165
+ // double-count the denominator every rate in this funnel is measured against.
166
+ const shownRef = useRef(false);
167
+ useEffect(() => {
168
+ if (shownRef.current) return;
169
+ shownRef.current = true;
170
+ onStageRef.current?.("shown");
171
+ }, []);
172
+
173
+ // The status probe, MOUNT-ONCE. `getPermissionsAsync()` and its equivalents READ, they never
174
+ // prompt - which is exactly why `request` is not called here and why this effect may exist at
175
+ // all. All it decides is which primary action the screen offers.
176
+ useEffect(() => {
177
+ const probe = getStatusRef.current;
178
+ if (!probe) return;
179
+ let cancelled = false;
180
+ try {
181
+ void Promise.resolve(probe())
182
+ .then((value) => {
183
+ if (!cancelled) setStatus(normalizePermissionStatus(value));
184
+ })
185
+ .catch(() => {
186
+ // A host probe that throws just leaves the screen in its ask state.
187
+ });
188
+ } catch {
189
+ // A synchronously-throwing probe, same treatment.
190
+ }
191
+ return () => {
192
+ cancelled = true;
193
+ };
194
+ }, []);
195
+
196
+ // A host that configured a screen with no `request` gets a screen that cannot ask. The kit says
197
+ // so instead of rendering a button that silently does nothing (dev only, never a throw).
198
+ const requestRef = useRef(request);
199
+ requestRef.current = request;
200
+ useEffect(() => {
201
+ if (requestRef.current) return;
202
+ warnInDev(
203
+ `[wireai] <WireOnboarding> got a permission screen for "${permission}" with no \`request\` ` +
204
+ "function, so it cannot open the OS dialog and renders as a plain continue. Pass " +
205
+ "request: () => Promise<'granted' | 'denied' | 'blocked'> (5 lines around " +
206
+ "expo-notifications, see the README).",
207
+ );
208
+ }, [permission]);
209
+
210
+ const settle = useCallback((outcome: WirePermissionOutcome) => {
211
+ if (settledRef.current) return;
212
+ settledRef.current = true;
213
+ clearWatchdog();
214
+ onSettledRef.current?.(outcome);
215
+ }, [clearWatchdog]);
216
+
217
+ const primaryAction: PrimaryAction =
218
+ status === "blocked" ? "settings" : status === "granted" || !request ? "continue" : "ask";
219
+
220
+ const handlePrimary = useCallback(() => {
221
+ if (settledRef.current || busy) return;
222
+
223
+ if (primaryAction === "settings") {
224
+ onStageRef.current?.("settings", "blocked");
225
+ try {
226
+ void Promise.resolve(openSettings?.()).catch(() => {});
227
+ } catch {
228
+ // A settings redirect that fails is not a reason to trap the user on this screen.
229
+ }
230
+ settle("blocked");
231
+ return;
232
+ }
233
+
234
+ if (primaryAction === "continue") {
235
+ // Already granted (nothing to ask), or no `request` was supplied. Report the state as it is;
236
+ // never report a grant this screen did not produce as anything other than what it is.
237
+ if (status === "granted") {
238
+ onStageRef.current?.("granted", "granted");
239
+ settle("granted");
240
+ } else {
241
+ onStageRef.current?.("skipped");
242
+ settle("skipped");
243
+ }
244
+ return;
245
+ }
246
+
247
+ // THE ONE PATH TO THE OS DIALOG, reachable from this press handler only.
248
+ //
249
+ // `accepted` is a per-screen boolean, not a per-tap counter: the screen is shown once, and
250
+ // `accepted / shown` is the rate that says whether the rationale copy works. A second tap after
251
+ // the watchdog re-armed the UI would push that rate past 100%, so it is emitted once.
252
+ if (!acceptedRef.current) {
253
+ acceptedRef.current = true;
254
+ onStageRef.current?.("accepted");
255
+ }
256
+ setBusy(true);
257
+ // FIRST SETTLEMENT WINS, and `settledRef` is the whole mechanism. The watchdog below can hand
258
+ // the UI back while a `request` is still outstanding, so a second tap can put a SECOND one in
259
+ // flight, and the original can answer late. What decides between them is that this function
260
+ // runs STRAIGHT THROUGH to `settle` with no await in between: the first settlement to arrive
261
+ // has already flipped that ref by the time any later one is invoked, so the later one returns
262
+ // on the line below. The flow advances once and each stage event is emitted once. The same ref
263
+ // is why a settlement landing after the user skipped changes nothing.
264
+ const finish = (value: unknown) => {
265
+ if (settledRef.current) return;
266
+ clearWatchdog();
267
+ const resolved = normalizePermissionStatus(value);
268
+ setBusy(false);
269
+ setStatus(resolved);
270
+ if (resolved === "granted") {
271
+ // A light success tap on the grant, through the kit's optional-peer haptics: a host
272
+ // without `expo-haptics` simply feels nothing and nothing throws.
273
+ playHaptic("success");
274
+ onStageRef.current?.("granted", "granted");
275
+ } else {
276
+ onStageRef.current?.("denied", resolved);
277
+ }
278
+ settle(resolved);
279
+ };
280
+
281
+ // THE WATCHDOG, and what it deliberately does NOT do.
282
+ //
283
+ // A host `request` that never settles (a native module that swallows its callback, a promise
284
+ // that is never resolved) would otherwise BRICK the screen: `busy` disables both buttons, the
285
+ // secondary re-guards on it, and nothing upstream rescues a rendered card. Dead end, and
286
+ // `onComplete` never fires.
287
+ //
288
+ // So the ceiling exists to un-brick a HOST BUG, never to race the user. A person can sit on an
289
+ // OS permission dialog for a long time, so a short ceiling that recorded `denied` on expiry
290
+ // would advance the flow underneath a dialog that is still open and log an outcome the user
291
+ // never gave. On expiry this therefore fabricates NOTHING: no outcome, no event, no settle. It
292
+ // only hands the controls back so the user can tap again or skip, and says so in dev.
293
+ clearWatchdog();
294
+ watchdogRef.current = setTimeout(() => {
295
+ watchdogRef.current = null;
296
+ if (settledRef.current) return;
297
+ setBusy(false);
298
+ warnInDev(
299
+ `[wireai] the \`request\` for the "${permission}" permission screen has not settled after ` +
300
+ `${Math.round(REQUEST_WATCHDOG_MS / 1000)}s, so the kit handed the controls back rather ` +
301
+ "than leaving the user on a dead-end screen. It recorded NO outcome, because a pending " +
302
+ "request is not a denial. Make sure your request resolves to 'granted' | 'denied' | " +
303
+ "'blocked' on every branch, including the one where the user dismisses the OS dialog.",
304
+ );
305
+ }, REQUEST_WATCHDOG_MS);
306
+
307
+ try {
308
+ void Promise.resolve(request?.()).then(finish, () => finish("denied"));
309
+ } catch {
310
+ // A synchronously-throwing request is a denial, never a stuck screen.
311
+ finish("denied");
312
+ }
313
+ }, [busy, primaryAction, status, request, openSettings, settle, clearWatchdog, permission]);
314
+
315
+ const handleSecondary = useCallback(() => {
316
+ if (settledRef.current || busy) return;
317
+ onStageRef.current?.("skipped");
318
+ settle("skipped");
319
+ }, [busy, settle]);
320
+
321
+ // Art springs in, copy rises behind it (final frame at once under reduce motion).
322
+ const popT = useRef(new Animated.Value(reduced ? 1 : 0)).current;
323
+ const titleT = useRef(new Animated.Value(reduced ? 1 : 0)).current;
324
+ const bodyT = useRef(new Animated.Value(reduced ? 1 : 0)).current;
325
+ useEffect(() => {
326
+ if (reduced) {
327
+ popT.setValue(1);
328
+ titleT.setValue(1);
329
+ bodyT.setValue(1);
330
+ return;
331
+ }
332
+ const rise = (value: Animated.Value, delay: number) =>
333
+ Animated.timing(value, {
334
+ toValue: 1,
335
+ duration: scaledMs(INTERSTITIAL_HEAD_MS),
336
+ delay,
337
+ easing: easeWire,
338
+ useNativeDriver: true,
339
+ });
340
+ const anims = [
341
+ Animated.spring(popT, {
342
+ toValue: 1,
343
+ friction: STATUS_POP_SPRING.friction,
344
+ tension: STATUS_POP_SPRING.tension,
345
+ useNativeDriver: true,
346
+ }),
347
+ rise(titleT, scaledMs(INTERSTITIAL_HEAD_STAGGER_MS)),
348
+ rise(bodyT, scaledMs(INTERSTITIAL_HEAD_STAGGER_MS * 2)),
349
+ ];
350
+ anims.forEach((a) => a.start());
351
+ return () => anims.forEach((a) => a.stop());
352
+ }, [reduced, popT, titleT, bodyT]);
353
+
354
+ const popScale = popT.interpolate({
355
+ inputRange: [0, 1],
356
+ outputRange: [STATUS_POP_SCALE_FROM, 1],
357
+ });
358
+ const riseStyle = (value: Animated.Value) => ({
359
+ opacity: value,
360
+ transform: [
361
+ { translateY: value.interpolate({ inputRange: [0, 1], outputRange: [RISE_PX, 0] }) },
362
+ ],
363
+ });
364
+
365
+ const blocked = status === "blocked";
366
+ const shownTitle = blocked ? (blockedTitle ?? title) : title;
367
+ const shownMessage = blocked ? (blockedMessage ?? message) : message;
368
+ const primaryTitle =
369
+ primaryAction === "settings"
370
+ ? (settingsLabel ?? primaryLabel)
371
+ : primaryAction === "continue"
372
+ ? (continueLabel ?? primaryLabel)
373
+ : primaryLabel;
374
+ // Nothing left to decline once the permission is already granted.
375
+ const showSecondary = status !== "granted";
376
+
377
+ return (
378
+ <CardLayout
379
+ align="center"
380
+ footer={
381
+ <View style={[styles.footer, { gap: t.spacing.sm }]}>
382
+ <Button title={primaryTitle} onPress={handlePrimary} variant="primary" disabled={busy} />
383
+ {showSecondary ? (
384
+ <Button
385
+ title={secondaryLabel}
386
+ onPress={handleSecondary}
387
+ variant="outline"
388
+ disabled={busy}
389
+ />
390
+ ) : null}
391
+ </View>
392
+ }
393
+ >
394
+ <View style={[styles.center, { gap: t.spacing.md }]}>
395
+ {art ? (
396
+ <Animated.View
397
+ style={[styles.art, { opacity: popT, transform: [{ scale: popScale }] }]}
398
+ >
399
+ {art}
400
+ </Animated.View>
401
+ ) : null}
402
+
403
+ <Animated.View style={riseStyle(titleT)}>
404
+ <Text style={[headingStyle(t.fonts), { color: t.colors.text, textAlign: "center" }]}>
405
+ {shownTitle}
406
+ </Text>
407
+ </Animated.View>
408
+
409
+ <Animated.View style={riseStyle(bodyT)}>
410
+ <Text style={[bodyStyle(t.fonts), { color: t.colors.textMuted, textAlign: "center" }]}>
411
+ {shownMessage}
412
+ </Text>
413
+ </Animated.View>
414
+ </View>
415
+ </CardLayout>
416
+ );
417
+ };
418
+
419
+ /** The typed component the flow renders directly (host-injected placement). */
420
+ export const PermissionCardView = React.memo(_PermissionCard);
421
+
422
+ /**
423
+ * The SDK registration object, so a server-emitted placement can adopt this exact screen later.
424
+ * Not part of `onboardingComponents` on purpose - see the file header.
425
+ */
426
+ export const PermissionCard: WireAIComponent = {
427
+ name: PERMISSION_CARD_NAME,
428
+ description:
429
+ "A priming screen shown BEFORE an OS permission dialog: it explains why the app wants the permission and asks only on the primary tap. Never first, never last. The user is never blocked by it: the secondary advances the flow with the OS prompt unspent.",
430
+ component: PermissionCardView as WireAIComponent["component"],
431
+ propsSchema: schema,
432
+ };
433
+
434
+ const styles = StyleSheet.create({
435
+ center: { width: "100%", alignItems: "center", justifyContent: "center" },
436
+ art: { alignItems: "center", justifyContent: "center" },
437
+ footer: { width: "100%", alignItems: "center" },
438
+ });
@@ -21,9 +21,16 @@ export {
21
21
  NumberStepperCard,
22
22
  InterstitialCard,
23
23
  };
24
+ export { PermissionCard, PermissionCardView, PERMISSION_CARD_NAME } from "./PermissionCard";
25
+ export type { PermissionCardProps } from "./PermissionCard";
24
26
  export { normalizeOptions, optionsField, optionObjectSchema } from "./optionSchema";
25
27
  export type { CardOption } from "./optionSchema";
26
28
 
29
+ // ⚠️ `PermissionCard` is deliberately NOT in this array. The list is what the device ADVERTISES to
30
+ // the backend as renderable (`metadata.supportedComponents`), and a backend told it may emit a
31
+ // permission screen could emit one into a host that injected no `request` function, which the kit
32
+ // has no way to honour. Permission screens are host-declared for now (`permissionScreens`); the
33
+ // card is registered and ready for the day a server-emitted placement lands.
27
34
  export const onboardingComponents: WireAIComponent[] = [
28
35
  ChipSelectCard,
29
36
  TextInputCard,
@@ -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
@@ -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<string>>;
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 id it settles on.
112
- * The promise is parked on the registry so a later `hydrateAutoDeviceKey` awaits the SAME read
113
- * instead of starting a second one. Never rejects: any storage failure resolves to the live id.
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<string> => {
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 settled = (): string => registry.keys.get(appId) ?? minted;
127
- let run: Promise<string>;
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
- return Promise.resolve(storage.setItem(slot, minted)).then(settled, settled);
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(settled);
167
+ .catch(degraded);
140
168
  } catch {
141
169
  // A storage adapter that throws synchronously — degrade to the in-memory id.
142
- run = Promise.resolve(settled());
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
- if (pending) await pending;
207
- return registry.keys.get(appId) ?? id;
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
- const cleanString = (value: unknown): string | undefined => {
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;