pulse-updates 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +50 -10
  2. package/SECURITY.md +42 -0
  3. package/android/src/main/java/app/pulse/updates/PulseController.kt +6 -2
  4. package/android/src/main/java/app/pulse/updates/PulseUpdatesModule.kt +25 -0
  5. package/app.plugin.js +47 -0
  6. package/ios/PulseUpdates/PulseController.swift +7 -2
  7. package/ios/PulseUpdates/PulseUpdates.m +7 -0
  8. package/ios/PulseUpdates/PulseUpdates.swift +22 -0
  9. package/lib/commonjs/NativePulseUpdates.js.map +1 -1
  10. package/lib/commonjs/PulseUpdates.js +36 -0
  11. package/lib/commonjs/PulseUpdates.js.map +1 -1
  12. package/lib/commonjs/config.js +175 -21
  13. package/lib/commonjs/config.js.map +1 -1
  14. package/lib/commonjs/decisions.js +128 -0
  15. package/lib/commonjs/decisions.js.map +1 -0
  16. package/lib/commonjs/index.js +12 -0
  17. package/lib/commonjs/index.js.map +1 -1
  18. package/lib/commonjs/init.js +153 -13
  19. package/lib/commonjs/init.js.map +1 -1
  20. package/lib/commonjs/track.js +154 -12
  21. package/lib/commonjs/track.js.map +1 -1
  22. package/lib/commonjs/usePulseUpdates.js +21 -16
  23. package/lib/commonjs/usePulseUpdates.js.map +1 -1
  24. package/lib/module/NativePulseUpdates.js.map +1 -1
  25. package/lib/module/PulseUpdates.js +33 -0
  26. package/lib/module/PulseUpdates.js.map +1 -1
  27. package/lib/module/config.js +174 -21
  28. package/lib/module/config.js.map +1 -1
  29. package/lib/module/decisions.js +122 -0
  30. package/lib/module/decisions.js.map +1 -0
  31. package/lib/module/index.js +1 -0
  32. package/lib/module/index.js.map +1 -1
  33. package/lib/module/init.js +154 -15
  34. package/lib/module/init.js.map +1 -1
  35. package/lib/module/track.js +152 -12
  36. package/lib/module/track.js.map +1 -1
  37. package/lib/module/usePulseUpdates.js +21 -16
  38. package/lib/module/usePulseUpdates.js.map +1 -1
  39. package/lib/typescript/NativePulseUpdates.d.ts +1 -0
  40. package/lib/typescript/NativePulseUpdates.d.ts.map +1 -1
  41. package/lib/typescript/PulseUpdates.d.ts +20 -0
  42. package/lib/typescript/PulseUpdates.d.ts.map +1 -1
  43. package/lib/typescript/config.d.ts +22 -0
  44. package/lib/typescript/config.d.ts.map +1 -1
  45. package/lib/typescript/decisions.d.ts +55 -0
  46. package/lib/typescript/decisions.d.ts.map +1 -0
  47. package/lib/typescript/index.d.ts +1 -0
  48. package/lib/typescript/index.d.ts.map +1 -1
  49. package/lib/typescript/init.d.ts +51 -4
  50. package/lib/typescript/init.d.ts.map +1 -1
  51. package/lib/typescript/track.d.ts +29 -1
  52. package/lib/typescript/track.d.ts.map +1 -1
  53. package/lib/typescript/usePulseUpdates.d.ts.map +1 -1
  54. package/logo.png +0 -0
  55. package/package.json +14 -3
  56. package/scripts/publish.mjs +68 -3
  57. package/src/NativePulseUpdates.ts +1 -0
  58. package/src/PulseUpdates.ts +54 -0
  59. package/src/config.ts +234 -21
  60. package/src/decisions.ts +179 -0
  61. package/src/index.ts +1 -0
  62. package/src/init.ts +227 -12
  63. package/src/track.ts +191 -13
  64. package/src/usePulseUpdates.ts +21 -16
@@ -0,0 +1,179 @@
1
+ export type DecisionChannel = 'in_app' | 'encore' | 'replio' | 'email' | 'push' | 'sms' | 'whatsapp' | 'webhook';
2
+ export type DecisionOutcome = 'pending' | 'delivered' | 'dismissed' | 'accepted' | 'converted' | 'failed';
3
+
4
+ export interface DecisionContext {
5
+ userId?: string;
6
+ deviceId?: string;
7
+ platform?: string;
8
+ appVersion?: string;
9
+ country?: string;
10
+ language?: string;
11
+ osVersion?: string;
12
+ attributes?: Record<string, string>;
13
+ }
14
+
15
+ export interface DecisionAction { kind: string; [key: string]: unknown }
16
+
17
+ export interface PulseDecision {
18
+ decisionId: string;
19
+ replayed: boolean;
20
+ execute: boolean;
21
+ mode: 'shadow' | 'live' | string;
22
+ verdict: string;
23
+ reasonCode: string;
24
+ policyKey?: string | null;
25
+ policyVersion?: number | null;
26
+ action?: DecisionAction | null;
27
+ }
28
+
29
+ export interface DecisionHandlerResult {
30
+ outcome?: DecisionOutcome;
31
+ metadata?: Record<string, unknown>;
32
+ }
33
+
34
+ export type DecisionHandler = (
35
+ action: DecisionAction,
36
+ decision: PulseDecision,
37
+ ) => void | DecisionHandlerResult | Promise<void | DecisionHandlerResult>;
38
+
39
+ export interface PulseDecisionClientOptions {
40
+ apiUrl: string;
41
+ appSlug: string;
42
+ getContext: () => DecisionContext | Promise<DecisionContext>;
43
+ fetch?: typeof globalThis.fetch;
44
+ timeoutMs?: number;
45
+ /** Safe default. User-facing execution needs an explicit per-app opt-in. */
46
+ executionMode?: 'shadow-only' | 'allow-live';
47
+ handlers?: Record<string, DecisionHandler>;
48
+ onError?: (error: unknown) => void;
49
+ }
50
+
51
+ export interface DecideOptions {
52
+ channel?: DecisionChannel;
53
+ idempotencyKey?: string;
54
+ attributes?: Record<string, string>;
55
+ }
56
+
57
+ export interface PulseDecisionClient {
58
+ decide(trigger: string, options?: DecideOptions): Promise<PulseDecision | null>;
59
+ reportOutcome(decisionId: string, outcome: DecisionOutcome, metadata?: Record<string, unknown>): Promise<boolean>;
60
+ }
61
+
62
+ /** Generic decision protocol. Product-specific action handlers remain in each app. */
63
+ export function createPulseDecisionClient(options: PulseDecisionClientOptions): PulseDecisionClient {
64
+ const fetcher = options.fetch ?? globalThis.fetch;
65
+ const base = options.apiUrl.replace(/\/+$/, '');
66
+ const slug = encodeURIComponent(options.appSlug);
67
+ const timeoutMs = Math.max(250, options.timeoutMs ?? 3_000);
68
+ const executionMode = options.executionMode ?? 'shadow-only';
69
+
70
+ const reportOutcome = async (
71
+ decisionId: string,
72
+ outcome: DecisionOutcome,
73
+ metadata?: Record<string, unknown>,
74
+ ): Promise<boolean> => {
75
+ try {
76
+ const response = await timedFetch(fetcher,
77
+ `${base}/pulse/decisions/${slug}/${encodeURIComponent(decisionId)}/outcome`, {
78
+ method: 'POST',
79
+ headers: { 'Content-Type': 'application/json' },
80
+ body: JSON.stringify({ outcome, ...(metadata ? { metadata } : {}) }),
81
+ }, timeoutMs);
82
+ return response.ok;
83
+ } catch (error) {
84
+ options.onError?.(error);
85
+ return false;
86
+ }
87
+ };
88
+
89
+ const decide = async (trigger: string, request: DecideOptions = {}): Promise<PulseDecision | null> => {
90
+ try {
91
+ const context = await options.getContext();
92
+ const response = await timedFetch(fetcher, `${base}/pulse/decisions/${slug}`, {
93
+ method: 'POST',
94
+ headers: { 'Content-Type': 'application/json' },
95
+ body: JSON.stringify({
96
+ ...context,
97
+ attributes: { ...(context.attributes ?? {}), ...(request.attributes ?? {}) },
98
+ trigger,
99
+ channel: request.channel ?? 'in_app',
100
+ idempotencyKey: request.idempotencyKey ?? randomKey(),
101
+ }),
102
+ }, timeoutMs);
103
+ if (!response.ok) throw new Error(`Pulse decision HTTP ${response.status}`);
104
+
105
+ const decision = parseDecision(await response.json());
106
+ if (!decision.execute) return decision;
107
+ if (executionMode !== 'allow-live') {
108
+ void reportOutcome(decision.decisionId, 'failed', { reason: 'client_executor_not_armed' });
109
+ return decision;
110
+ }
111
+
112
+ const action = decision.action;
113
+ const handler = action?.kind ? options.handlers?.[action.kind] : undefined;
114
+ if (!action || !handler) {
115
+ void reportOutcome(decision.decisionId, 'failed', {
116
+ reason: 'unknown_action_kind',
117
+ kind: action?.kind ?? null,
118
+ });
119
+ return decision;
120
+ }
121
+
122
+ try {
123
+ const result = await handler(action, decision);
124
+ void reportOutcome(decision.decisionId, result?.outcome ?? 'accepted', result?.metadata);
125
+ } catch (error) {
126
+ options.onError?.(error);
127
+ void reportOutcome(decision.decisionId, 'failed', { reason: 'handler_failed' });
128
+ }
129
+ return decision;
130
+ } catch (error) {
131
+ options.onError?.(error);
132
+ return null;
133
+ }
134
+ };
135
+
136
+ return { decide, reportOutcome };
137
+ }
138
+
139
+ function parseDecision(raw: unknown): PulseDecision {
140
+ if (!raw || typeof raw !== 'object') throw new Error('Malformed Pulse decision');
141
+ const value = raw as Record<string, unknown>;
142
+ if (typeof value.decisionId !== 'string' || typeof value.execute !== 'boolean' ||
143
+ typeof value.mode !== 'string' || typeof value.verdict !== 'string' ||
144
+ typeof value.reasonCode !== 'string') throw new Error('Malformed Pulse decision');
145
+ const action = value.action;
146
+ if (action != null && (typeof action !== 'object' ||
147
+ typeof (action as Record<string, unknown>).kind !== 'string')) {
148
+ throw new Error('Malformed Pulse action');
149
+ }
150
+ return {
151
+ decisionId: value.decisionId,
152
+ replayed: value.replayed === true,
153
+ execute: value.execute,
154
+ mode: value.mode,
155
+ verdict: value.verdict,
156
+ reasonCode: value.reasonCode,
157
+ policyKey: typeof value.policyKey === 'string' ? value.policyKey : null,
158
+ policyVersion: typeof value.policyVersion === 'number' ? value.policyVersion : null,
159
+ action: (action as DecisionAction | null | undefined) ?? null,
160
+ };
161
+ }
162
+
163
+ async function timedFetch(
164
+ fetcher: typeof globalThis.fetch,
165
+ url: string,
166
+ init: RequestInit,
167
+ timeoutMs: number,
168
+ ): Promise<Response> {
169
+ const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
170
+ const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
171
+ try { return await fetcher(url, { ...init, signal: controller?.signal }); }
172
+ finally { if (timer) clearTimeout(timer); }
173
+ }
174
+
175
+ function randomKey(): string {
176
+ const cryptoLike = globalThis.crypto as { randomUUID?: () => string } | undefined;
177
+ if (typeof cryptoLike?.randomUUID === 'function') return cryptoLike.randomUUID();
178
+ return `moment-${Date.now()}-${Math.random().toString(36).slice(2, 14)}`;
179
+ }
package/src/index.ts CHANGED
@@ -5,3 +5,4 @@ export { initializeAssetResolver, updateLocalAssets } from './assetResolver';
5
5
  export * from './config';
6
6
  export * from './track';
7
7
  export * from './init';
8
+ export * from './decisions';
package/src/init.ts CHANGED
@@ -30,8 +30,21 @@ import {
30
30
  type ConfigContext,
31
31
  type ConfigStorage,
32
32
  type ConfigValue,
33
+ type ConfigSignature,
34
+ getConfigInfo,
35
+ getConfigBoolean,
36
+ getConfigNumber,
37
+ getConfigString,
38
+ getConfigJson,
33
39
  } from './config';
34
- import { configureTracking } from './track';
40
+ import {
41
+ configureTracking,
42
+ disposeTracking,
43
+ flushEvents,
44
+ getTrackingInfo,
45
+ track,
46
+ type TrackedEventInput,
47
+ } from './track';
35
48
 
36
49
  export interface InitPulseOptions {
37
50
  /** Where Pulse lives, e.g. https://pulse.example.com — no path. */
@@ -53,6 +66,9 @@ export interface InitPulseOptions {
53
66
  /** The store build, e.g. "5.2.0". Needed to target a rollout at a version. */
54
67
  appVersion?: string;
55
68
 
69
+ /** Locale override. When omitted the installed RN locale adapter is detected. */
70
+ language?: string;
71
+
56
72
  /**
57
73
  * A stable id for this install. Left out, one is minted and persisted.
58
74
  *
@@ -68,6 +84,19 @@ export interface InitPulseOptions {
68
84
  /** Attributes a rule can target — a plan, a storefront. Keep them few and stable. */
69
85
  getUserAttributes?: () => Record<string, string> | undefined;
70
86
 
87
+ /** Initial analytics consent or a live consent reader. Defaults to granted for compatibility. */
88
+ analyticsConsent?: boolean | (() => boolean);
89
+
90
+ /** Privacy guardrails applied to every track() call. */
91
+ eventPropertyAllowlist?: readonly string[];
92
+ redactEventProperties?: readonly string[];
93
+
94
+ /** Signed config uses the same Ed25519 public key as OTA updates. */
95
+ signingPublicKey?: string;
96
+ signingKeyId?: string;
97
+ requireConfigSignature?: boolean;
98
+ verifyConfigSignature?: (canonicalPayload: string, signature: ConfigSignature) => boolean | Promise<boolean>;
99
+
71
100
  /** Foreground poll interval for the config. 0 disables it; launch and resume still fetch. */
72
101
  pollIntervalMs?: number;
73
102
 
@@ -85,39 +114,108 @@ export interface InitPulseOptions {
85
114
 
86
115
  const DEVICE_ID_KEY = 'pulse.device-id';
87
116
 
117
+ export interface PulseHealth {
118
+ appSlug: string;
119
+ deviceId: string;
120
+ config: ReturnType<typeof getConfigInfo>;
121
+ events: ReturnType<typeof getTrackingInfo>;
122
+ analyticsConsent: boolean;
123
+ }
124
+
125
+ export interface PulseClient {
126
+ deviceId: string;
127
+ configUrl: string;
128
+ trackUrl: string;
129
+ track: (event: string, props?: TrackedEventInput['props'], time?: Date) => void;
130
+ flush: () => Promise<number>;
131
+ setUser: (userId?: string, attributes?: Record<string, string>) => void;
132
+ clearUser: () => void;
133
+ setAnalyticsConsent: (granted: boolean) => void;
134
+ resetInstallId: () => string;
135
+ health: () => PulseHealth;
136
+ config: {
137
+ boolean: typeof getConfigBoolean;
138
+ number: typeof getConfigNumber;
139
+ string: typeof getConfigString;
140
+ json: typeof getConfigJson;
141
+ refresh: typeof fetchConfig;
142
+ };
143
+ dispose: () => Promise<void>;
144
+ }
145
+
146
+ export interface AsyncPulseStorage {
147
+ getItem(key: string): Promise<string | null>;
148
+ setItem(key: string, value: string): Promise<void>;
149
+ }
150
+
151
+ let activeClient: PulseClient | null = null;
152
+ let stopActiveRefresh: (() => void) | null = null;
153
+
88
154
  /** Wires config and events, and returns the context the two share. */
89
- export function initPulse(opts: InitPulseOptions): { deviceId: string; configUrl: string; trackUrl: string } {
155
+ export function initPulse(opts: InitPulseOptions): PulseClient {
156
+ // Reconfiguration is synchronous. Calling the old async dispose without awaiting
157
+ // it lets its eventual flush tear down the brand-new tracker — exactly the race a
158
+ // fast app switch or test setup triggers. Persist the old outbox, stop its refresh,
159
+ // then configure the new app in one uninterrupted turn.
160
+ stopActiveRefresh?.();
161
+ disposeTracking();
162
+ activeClient = null;
163
+ stopActiveRefresh = null;
90
164
  const base = opts.apiUrl.replace(/\/+$/, '');
91
165
  const slug = opts.appSlug.trim();
92
166
  const configUrl = `${base}/pulse/config/${slug}`;
93
167
  const trackUrl = `${base}/pulse/track/${slug}`;
94
168
 
95
- const deviceId = opts.deviceId?.trim() || resolveDeviceId(opts.storage);
169
+ const deviceStorageKey = `pulse.${slug}.device-id`;
170
+ let deviceId = opts.deviceId?.trim() || resolveDeviceId(opts.storage, deviceStorageKey);
96
171
  const platform = detectPlatform();
97
172
  const osVersion = detectOsVersion();
173
+ const appVersion = opts.appVersion ?? detectAppVersion();
174
+ const language = opts.language ?? detectLanguage();
175
+ let userId: string | undefined;
176
+ let userAttributes: Record<string, string> | undefined;
177
+ let consentOverride: boolean | undefined = typeof opts.analyticsConsent === 'boolean'
178
+ ? opts.analyticsConsent
179
+ : undefined;
180
+
181
+ const hasConsent = (): boolean => consentOverride ??
182
+ (typeof opts.analyticsConsent === 'function' ? opts.analyticsConsent() : true);
98
183
 
99
184
  const getContext = (): ConfigContext => ({
100
185
  platform,
101
186
  osVersion,
102
- appVersion: opts.appVersion,
187
+ appVersion,
188
+ language,
103
189
  deviceId,
104
- userId: opts.getUserId?.(),
105
- userAttributes: opts.getUserAttributes?.(),
190
+ userId: userId ?? opts.getUserId?.(),
191
+ userAttributes: userAttributes ?? opts.getUserAttributes?.(),
106
192
  });
107
193
 
108
194
  configureConfig({
109
195
  url: configUrl,
110
196
  defaults: opts.defaults,
111
197
  storage: opts.storage,
198
+ storageKey: `pulse.${slug}.config.v1`,
112
199
  getContext,
113
200
  pollIntervalMs: opts.pollIntervalMs,
114
201
  onError: opts.onError,
202
+ signingPublicKey: opts.signingPublicKey,
203
+ signingKeyId: opts.signingKeyId,
204
+ requireSignature: opts.requireConfigSignature,
205
+ verifySignature: opts.verifyConfigSignature ??
206
+ (opts.requireConfigSignature && !opts.signingPublicKey ? verifyWithNativeKey : undefined),
115
207
  });
116
208
 
117
209
  configureTracking({
118
210
  url: trackUrl,
119
211
  getContext,
120
212
  flushIntervalMs: opts.flushIntervalMs,
213
+ storage: opts.storage,
214
+ storageKey: `pulse.${slug}.events.v1`,
215
+ hasConsent,
216
+ propertyAllowlist: opts.eventPropertyAllowlist,
217
+ redactProperties: opts.redactEventProperties,
218
+ appState: opts.appState,
121
219
  onIgnored: opts.onIgnoredEvents,
122
220
  onError: opts.onError,
123
221
  });
@@ -127,7 +225,7 @@ export function initPulse(opts: InitPulseOptions): { deviceId: string; configUrl
127
225
  // simply does not match this install — which reads as "the rollout did nothing".
128
226
  // Without storage the id is new on every launch, so the arm is re-dealt each time
129
227
  // and no experiment can mean anything.
130
- if (!opts.appVersion) {
228
+ if (!appVersion) {
131
229
  opts.onError?.(new Error(
132
230
  'Pulse: no appVersion given — version targeting and version slices will not match this install'));
133
231
  }
@@ -140,9 +238,77 @@ export function initPulse(opts: InitPulseOptions): { deviceId: string; configUrl
140
238
  // the first response is the launch that runs on defaults, and defaults are the
141
239
  // off position of every flag.
142
240
  void fetchConfig();
143
- startConfigAutoRefresh(opts.appState);
241
+ const stopRefresh = startConfigAutoRefresh(opts.appState);
242
+
243
+ const client: PulseClient = {
244
+ get deviceId() { return deviceId; },
245
+ configUrl,
246
+ trackUrl,
247
+ track,
248
+ flush: flushEvents,
249
+ setUser: (nextUserId, attributes) => {
250
+ userId = nextUserId?.trim() || undefined;
251
+ userAttributes = sanitizeAttributes(attributes);
252
+ },
253
+ clearUser: () => {
254
+ userId = undefined;
255
+ userAttributes = undefined;
256
+ },
257
+ setAnalyticsConsent: (granted) => { consentOverride = granted; },
258
+ resetInstallId: () => {
259
+ deviceId = randomId();
260
+ try { opts.storage?.set(deviceStorageKey, deviceId); } catch { /* memory-only fallback */ }
261
+ return deviceId;
262
+ },
263
+ health: () => ({ appSlug: slug, deviceId, config: getConfigInfo(), events: getTrackingInfo(), analyticsConsent: hasConsent() }),
264
+ config: {
265
+ boolean: getConfigBoolean,
266
+ number: getConfigNumber,
267
+ string: getConfigString,
268
+ json: getConfigJson,
269
+ refresh: fetchConfig,
270
+ },
271
+ dispose: async () => {
272
+ stopRefresh();
273
+ if (activeClient !== client) return;
274
+ await flushEvents();
275
+ // Another init may have taken ownership while this network flush was pending.
276
+ if (activeClient === client) {
277
+ disposeTracking();
278
+ activeClient = null;
279
+ stopActiveRefresh = null;
280
+ }
281
+ },
282
+ };
283
+ activeClient = client;
284
+ stopActiveRefresh = stopRefresh;
285
+ return client;
286
+ }
144
287
 
145
- return { deviceId, configUrl, trackUrl };
288
+ /**
289
+ * AsyncStorage-friendly one-call setup. It preloads only Pulse's three app-scoped
290
+ * records, then exposes the same synchronous hot-path API as initPulse.
291
+ */
292
+ export async function initPulseAsync(
293
+ opts: Omit<InitPulseOptions, 'storage'> & { storage: AsyncPulseStorage },
294
+ ): Promise<PulseClient> {
295
+ const slug = opts.appSlug.trim();
296
+ const keys = [
297
+ `pulse.${slug}.device-id`,
298
+ `pulse.${slug}.config.v1`,
299
+ `pulse.${slug}.events.v1`,
300
+ DEVICE_ID_KEY,
301
+ ];
302
+ const loaded = await Promise.all(keys.map(async (key) => [key, await opts.storage.getItem(key)] as const));
303
+ const memory = new Map(loaded.filter((entry): entry is readonly [string, string] => entry[1] !== null));
304
+ const storage: ConfigStorage = {
305
+ getString: (key) => memory.get(key),
306
+ set: (key, value) => {
307
+ memory.set(key, value);
308
+ void opts.storage.setItem(key, value);
309
+ },
310
+ };
311
+ return initPulse({ ...opts, storage });
146
312
  }
147
313
 
148
314
  /**
@@ -152,20 +318,28 @@ export function initPulse(opts: InitPulseOptions): { deviceId: string; configUrl
152
318
  * arm is a hash of it, so every launch would be a fresh coin toss and no experiment
153
319
  * could mean anything. Said out loud through onError rather than hidden.
154
320
  */
155
- function resolveDeviceId(storage?: ConfigStorage): string {
321
+ function resolveDeviceId(storage?: ConfigStorage, key = DEVICE_ID_KEY): string {
156
322
  if (!storage) return randomId();
157
323
 
158
324
  try {
159
- const existing = storage.getString(DEVICE_ID_KEY);
325
+ const existing = storage.getString(key) ?? storage.getString(DEVICE_ID_KEY);
160
326
  if (existing) return existing;
161
327
  const minted = randomId();
162
- storage.set(DEVICE_ID_KEY, minted);
328
+ storage.set(key, minted);
163
329
  return minted;
164
330
  } catch {
165
331
  return randomId();
166
332
  }
167
333
  }
168
334
 
335
+ function sanitizeAttributes(attributes?: Record<string, string>): Record<string, string> | undefined {
336
+ if (!attributes) return undefined;
337
+ const clean = Object.fromEntries(Object.entries(attributes)
338
+ .filter(([key, value]) => key.trim() !== '' && typeof value === 'string')
339
+ .slice(0, 50));
340
+ return Object.keys(clean).length > 0 ? clean : undefined;
341
+ }
342
+
169
343
  function randomId(): string {
170
344
  const bytes = new Uint8Array(16);
171
345
  const crypto = (globalThis as { crypto?: { getRandomValues?: (a: Uint8Array) => void } }).crypto;
@@ -208,3 +382,44 @@ function detectOsVersion(): string | undefined {
208
382
  return undefined;
209
383
  }
210
384
  }
385
+
386
+ function detectAppVersion(): string | undefined {
387
+ try {
388
+ // Optional by design: most RN apps already carry this package, plain JS hosts do not.
389
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
390
+ const loaded = require('react-native-device-info') as { default?: { getVersion?: () => string }; getVersion?: () => string };
391
+ return loaded.default?.getVersion?.() ?? loaded.getVersion?.();
392
+ } catch {
393
+ return undefined;
394
+ }
395
+ }
396
+
397
+ async function verifyWithNativeKey(
398
+ canonicalPayload: string,
399
+ signature: ConfigSignature,
400
+ ): Promise<boolean> {
401
+ try {
402
+ // Kept lazy so init/config remain usable in Node, web and test hosts without RN.
403
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
404
+ const updates = require('./PulseUpdates') as {
405
+ verifyConfigSignatureAsync?: (payload: string, value: ConfigSignature) => Promise<boolean>;
406
+ };
407
+ return await updates.verifyConfigSignatureAsync?.(canonicalPayload, signature) ?? false;
408
+ } catch {
409
+ return false;
410
+ }
411
+ }
412
+
413
+ function detectLanguage(): string | undefined {
414
+ try {
415
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
416
+ const localize = require('react-native-localize') as { getLocales?: () => Array<{ languageTag?: string }> };
417
+ return localize.getLocales?.()[0]?.languageTag;
418
+ } catch {
419
+ try {
420
+ return Intl.DateTimeFormat().resolvedOptions().locale;
421
+ } catch {
422
+ return undefined;
423
+ }
424
+ }
425
+ }