pulse-updates 1.1.0 → 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 +164 -12
- 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 +165 -14
- 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 +240 -11
- package/src/track.ts +191 -13
- package/src/usePulseUpdates.ts +21 -16
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
|
}
|
package/src/decisions.ts
ADDED
|
@@ -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