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
@@ -1086,10 +1086,19 @@ async function publish(options) {
1086
1086
  */
1087
1087
  async function registerCapabilities(options) {
1088
1088
  const config = loadConfig(options);
1089
- const bundlePath = options['bundle'] || options['embedded-bundle'];
1090
- if (!bundlePath) {
1091
- throw new Error('Provide --bundle <path to the embedded JS bundle> (the bundle built into the app binary).');
1089
+ if (!config.platform || !['ios', 'android'].includes(config.platform)) {
1090
+ throw new Error('Platform is required. Pass --platform ios or --platform android.');
1092
1091
  }
1092
+ if (!config.runtimeVersion) {
1093
+ throw new Error('Runtime version is required. Pass --runtime-version or configure it in the native project.');
1094
+ }
1095
+ if (!config.apiUrl) {
1096
+ throw new Error('API URL is required. Set --api-url, PULSE_API_URL, or the native PulseUpdatesURL.');
1097
+ }
1098
+ if (!config.apiKey) {
1099
+ throw new Error('API key is required. Set --api-key, PULSE_API_KEY, or pulse.config.json.');
1100
+ }
1101
+ const bundlePath = resolveCapabilitiesBundle(options, config);
1093
1102
  const nativeModules = extractNativeModules(bundlePath);
1094
1103
  log(`Registering ${nativeModules.length} native module(s) for ${config.runtimeVersion}/${config.platform}`);
1095
1104
  const res = await fetch(`${config.apiUrl}/api/capabilities`, {
@@ -1107,6 +1116,27 @@ async function registerCapabilities(options) {
1107
1116
  logSuccess(`Capabilities registered (${nativeModules.length} native modules)`);
1108
1117
  }
1109
1118
 
1119
+ /**
1120
+ * Resolve the embedded bundle used for the capability snapshot. A release pipeline
1121
+ * can pass the exact bundle it just put in the binary; local and CI adoption can use
1122
+ * the zero-config path, which builds the same production Metro graph first.
1123
+ */
1124
+ export function resolveCapabilitiesBundle(options, config, bundleFactory = createBundle) {
1125
+ const explicit = options.bundle || options['embedded-bundle'];
1126
+ if (explicit) {
1127
+ const resolved = path.resolve(explicit);
1128
+ if (!fs.existsSync(resolved)) throw new Error(`Capability bundle not found: ${resolved}`);
1129
+ return resolved;
1130
+ }
1131
+
1132
+ logStep('1/2', `Building the ${config.platform} production bundle for capability discovery...`);
1133
+ return bundleFactory(
1134
+ config.platform,
1135
+ path.resolve(config.bundleDir || './dist'),
1136
+ config.entryFile || 'index.ts'
1137
+ );
1138
+ }
1139
+
1110
1140
 
1111
1141
  /**
1112
1142
  * The experiments an app is running, and how their split is holding.
@@ -1293,6 +1323,34 @@ ${colors.cyan}App${colors.reset} (PUBLIC — set in Info.plist / AndroidManifest
1293
1323
  `);
1294
1324
  }
1295
1325
 
1326
+ /** Generate small app-local contracts from a reviewable schema file. */
1327
+ export function generateContracts(options) {
1328
+ const schemaPath = path.resolve(options.schema || 'pulse.schema.json');
1329
+ const outputPath = path.resolve(options.output || 'src/pulse.generated.ts');
1330
+ if (!fs.existsSync(schemaPath)) {
1331
+ throw new Error(`Schema not found: ${schemaPath}. Create pulse.schema.json or pass --schema.`);
1332
+ }
1333
+ const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf8'));
1334
+ const scalar = (value) => ({ boolean: 'boolean', number: 'number', string: 'string', json: 'unknown' }[value] || 'unknown');
1335
+ const objectType = (shape = {}) => `{ ${Object.entries(shape).map(([key, value]) => `${JSON.stringify(key)}?: ${scalar(value)}`).join('; ')} }`;
1336
+ const configEntries = Object.entries(schema.config || {});
1337
+ const eventEntries = Object.entries(schema.events || {});
1338
+ const actionEntries = Object.entries(schema.actions || {});
1339
+ const output = `/* Generated by pulse-updates generate. Do not edit by hand. */\n` +
1340
+ `import type { TrackedEventInput, DecisionAction } from 'pulse-updates';\n\n` +
1341
+ `export interface PulseConfigSchema {\n${configEntries.map(([key, value]) => ` ${JSON.stringify(key)}: ${scalar(value)};`).join('\n')}\n}\n\n` +
1342
+ `export interface PulseEventSchema {\n${eventEntries.map(([key, value]) => ` ${JSON.stringify(key)}: ${objectType(value)};`).join('\n')}\n}\n\n` +
1343
+ `export type PulseEventKey = keyof PulseEventSchema;\n` +
1344
+ `export type PulseTypedEvent<K extends PulseEventKey> = { event: K; props: PulseEventSchema[K]; time?: Date };\n\n` +
1345
+ `export type PulseAction = ${actionEntries.length > 0
1346
+ ? actionEntries.map(([key, value]) => `(DecisionAction & { kind: ${JSON.stringify(key)} } & ${objectType(value)})`).join(' | ')
1347
+ : 'DecisionAction'};\n` +
1348
+ `export type PulseTrack = <K extends PulseEventKey>(event: K, props?: PulseEventSchema[K], time?: TrackedEventInput['time']) => void;\n`;
1349
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
1350
+ fs.writeFileSync(outputPath, output);
1351
+ logSuccess(`Generated ${path.relative(process.cwd(), outputPath)} from ${path.relative(process.cwd(), schemaPath)}`);
1352
+ }
1353
+
1296
1354
  /**
1297
1355
  * Show help
1298
1356
  */
@@ -1307,6 +1365,7 @@ ${colors.cyan}Commands:${colors.reset}
1307
1365
  publish Create bundle and publish update to server
1308
1366
  keygen Generate an Ed25519 signing keypair (server + app config)
1309
1367
  register-capabilities Record the embedded bundle's native capability set (crash-prediction)
1368
+ generate Generate typed config/event/action contracts from pulse.schema.json
1310
1369
 
1311
1370
  ${colors.cyan}Options:${colors.reset}
1312
1371
  --api-url <url> API server URL (auto-detected from native config)
@@ -1315,6 +1374,7 @@ ${colors.cyan}Options:${colors.reset}
1315
1374
  --runtime-version <v> Runtime version (auto-detected from native config)
1316
1375
  --channel <channel> Release channel (default: production)
1317
1376
  --bundle-dir <dir> Output directory (default: ./dist)
1377
+ --bundle <file> Embedded bundle for register-capabilities (otherwise built automatically)
1318
1378
  --entry-file <file> Entry file (default: index.ts)
1319
1379
  --skip-bundle Skip bundle creation (use existing)
1320
1380
  --dry-run Build + validate locally without creating a release on the server
@@ -1326,6 +1386,8 @@ ${colors.cyan}Options:${colors.reset}
1326
1386
  --build <number> Build number (shown in version, e.g., 1.0.0.42)
1327
1387
  --message <msg> Release message/notes
1328
1388
  --key-id <id> Key id for keygen (default: auto-generated)
1389
+ --schema <file> Contract schema for generate (default: pulse.schema.json)
1390
+ --output <file> Generated TypeScript file (default: src/pulse.generated.ts)
1329
1391
 
1330
1392
  ${colors.cyan}Experiments:${colors.reset}
1331
1393
  pulse-updates experiments List them, with the split each one got
@@ -1388,6 +1450,9 @@ async function main() {
1388
1450
  case 'keygen':
1389
1451
  keygen(options);
1390
1452
  break;
1453
+ case 'generate':
1454
+ generateContracts(options);
1455
+ break;
1391
1456
  case 'help':
1392
1457
  case '--help':
1393
1458
  case '-h':
@@ -43,6 +43,7 @@ export interface Spec extends TurboModule {
43
43
 
44
44
  markAppReady(): Promise<void>;
45
45
  reportLaunchFailure(reason: string): Promise<void>;
46
+ verifySignatureAsync(canonicalPayload: string, alg: string, keyId: string, signature: string): Promise<boolean>;
46
47
 
47
48
  // Event emitter
48
49
  addListener(eventName: string): void;
@@ -16,6 +16,42 @@ let _isEmbeddedLaunch = true;
16
16
  let _manifest: PulseManifest | null = null;
17
17
  let _createdAt: Date | null = null;
18
18
  let _localAssets: Record<string, string> | null = null;
19
+ const stateListeners = new Set<() => void>();
20
+
21
+ export interface PulseNativeState {
22
+ isEnabled: boolean;
23
+ updateId: string | null;
24
+ runtimeVersion: string | null;
25
+ channel: string | null;
26
+ isEmbeddedLaunch: boolean;
27
+ manifest: PulseManifest | null;
28
+ createdAt: Date | null;
29
+ localAssets: Record<string, string> | null;
30
+ }
31
+
32
+ /** A stable read surface for React and for diagnostics. */
33
+ export function getUpdatesState(): PulseNativeState {
34
+ return {
35
+ isEnabled: _isEnabled,
36
+ updateId: _updateId,
37
+ runtimeVersion: _runtimeVersion,
38
+ channel: _channel,
39
+ isEmbeddedLaunch: _isEmbeddedLaunch,
40
+ manifest: _manifest,
41
+ createdAt: _createdAt,
42
+ localAssets: _localAssets,
43
+ };
44
+ }
45
+
46
+ /** Subscribe to native-state refreshes without depending on mutable ES exports. */
47
+ export function onUpdatesStateChange(listener: () => void): () => void {
48
+ stateListeners.add(listener);
49
+ return () => stateListeners.delete(listener);
50
+ }
51
+
52
+ function notifyStateListeners(): void {
53
+ for (const listener of stateListeners) listener();
54
+ }
19
55
 
20
56
  /**
21
57
  * Configure pulse-updates with the given options.
@@ -51,6 +87,9 @@ export function configure(config: PulseUpdatesConfig): void {
51
87
  _isConfigured = true;
52
88
  _isEnabled = config.enabled;
53
89
  _runtimeVersion = config.runtimeVersion;
90
+ isEnabled = _isEnabled;
91
+ runtimeVersion = _runtimeVersion;
92
+ notifyStateListeners();
54
93
  }
55
94
 
56
95
  /**
@@ -133,6 +172,7 @@ export async function refreshStateAsync(): Promise<void> {
133
172
  // Read localAssets map for asset resolution
134
173
  _localAssets = state.localAssets ?? null;
135
174
  localAssets = _localAssets;
175
+ notifyStateListeners();
136
176
  } catch (error) {
137
177
  console.warn('[PulseUpdates] Failed to refresh state:', error);
138
178
  // Keep current values on error
@@ -241,6 +281,20 @@ export async function reportLaunchFailure(reason: string): Promise<void> {
241
281
  await PulseUpdatesModule.reportLaunchFailure(reason);
242
282
  }
243
283
 
284
+ /** Verify config/decision payloads with the public key already pinned in native config. */
285
+ export async function verifyConfigSignatureAsync(
286
+ canonicalPayload: string,
287
+ signature: { alg: string; keyId: string; sig: string },
288
+ ): Promise<boolean> {
289
+ if (!PulseUpdatesModule?.verifySignatureAsync) return false;
290
+ return PulseUpdatesModule.verifySignatureAsync(
291
+ canonicalPayload,
292
+ signature.alg,
293
+ signature.keyId,
294
+ signature.sig,
295
+ );
296
+ }
297
+
244
298
  /**
245
299
  * Get the build number from the current update's metadata.
246
300
  */
package/src/config.ts CHANGED
@@ -20,6 +20,8 @@
20
20
  * config costs a 304 with no body.
21
21
  */
22
22
 
23
+ import nacl from 'tweetnacl';
24
+
23
25
  export type ConfigValue = boolean | number | string | null | object;
24
26
 
25
27
  /**
@@ -35,6 +37,16 @@ export interface ConfigExperiment {
35
37
  variant: string;
36
38
  }
37
39
 
40
+ export interface ConfigSignature {
41
+ alg: string;
42
+ keyId: string;
43
+ sig: string;
44
+ /** Exact canonical response bytes covered by the signature, when supplied by PulseServer. */
45
+ canonical?: string;
46
+ }
47
+
48
+ export type ConfigSignatureStatus = 'not-configured' | 'verified' | 'cached-verified' | 'unsigned' | 'invalid';
49
+
38
50
  export interface ConfigStorage {
39
51
  getString(key: string): string | null | undefined;
40
52
  set(key: string, value: string): void;
@@ -71,6 +83,8 @@ export interface ConfigOptions {
71
83
  defaults?: Record<string, ConfigValue>;
72
84
  /** Persistence for the last good payload. Without it the cache is memory-only. */
73
85
  storage?: ConfigStorage;
86
+ /** App-scoped key for multi-app/white-label hosts. */
87
+ storageKey?: string;
74
88
  /** Read fresh on every request: the country or plan can change between launches. */
75
89
  getContext?: () => ConfigContext;
76
90
  /** Foreground poll interval. 0 disables polling (launch + resume still fetch). */
@@ -101,6 +115,14 @@ export interface ConfigOptions {
101
115
  reportExposure?: boolean;
102
116
  /** Network timeout per request. */
103
117
  timeoutMs?: number;
118
+ /** Ed25519 public key, as raw 32-byte base64. Enables built-in verification. */
119
+ signingPublicKey?: string;
120
+ /** Optional key-id pin. A response signed by another key is rejected. */
121
+ signingKeyId?: string;
122
+ /** Reject unsigned/unverifiable payloads and keep the last good snapshot. */
123
+ requireSignature?: boolean;
124
+ /** Custom verifier for hosts that keep keys in a secure native module. */
125
+ verifySignature?: (canonicalPayload: string, signature: ConfigSignature) => boolean | Promise<boolean>;
104
126
  onError?: (error: unknown) => void;
105
127
  }
106
128
 
@@ -110,6 +132,8 @@ interface CachedPayload {
110
132
  experiments?: ConfigExperiment[];
111
133
  etag: string | null;
112
134
  fetchedAt: number;
135
+ signatureVerified?: boolean;
136
+ signatureKeyId?: string | null;
113
137
  }
114
138
 
115
139
  /**
@@ -148,11 +172,14 @@ let fetchedAt = 0;
148
172
  */
149
173
  let verifiedAt = 0;
150
174
  let source: 'defaults' | 'cache' | 'remote' = 'defaults';
175
+ let signatureStatus: ConfigSignatureStatus = 'not-configured';
176
+ let signatureKeyId: string | null = null;
151
177
  let pending: ConfigSnapshot | null = null;
152
178
  /** The arm set already reported this session, so a poll does not re-report it. */
153
179
  let reportedExposure: string | null = null;
154
180
  let inFlight: Promise<boolean> | null = null;
155
181
  let pollTimer: ReturnType<typeof setInterval> | null = null;
182
+ let configGeneration = 0;
156
183
  const listeners = new Set<(values: Record<string, ConfigValue>) => void>();
157
184
 
158
185
  /**
@@ -161,17 +188,26 @@ const listeners = new Set<(values: Record<string, ConfigValue>) => void>();
161
188
  * while the network call is in flight.
162
189
  */
163
190
  export function configureConfig(opts: ConfigOptions): void {
191
+ // A host can switch app/tenant without restarting the JS runtime. Nothing from the
192
+ // previous app — values, ETag, pending activation or an eventual network response —
193
+ // may cross that boundary.
194
+ configGeneration += 1;
195
+ stopPolling();
164
196
  options = opts;
165
197
  defaults = { ...(opts.defaults ?? {}) };
198
+ values = {};
199
+ experiments = [];
200
+ etag = null;
201
+ fetchedAt = 0;
202
+ verifiedAt = 0;
203
+ source = 'defaults';
204
+ signatureStatus = 'not-configured';
205
+ signatureKeyId = null;
206
+ pending = null;
207
+ reportedExposure = null;
208
+ inFlight = null;
166
209
 
167
- const cached = readCache(opts.storage);
168
- if (cached) {
169
- values = cached.values;
170
- experiments = cached.experiments ?? [];
171
- etag = cached.etag;
172
- fetchedAt = cached.fetchedAt;
173
- source = 'cache';
174
- }
210
+ applyCachedPayload(readCache(opts.storage, opts.storageKey), opts);
175
211
  }
176
212
 
177
213
  /** Merge more defaults after configure (a late-loading module registering its own). */
@@ -196,7 +232,8 @@ export async function fetchConfig(): Promise<boolean> {
196
232
  return false;
197
233
  }
198
234
 
199
- inFlight = (async () => {
235
+ const requestGeneration = configGeneration;
236
+ const request = (async () => {
200
237
  const opts = options!;
201
238
  const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
202
239
  const timer = controller
@@ -210,6 +247,8 @@ export async function fetchConfig(): Promise<boolean> {
210
247
  signal: controller?.signal,
211
248
  });
212
249
 
250
+ if (requestGeneration !== configGeneration || options !== opts) return false;
251
+
213
252
  // Nothing changed — which is a positive answer about freshness, not a
214
253
  // non-event: the values we hold are the ones the server would send.
215
254
  if (response.status === 304) {
@@ -218,8 +257,11 @@ export async function fetchConfig(): Promise<boolean> {
218
257
  }
219
258
  if (!response.ok) throw new Error(`Pulse config HTTP ${response.status}`);
220
259
 
221
- const payload = await response.json();
222
- const nextValues = payload?.values;
260
+ const payload: unknown = await response.json();
261
+ await verifyConfigPayload(payload, opts, requestGeneration);
262
+ if (requestGeneration !== configGeneration || options !== opts) return false;
263
+ const payloadObject = payload as Record<string, unknown>;
264
+ const nextValues = payloadObject.values;
223
265
  // A payload without a values object is a broken response, not "no keys": adopting
224
266
  // it would blank every flag at once.
225
267
  if (!nextValues || typeof nextValues !== 'object' || Array.isArray(nextValues)) {
@@ -232,7 +274,7 @@ export async function fetchConfig(): Promise<boolean> {
232
274
 
233
275
  const snapshot: ConfigSnapshot = {
234
276
  values: nextValues as Record<string, ConfigValue>,
235
- experiments: parseExperiments(payload?.experiments),
277
+ experiments: parseExperiments(payloadObject.experiments),
236
278
  };
237
279
 
238
280
  if (opts.activateOnFetch === false) {
@@ -248,11 +290,12 @@ export async function fetchConfig(): Promise<boolean> {
248
290
  return false;
249
291
  } finally {
250
292
  if (timer) clearTimeout(timer);
251
- inFlight = null;
293
+ if (requestGeneration === configGeneration) inFlight = null;
252
294
  }
253
295
  })();
254
296
 
255
- return inFlight;
297
+ inFlight = request;
298
+ return request;
256
299
  }
257
300
 
258
301
  /**
@@ -305,7 +348,14 @@ function applySnapshot(next: ConfigSnapshot, opts: ConfigOptions): boolean {
305
348
  values = next.values;
306
349
  experiments = next.experiments;
307
350
  source = 'remote';
308
- writeCache(opts.storage, { values, experiments, etag, fetchedAt });
351
+ writeCache(opts.storage, {
352
+ values,
353
+ experiments,
354
+ etag,
355
+ fetchedAt,
356
+ signatureVerified: signatureStatus === 'verified',
357
+ signatureKeyId,
358
+ }, opts.storageKey);
309
359
 
310
360
  // Reported on apply, never on fetch: with activateOnFetch false a payload can sit
311
361
  // unapplied for the rest of a session, and an arm the app is not actually serving
@@ -360,10 +410,137 @@ async function reportExposure(opts: ConfigOptions): Promise<void> {
360
410
  });
361
411
  } catch {
362
412
  // A device that cannot report its arm still has the right arm.
363
- reportedExposure = signature;
413
+ if (options === opts) reportedExposure = signature;
364
414
  }
365
415
  }
366
416
 
417
+ /**
418
+ * Verifies the exact unsigned JSON object the server signs. A configured verifier
419
+ * also rejects a bad optional signature: accepting a payload that claims to be
420
+ * signed but is not valid would make signature stripping safer than tampering.
421
+ */
422
+ async function verifyConfigPayload(
423
+ payload: unknown,
424
+ opts: ConfigOptions,
425
+ requestGeneration: number,
426
+ ): Promise<void> {
427
+ const setSignatureState = (status: ConfigSignatureStatus, keyId: string | null = null) => {
428
+ if (requestGeneration !== configGeneration || options !== opts) return;
429
+ signatureStatus = status;
430
+ signatureKeyId = keyId;
431
+ };
432
+ const verificationConfigured = Boolean(
433
+ opts.requireSignature || opts.signingPublicKey || opts.signingKeyId || opts.verifySignature,
434
+ );
435
+ if (!verificationConfigured) {
436
+ setSignatureState('not-configured');
437
+ return;
438
+ }
439
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
440
+ setSignatureState('invalid');
441
+ throw new Error('Pulse config: malformed signed payload');
442
+ }
443
+
444
+ const record = payload as Record<string, unknown>;
445
+ const rawSignature = record.signature;
446
+ if (!isConfigSignature(rawSignature)) {
447
+ setSignatureState('unsigned');
448
+ if (opts.requireSignature) throw new Error('Pulse config: signature required but missing');
449
+ return;
450
+ }
451
+
452
+ setSignatureState('not-configured', rawSignature.keyId);
453
+ if (rawSignature.alg.toLowerCase() !== 'ed25519' ||
454
+ (opts.signingKeyId && rawSignature.keyId !== opts.signingKeyId)) {
455
+ setSignatureState('invalid', rawSignature.keyId);
456
+ throw new Error('Pulse config: signature algorithm or key id is not trusted');
457
+ }
458
+
459
+ const unsigned = { ...record };
460
+ delete unsigned.signature;
461
+ const locallyCanonical = canonicalizeConfigJson(unsigned);
462
+ let canonical = locallyCanonical;
463
+ if (rawSignature.canonical) {
464
+ try {
465
+ // Compare semantic JSON after parsing, then verify the server's exact bytes.
466
+ // This preserves tamper detection while avoiding 1.0/1 formatting drift
467
+ // between System.Text.Json and JavaScript.
468
+ if (canonicalizeConfigJson(JSON.parse(rawSignature.canonical)) !== locallyCanonical) {
469
+ throw new Error('canonical payload mismatch');
470
+ }
471
+ canonical = rawSignature.canonical;
472
+ } catch {
473
+ setSignatureState('invalid', rawSignature.keyId);
474
+ throw new Error('Pulse config: signed canonical payload does not match response');
475
+ }
476
+ }
477
+ let valid = false;
478
+ if (opts.verifySignature) {
479
+ valid = await opts.verifySignature(canonical, rawSignature);
480
+ } else if (opts.signingPublicKey) {
481
+ try {
482
+ valid = nacl.sign.detached.verify(
483
+ utf8Bytes(canonical),
484
+ decodeBase64(rawSignature.sig),
485
+ decodeBase64(opts.signingPublicKey),
486
+ );
487
+ } catch {
488
+ valid = false;
489
+ }
490
+ } else if (opts.requireSignature) {
491
+ setSignatureState('invalid', rawSignature.keyId);
492
+ throw new Error('Pulse config: signature required but no verifier or public key is configured');
493
+ } else {
494
+ setSignatureState('not-configured', rawSignature.keyId);
495
+ return;
496
+ }
497
+
498
+ if (!valid) {
499
+ setSignatureState('invalid', rawSignature.keyId);
500
+ throw new Error('Pulse config: signature verification failed');
501
+ }
502
+ setSignatureState('verified', rawSignature.keyId);
503
+ }
504
+
505
+ function isConfigSignature(value: unknown): value is ConfigSignature {
506
+ if (!value || typeof value !== 'object') return false;
507
+ const signature = value as Record<string, unknown>;
508
+ return typeof signature.alg === 'string' &&
509
+ typeof signature.keyId === 'string' &&
510
+ typeof signature.sig === 'string' &&
511
+ (signature.canonical === undefined || typeof signature.canonical === 'string');
512
+ }
513
+
514
+ /** Same recursive ordinal-key JSON form used by PulseServer's ManifestSigner. */
515
+ export function canonicalizeConfigJson(value: unknown): string {
516
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
517
+ if (Array.isArray(value)) return `[${value.map(canonicalizeConfigJson).join(',')}]`;
518
+ return `{${Object.keys(value as Record<string, unknown>)
519
+ .sort()
520
+ .map((key) => `${JSON.stringify(key)}:${canonicalizeConfigJson((value as Record<string, unknown>)[key])}`)
521
+ .join(',')}}`;
522
+ }
523
+
524
+ function decodeBase64(value: string): Uint8Array {
525
+ const atobLike = (globalThis as { atob?: (encoded: string) => string }).atob;
526
+ if (atobLike) {
527
+ const decoded = atobLike(value);
528
+ return Uint8Array.from(decoded, (char) => char.charCodeAt(0));
529
+ }
530
+ // Node/older React Native hosts commonly expose Buffer even when atob is absent.
531
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
532
+ const BufferCtor = require('buffer').Buffer as { from: (input: string, encoding: string) => Uint8Array };
533
+ return Uint8Array.from(BufferCtor.from(value, 'base64'));
534
+ }
535
+
536
+ function utf8Bytes(value: string): Uint8Array {
537
+ const Encoder = (globalThis as { TextEncoder?: new () => { encode: (input: string) => Uint8Array } }).TextEncoder;
538
+ if (Encoder) return new Encoder().encode(value);
539
+ // encodeURIComponent is available in every RN JS runtime supported by this package.
540
+ const encoded = unescape(encodeURIComponent(value));
541
+ return Uint8Array.from(encoded, (char) => char.charCodeAt(0));
542
+ }
543
+
367
544
  /**
368
545
  * The arms as the server sent them, keeping only entries that name both halves.
369
546
  *
@@ -507,17 +684,30 @@ export function getConfigInfo(): {
507
684
  verifiedAt: number;
508
685
  etag: string | null;
509
686
  keyCount: number;
687
+ signatureStatus: ConfigSignatureStatus;
688
+ signatureKeyId: string | null;
510
689
  } {
511
- return { source, fetchedAt, verifiedAt, etag, keyCount: Object.keys(getAllConfig()).length };
690
+ return {
691
+ source,
692
+ fetchedAt,
693
+ verifiedAt,
694
+ etag,
695
+ keyCount: Object.keys(getAllConfig()).length,
696
+ signatureStatus,
697
+ signatureKeyId,
698
+ };
512
699
  }
513
700
 
514
701
  /** Test seam: drops every piece of module state. */
515
702
  export function resetConfigForTests(): void {
703
+ configGeneration += 1;
516
704
  options = null;
517
705
  defaults = {};
518
706
  verifiedAt = 0;
519
707
  values = {};
520
708
  experiments = [];
709
+ signatureStatus = 'not-configured';
710
+ signatureKeyId = null;
521
711
  etag = null;
522
712
  fetchedAt = 0;
523
713
  source = 'defaults';
@@ -550,10 +740,19 @@ function buildHeaders(opts: ConfigOptions, currentEtag: string | null): Record<s
550
740
  return headers;
551
741
  }
552
742
 
553
- function readCache(storage?: ConfigStorage): CachedPayload | null {
743
+ function readCache(storage?: ConfigStorage, key?: string): CachedPayload | null {
554
744
  if (!storage) return null;
555
745
  try {
556
- const raw = storage.getString(STORAGE_KEY);
746
+ const raw = storage.getString(key?.trim() || STORAGE_KEY);
747
+ return parseCachedPayload(raw);
748
+ } catch {
749
+ // A corrupt cache is not worth a crash at boot: fall back to defaults.
750
+ return null;
751
+ }
752
+ }
753
+
754
+ function parseCachedPayload(raw: string | null | undefined): CachedPayload | null {
755
+ try {
557
756
  if (!raw) return null;
558
757
  const parsed = JSON.parse(raw) as CachedPayload;
559
758
  if (!parsed || typeof parsed !== 'object' || !parsed.values) return null;
@@ -564,10 +763,24 @@ function readCache(storage?: ConfigStorage): CachedPayload | null {
564
763
  }
565
764
  }
566
765
 
567
- function writeCache(storage: ConfigStorage | undefined, payload: CachedPayload): void {
766
+ function applyCachedPayload(cached: CachedPayload | null, opts: ConfigOptions): void {
767
+ if (!cached || (opts.requireSignature && cached.signatureVerified !== true)) return;
768
+ values = cached.values;
769
+ experiments = cached.experiments ?? [];
770
+ etag = cached.etag;
771
+ fetchedAt = cached.fetchedAt;
772
+ source = 'cache';
773
+ if (cached.signatureVerified) {
774
+ signatureStatus = 'cached-verified';
775
+ signatureKeyId = cached.signatureKeyId ?? null;
776
+ }
777
+ notify();
778
+ }
779
+
780
+ function writeCache(storage: ConfigStorage | undefined, payload: CachedPayload, key?: string): void {
568
781
  if (!storage) return;
569
782
  try {
570
- storage.set(STORAGE_KEY, JSON.stringify(payload));
783
+ void storage.set(key?.trim() || STORAGE_KEY, JSON.stringify(payload));
571
784
  } catch {
572
785
  // Persistence is an optimisation; failing to write must not fail the fetch.
573
786
  }