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.
- package/README.md +50 -10
- package/SECURITY.md +42 -0
- package/android/src/main/java/app/pulse/updates/PulseController.kt +6 -2
- package/android/src/main/java/app/pulse/updates/PulseUpdatesModule.kt +25 -0
- package/app.plugin.js +47 -0
- package/ios/PulseUpdates/PulseController.swift +7 -2
- package/ios/PulseUpdates/PulseUpdates.m +7 -0
- package/ios/PulseUpdates/PulseUpdates.swift +22 -0
- package/lib/commonjs/NativePulseUpdates.js.map +1 -1
- package/lib/commonjs/PulseUpdates.js +36 -0
- package/lib/commonjs/PulseUpdates.js.map +1 -1
- package/lib/commonjs/config.js +175 -21
- package/lib/commonjs/config.js.map +1 -1
- package/lib/commonjs/decisions.js +128 -0
- package/lib/commonjs/decisions.js.map +1 -0
- package/lib/commonjs/index.js +12 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/init.js +153 -13
- package/lib/commonjs/init.js.map +1 -1
- package/lib/commonjs/track.js +154 -12
- package/lib/commonjs/track.js.map +1 -1
- package/lib/commonjs/usePulseUpdates.js +21 -16
- package/lib/commonjs/usePulseUpdates.js.map +1 -1
- package/lib/module/NativePulseUpdates.js.map +1 -1
- package/lib/module/PulseUpdates.js +33 -0
- package/lib/module/PulseUpdates.js.map +1 -1
- package/lib/module/config.js +174 -21
- package/lib/module/config.js.map +1 -1
- package/lib/module/decisions.js +122 -0
- package/lib/module/decisions.js.map +1 -0
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/init.js +154 -15
- package/lib/module/init.js.map +1 -1
- package/lib/module/track.js +152 -12
- package/lib/module/track.js.map +1 -1
- package/lib/module/usePulseUpdates.js +21 -16
- package/lib/module/usePulseUpdates.js.map +1 -1
- package/lib/typescript/NativePulseUpdates.d.ts +1 -0
- package/lib/typescript/NativePulseUpdates.d.ts.map +1 -1
- package/lib/typescript/PulseUpdates.d.ts +20 -0
- package/lib/typescript/PulseUpdates.d.ts.map +1 -1
- package/lib/typescript/config.d.ts +22 -0
- package/lib/typescript/config.d.ts.map +1 -1
- package/lib/typescript/decisions.d.ts +55 -0
- package/lib/typescript/decisions.d.ts.map +1 -0
- package/lib/typescript/index.d.ts +1 -0
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/init.d.ts +51 -4
- package/lib/typescript/init.d.ts.map +1 -1
- package/lib/typescript/track.d.ts +29 -1
- package/lib/typescript/track.d.ts.map +1 -1
- package/lib/typescript/usePulseUpdates.d.ts.map +1 -1
- package/logo.png +0 -0
- package/package.json +14 -3
- package/scripts/publish.mjs +68 -3
- package/src/NativePulseUpdates.ts +1 -0
- package/src/PulseUpdates.ts +54 -0
- package/src/config.ts +234 -21
- package/src/decisions.ts +179 -0
- package/src/index.ts +1 -0
- package/src/init.ts +227 -12
- package/src/track.ts +191 -13
- package/src/usePulseUpdates.ts +21 -16
package/scripts/publish.mjs
CHANGED
|
@@ -1086,10 +1086,19 @@ async function publish(options) {
|
|
|
1086
1086
|
*/
|
|
1087
1087
|
async function registerCapabilities(options) {
|
|
1088
1088
|
const config = loadConfig(options);
|
|
1089
|
-
|
|
1090
|
-
|
|
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;
|
package/src/PulseUpdates.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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, {
|
|
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 {
|
|
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
|
|
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
|
}
|