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.
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 +164 -12
  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 +165 -14
  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 +240 -11
  63. package/src/track.ts +191 -13
  64. 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
- 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
  }
@@ -0,0 +1,179 @@
1
+ export type DecisionChannel = 'in_app' | 'encore' | 'replio' | 'email' | 'push' | 'sms' | 'whatsapp' | 'webhook';
2
+ export type DecisionOutcome = 'pending' | 'delivered' | 'dismissed' | 'accepted' | 'converted' | 'failed';
3
+
4
+ export interface DecisionContext {
5
+ userId?: string;
6
+ deviceId?: string;
7
+ platform?: string;
8
+ appVersion?: string;
9
+ country?: string;
10
+ language?: string;
11
+ osVersion?: string;
12
+ attributes?: Record<string, string>;
13
+ }
14
+
15
+ export interface DecisionAction { kind: string; [key: string]: unknown }
16
+
17
+ export interface PulseDecision {
18
+ decisionId: string;
19
+ replayed: boolean;
20
+ execute: boolean;
21
+ mode: 'shadow' | 'live' | string;
22
+ verdict: string;
23
+ reasonCode: string;
24
+ policyKey?: string | null;
25
+ policyVersion?: number | null;
26
+ action?: DecisionAction | null;
27
+ }
28
+
29
+ export interface DecisionHandlerResult {
30
+ outcome?: DecisionOutcome;
31
+ metadata?: Record<string, unknown>;
32
+ }
33
+
34
+ export type DecisionHandler = (
35
+ action: DecisionAction,
36
+ decision: PulseDecision,
37
+ ) => void | DecisionHandlerResult | Promise<void | DecisionHandlerResult>;
38
+
39
+ export interface PulseDecisionClientOptions {
40
+ apiUrl: string;
41
+ appSlug: string;
42
+ getContext: () => DecisionContext | Promise<DecisionContext>;
43
+ fetch?: typeof globalThis.fetch;
44
+ timeoutMs?: number;
45
+ /** Safe default. User-facing execution needs an explicit per-app opt-in. */
46
+ executionMode?: 'shadow-only' | 'allow-live';
47
+ handlers?: Record<string, DecisionHandler>;
48
+ onError?: (error: unknown) => void;
49
+ }
50
+
51
+ export interface DecideOptions {
52
+ channel?: DecisionChannel;
53
+ idempotencyKey?: string;
54
+ attributes?: Record<string, string>;
55
+ }
56
+
57
+ export interface PulseDecisionClient {
58
+ decide(trigger: string, options?: DecideOptions): Promise<PulseDecision | null>;
59
+ reportOutcome(decisionId: string, outcome: DecisionOutcome, metadata?: Record<string, unknown>): Promise<boolean>;
60
+ }
61
+
62
+ /** Generic decision protocol. Product-specific action handlers remain in each app. */
63
+ export function createPulseDecisionClient(options: PulseDecisionClientOptions): PulseDecisionClient {
64
+ const fetcher = options.fetch ?? globalThis.fetch;
65
+ const base = options.apiUrl.replace(/\/+$/, '');
66
+ const slug = encodeURIComponent(options.appSlug);
67
+ const timeoutMs = Math.max(250, options.timeoutMs ?? 3_000);
68
+ const executionMode = options.executionMode ?? 'shadow-only';
69
+
70
+ const reportOutcome = async (
71
+ decisionId: string,
72
+ outcome: DecisionOutcome,
73
+ metadata?: Record<string, unknown>,
74
+ ): Promise<boolean> => {
75
+ try {
76
+ const response = await timedFetch(fetcher,
77
+ `${base}/pulse/decisions/${slug}/${encodeURIComponent(decisionId)}/outcome`, {
78
+ method: 'POST',
79
+ headers: { 'Content-Type': 'application/json' },
80
+ body: JSON.stringify({ outcome, ...(metadata ? { metadata } : {}) }),
81
+ }, timeoutMs);
82
+ return response.ok;
83
+ } catch (error) {
84
+ options.onError?.(error);
85
+ return false;
86
+ }
87
+ };
88
+
89
+ const decide = async (trigger: string, request: DecideOptions = {}): Promise<PulseDecision | null> => {
90
+ try {
91
+ const context = await options.getContext();
92
+ const response = await timedFetch(fetcher, `${base}/pulse/decisions/${slug}`, {
93
+ method: 'POST',
94
+ headers: { 'Content-Type': 'application/json' },
95
+ body: JSON.stringify({
96
+ ...context,
97
+ attributes: { ...(context.attributes ?? {}), ...(request.attributes ?? {}) },
98
+ trigger,
99
+ channel: request.channel ?? 'in_app',
100
+ idempotencyKey: request.idempotencyKey ?? randomKey(),
101
+ }),
102
+ }, timeoutMs);
103
+ if (!response.ok) throw new Error(`Pulse decision HTTP ${response.status}`);
104
+
105
+ const decision = parseDecision(await response.json());
106
+ if (!decision.execute) return decision;
107
+ if (executionMode !== 'allow-live') {
108
+ void reportOutcome(decision.decisionId, 'failed', { reason: 'client_executor_not_armed' });
109
+ return decision;
110
+ }
111
+
112
+ const action = decision.action;
113
+ const handler = action?.kind ? options.handlers?.[action.kind] : undefined;
114
+ if (!action || !handler) {
115
+ void reportOutcome(decision.decisionId, 'failed', {
116
+ reason: 'unknown_action_kind',
117
+ kind: action?.kind ?? null,
118
+ });
119
+ return decision;
120
+ }
121
+
122
+ try {
123
+ const result = await handler(action, decision);
124
+ void reportOutcome(decision.decisionId, result?.outcome ?? 'accepted', result?.metadata);
125
+ } catch (error) {
126
+ options.onError?.(error);
127
+ void reportOutcome(decision.decisionId, 'failed', { reason: 'handler_failed' });
128
+ }
129
+ return decision;
130
+ } catch (error) {
131
+ options.onError?.(error);
132
+ return null;
133
+ }
134
+ };
135
+
136
+ return { decide, reportOutcome };
137
+ }
138
+
139
+ function parseDecision(raw: unknown): PulseDecision {
140
+ if (!raw || typeof raw !== 'object') throw new Error('Malformed Pulse decision');
141
+ const value = raw as Record<string, unknown>;
142
+ if (typeof value.decisionId !== 'string' || typeof value.execute !== 'boolean' ||
143
+ typeof value.mode !== 'string' || typeof value.verdict !== 'string' ||
144
+ typeof value.reasonCode !== 'string') throw new Error('Malformed Pulse decision');
145
+ const action = value.action;
146
+ if (action != null && (typeof action !== 'object' ||
147
+ typeof (action as Record<string, unknown>).kind !== 'string')) {
148
+ throw new Error('Malformed Pulse action');
149
+ }
150
+ return {
151
+ decisionId: value.decisionId,
152
+ replayed: value.replayed === true,
153
+ execute: value.execute,
154
+ mode: value.mode,
155
+ verdict: value.verdict,
156
+ reasonCode: value.reasonCode,
157
+ policyKey: typeof value.policyKey === 'string' ? value.policyKey : null,
158
+ policyVersion: typeof value.policyVersion === 'number' ? value.policyVersion : null,
159
+ action: (action as DecisionAction | null | undefined) ?? null,
160
+ };
161
+ }
162
+
163
+ async function timedFetch(
164
+ fetcher: typeof globalThis.fetch,
165
+ url: string,
166
+ init: RequestInit,
167
+ timeoutMs: number,
168
+ ): Promise<Response> {
169
+ const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
170
+ const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
171
+ try { return await fetcher(url, { ...init, signal: controller?.signal }); }
172
+ finally { if (timer) clearTimeout(timer); }
173
+ }
174
+
175
+ function randomKey(): string {
176
+ const cryptoLike = globalThis.crypto as { randomUUID?: () => string } | undefined;
177
+ if (typeof cryptoLike?.randomUUID === 'function') return cryptoLike.randomUUID();
178
+ return `moment-${Date.now()}-${Math.random().toString(36).slice(2, 14)}`;
179
+ }
package/src/index.ts CHANGED
@@ -5,3 +5,4 @@ export { initializeAssetResolver, updateLocalAssets } from './assetResolver';
5
5
  export * from './config';
6
6
  export * from './track';
7
7
  export * from './init';
8
+ export * from './decisions';