@govplane/runtime-sdk 0.2.4

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/dist/index.cjs ADDED
@@ -0,0 +1,1080 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ DEFAULT_CONTEXT_POLICY: () => DEFAULT_CONTEXT_POLICY,
24
+ GovplaneError: () => GovplaneError,
25
+ HttpError: () => HttpError,
26
+ RuntimeClient: () => RuntimeClient,
27
+ createPolicyEngine: () => createPolicyEngine,
28
+ formatTrace: () => formatTrace,
29
+ validateContext: () => validateContext
30
+ });
31
+ module.exports = __toCommonJS(index_exports);
32
+
33
+ // src/client/RuntimeClient.ts
34
+ var import_undici = require("undici");
35
+
36
+ // src/engine/context.ts
37
+ var DEFAULT_CONTEXT_POLICY = {
38
+ allowedKeys: [
39
+ "ctx.plan",
40
+ "ctx.country",
41
+ "ctx.requestTier",
42
+ "ctx.feature",
43
+ "ctx.amount",
44
+ "ctx.isAuthenticated",
45
+ "ctx.role"
46
+ ],
47
+ maxStringLen: 64,
48
+ maxArrayLen: 10,
49
+ blockLikelyPiiKeys: true
50
+ };
51
+ var PII_KEY_PATTERNS = [
52
+ /email/i,
53
+ /phone/i,
54
+ /mobile/i,
55
+ /name/i,
56
+ /firstname/i,
57
+ /lastname/i,
58
+ /address/i,
59
+ /street/i,
60
+ /postcode|postal/i,
61
+ /city/i,
62
+ /ip/i,
63
+ /ssn/i,
64
+ /dni|nie/i,
65
+ /passport/i
66
+ ];
67
+ var DEFAULT_POLICY = {
68
+ maxStringLen: 80,
69
+ maxArrayLen: 25,
70
+ blockLikelyPiiKeys: true
71
+ };
72
+ function validateContext(ctx, policy) {
73
+ const allowed = new Set(policy.allowedKeys);
74
+ const maxStringLen = policy.maxStringLen ?? DEFAULT_POLICY.maxStringLen;
75
+ const maxArrayLen = policy.maxArrayLen ?? DEFAULT_POLICY.maxArrayLen;
76
+ const blockLikelyPiiKeys = policy.blockLikelyPiiKeys ?? DEFAULT_POLICY.blockLikelyPiiKeys;
77
+ for (const [k, v] of Object.entries(ctx)) {
78
+ const path = `ctx.${k}`;
79
+ if (!allowed.has(path)) {
80
+ throw new Error(`Context key not allowed: ${path}`);
81
+ }
82
+ if (blockLikelyPiiKeys) {
83
+ for (const re of PII_KEY_PATTERNS) {
84
+ if (re.test(k)) {
85
+ throw new Error(`Context key looks like PII and is blocked: ${path}`);
86
+ }
87
+ }
88
+ }
89
+ if (v === null || v === void 0) continue;
90
+ const t = typeof v;
91
+ if (t === "boolean" || t === "number") continue;
92
+ if (t === "string") {
93
+ if (v.length > maxStringLen) throw new Error(`Context value too long: ${path}`);
94
+ continue;
95
+ }
96
+ if (Array.isArray(v)) {
97
+ if (v.length > maxArrayLen) throw new Error(`Context array too long: ${path}`);
98
+ for (const it of v) {
99
+ if (typeof it !== "string") throw new Error(`Invalid array value type: ${path}`);
100
+ if (it.length > maxStringLen) throw new Error(`Invalid array value length: ${path}`);
101
+ }
102
+ continue;
103
+ }
104
+ throw new Error(`Invalid context type for ${path}`);
105
+ }
106
+ }
107
+
108
+ // src/engine/when.ts
109
+ function getPath(obj, path) {
110
+ const parts = path.split(".");
111
+ let cur = obj;
112
+ for (const p of parts) {
113
+ if (!cur || typeof cur !== "object") return void 0;
114
+ cur = cur[p];
115
+ }
116
+ return cur;
117
+ }
118
+ function evalWhen(node, ctx) {
119
+ switch (node.op) {
120
+ case "and":
121
+ return node.args.every((n) => evalWhen(n, ctx));
122
+ case "or":
123
+ return node.args.some((n) => evalWhen(n, ctx));
124
+ case "not":
125
+ return !evalWhen(node.arg, ctx);
126
+ case "exists":
127
+ return getPath({ ctx }, node.path) !== void 0;
128
+ case "in": {
129
+ const v = getPath({ ctx }, node.path);
130
+ return node.values.some((x) => x === v);
131
+ }
132
+ case "eq":
133
+ return getPath({ ctx }, node.path) === node.value;
134
+ case "ne":
135
+ return getPath({ ctx }, node.path) !== node.value;
136
+ case "gt":
137
+ return Number(getPath({ ctx }, node.path)) > Number(node.value);
138
+ case "gte":
139
+ return Number(getPath({ ctx }, node.path)) >= Number(node.value);
140
+ case "lt":
141
+ return Number(getPath({ ctx }, node.path)) < Number(node.value);
142
+ case "lte":
143
+ return Number(getPath({ ctx }, node.path)) <= Number(node.value);
144
+ default:
145
+ return false;
146
+ }
147
+ }
148
+
149
+ // src/engine/traceDispatcher.ts
150
+ var TraceDispatcher = class {
151
+ constructor(opts) {
152
+ this.opts = opts;
153
+ }
154
+ q = [];
155
+ draining = false;
156
+ flushWaiters = [];
157
+ enqueue(evt) {
158
+ if (this.q.length >= this.opts.max) {
159
+ if (this.opts.dropPolicy === "drop_old") {
160
+ this.q.shift();
161
+ } else {
162
+ return;
163
+ }
164
+ }
165
+ this.q.push(evt);
166
+ this.kick();
167
+ }
168
+ async flush() {
169
+ if (!this.draining && this.q.length === 0) return;
170
+ return new Promise((resolve) => {
171
+ this.flushWaiters.push(resolve);
172
+ this.kick();
173
+ });
174
+ }
175
+ kick() {
176
+ if (this.draining) return;
177
+ if (this.q.length === 0) {
178
+ this.resolveFlushWaitersIfIdle();
179
+ return;
180
+ }
181
+ this.draining = true;
182
+ queueMicrotask(() => void this.drain());
183
+ }
184
+ resolveFlushWaitersIfIdle() {
185
+ if (this.draining) return;
186
+ if (this.q.length !== 0) return;
187
+ const waiters = this.flushWaiters;
188
+ this.flushWaiters = [];
189
+ for (const w of waiters) w();
190
+ }
191
+ async drain() {
192
+ try {
193
+ while (this.q.length) {
194
+ const evt = this.q.shift();
195
+ try {
196
+ await this.opts.sinkAsync(evt);
197
+ } catch (err) {
198
+ if (this.opts.onError) this.opts.onError(err);
199
+ }
200
+ }
201
+ } finally {
202
+ this.draining = false;
203
+ this.resolveFlushWaitersIfIdle();
204
+ if (this.q.length) this.kick();
205
+ }
206
+ }
207
+ };
208
+
209
+ // src/engine/toStructuredTrace.ts
210
+ function toStructuredTraceEvent(input) {
211
+ const { decision, trace, level } = input;
212
+ const evt = {
213
+ v: 1,
214
+ ts: trace.evaluatedAt,
215
+ traceId: trace.traceId,
216
+ sampled: trace.sampled,
217
+ level,
218
+ target: trace.target,
219
+ decision: decision.decision,
220
+ reason: decision.reason,
221
+ winner: trace.winner,
222
+ summary: trace.summary
223
+ };
224
+ if (level === "full" && "rules" in trace) {
225
+ evt.rules = trace.rules.map((r) => ({
226
+ policyKey: r.policyKey,
227
+ ruleId: r.ruleId,
228
+ priority: r.priority,
229
+ effectType: r.effectType,
230
+ matched: r.matched,
231
+ discardedReason: r.discardedReason
232
+ }));
233
+ }
234
+ return evt;
235
+ }
236
+
237
+ // src/engine/emitTrace.ts
238
+ function emitTraceIfAny(input) {
239
+ const { trace, decision, level } = input;
240
+ if (!trace) return;
241
+ let evt;
242
+ try {
243
+ evt = toStructuredTraceEvent({ level, decision, trace });
244
+ } catch (err) {
245
+ input.onSinkError?.(err);
246
+ return;
247
+ }
248
+ if (input.sink) {
249
+ try {
250
+ input.sink(evt);
251
+ } catch (err) {
252
+ input.onSinkError?.(err);
253
+ }
254
+ }
255
+ if (input.dispatcher) {
256
+ input.dispatcher.enqueue(evt);
257
+ }
258
+ }
259
+
260
+ // src/engine/createPolicyEngine.ts
261
+ function clamp01(n) {
262
+ if (!Number.isFinite(n)) return 0;
263
+ if (n < 0) return 0;
264
+ if (n > 1) return 1;
265
+ return n;
266
+ }
267
+ function safeEffectType(effect) {
268
+ const t = effect?.type;
269
+ return typeof t === "string" ? t : void 0;
270
+ }
271
+ function makeTraceId() {
272
+ const g = globalThis;
273
+ const cryptoAny = g.crypto;
274
+ if (cryptoAny?.randomUUID) {
275
+ return cryptoAny.randomUUID();
276
+ }
277
+ try {
278
+ const nodeCrypto = require("crypto");
279
+ if (nodeCrypto?.randomUUID) return nodeCrypto.randomUUID();
280
+ const b = nodeCrypto.randomBytes?.(16);
281
+ if (!b || b.length < 16) {
282
+ throw new Error("Failed to generate random bytes for UUID");
283
+ }
284
+ if (b) {
285
+ b[6] = b[6] & 15 | 64;
286
+ b[8] = b[8] & 63 | 128;
287
+ }
288
+ const hex = b.toString("hex");
289
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
290
+ } catch {
291
+ return `gp_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
292
+ }
293
+ }
294
+ function targetMatch(a, b) {
295
+ return a.service === b.service && a.resource === b.resource && a.action === b.action;
296
+ }
297
+ function byRuleOrder(a, b) {
298
+ const pr = (Number(b.priority) || 0) - (Number(a.priority) || 0);
299
+ if (pr !== 0) return pr;
300
+ const pk = String(a.policyKey).localeCompare(String(b.policyKey));
301
+ if (pk !== 0) return pk;
302
+ return String(a.ruleId).localeCompare(String(b.ruleId));
303
+ }
304
+ function throttleStrictness(th) {
305
+ const limit = Number(th?.limit);
306
+ const windowSeconds = Number(th?.windowSeconds);
307
+ const rate = windowSeconds > 0 ? limit / windowSeconds : Number.POSITIVE_INFINITY;
308
+ return { rate, limit, windowSeconds };
309
+ }
310
+ function makeTraceBudget(maxTraces, windowMs) {
311
+ let windowStart = Date.now();
312
+ let used = 0;
313
+ return {
314
+ allow() {
315
+ const now = Date.now();
316
+ if (now - windowStart >= windowMs) {
317
+ windowStart = now;
318
+ used = 0;
319
+ }
320
+ if (used >= maxTraces) return false;
321
+ used += 1;
322
+ return true;
323
+ }
324
+ };
325
+ }
326
+ function createPolicyEngine(opts) {
327
+ const budget = makeTraceBudget(
328
+ Number.isFinite(opts.traceDefaults?.budget?.maxTraces) ? opts.traceDefaults.budget.maxTraces : 60,
329
+ Number.isFinite(opts.traceDefaults?.budget?.windowMs) ? opts.traceDefaults.budget.windowMs : 6e4
330
+ );
331
+ const dispatcher = opts.traceSinkAsync ? new TraceDispatcher({
332
+ sinkAsync: opts.traceSinkAsync,
333
+ max: Number.isFinite(opts.traceQueueMax) ? opts.traceQueueMax : 1e3,
334
+ dropPolicy: opts.traceQueueDropPolicy ?? "drop_new",
335
+ onError: opts.onTraceSinkError
336
+ }) : void 0;
337
+ function coreEvaluate(input, wantTrace) {
338
+ const bundle = opts.getBundle();
339
+ const ctx = input.context ?? {};
340
+ if (opts.validateContext !== false) {
341
+ validateContext(ctx, opts.contextPolicy ?? DEFAULT_CONTEXT_POLICY);
342
+ }
343
+ const traceBase = wantTrace ? {
344
+ traceId: makeTraceId(),
345
+ sampled: "random",
346
+ // overwritten if forced
347
+ evaluatedAt: (/* @__PURE__ */ new Date()).toISOString(),
348
+ target: input.target,
349
+ summary: {
350
+ policiesSeen: 0,
351
+ rulesSeen: 0,
352
+ matched: 0,
353
+ considered: { kill_switch: 0, deny: 0, throttle: 0, allow: 0 }
354
+ },
355
+ rules: []
356
+ } : null;
357
+ if (!bundle || bundle.schemaVersion !== 1) {
358
+ const decision2 = { decision: "deny", reason: "default" };
359
+ if (!wantTrace) return decision2;
360
+ return {
361
+ ...decision2,
362
+ trace: {
363
+ ...traceBase,
364
+ winner: void 0
365
+ }
366
+ };
367
+ }
368
+ const kills = [];
369
+ const denies = [];
370
+ const throttles = [];
371
+ const allows = [];
372
+ const policies = bundle.policies ?? [];
373
+ if (wantTrace) traceBase.summary.policiesSeen = policies.length;
374
+ for (const p of policies) {
375
+ const policyKey = String(p.policyKey ?? "");
376
+ const rules = p.rules ?? [];
377
+ for (const r of rules) {
378
+ if (wantTrace) traceBase.summary.rulesSeen += 1;
379
+ const ruleId = String(r?.id ?? "");
380
+ const priority = Number(r?.priority ?? 0);
381
+ const effectType = safeEffectType(r?.effect);
382
+ if (r?.status !== "active") {
383
+ if (wantTrace) {
384
+ traceBase.rules.push({
385
+ policyKey,
386
+ ruleId,
387
+ priority,
388
+ effectType,
389
+ matched: false,
390
+ discardedReason: "disabled"
391
+ });
392
+ }
393
+ continue;
394
+ }
395
+ if (!r?.target || !targetMatch(r.target, input.target)) {
396
+ if (wantTrace) {
397
+ traceBase.rules.push({
398
+ policyKey,
399
+ ruleId,
400
+ priority,
401
+ effectType,
402
+ matched: false,
403
+ discardedReason: "target_mismatch"
404
+ });
405
+ }
406
+ continue;
407
+ }
408
+ if (r.when && !evalWhen(r.when, ctx)) {
409
+ if (wantTrace) {
410
+ traceBase.rules.push({
411
+ policyKey,
412
+ ruleId,
413
+ priority,
414
+ effectType,
415
+ matched: false,
416
+ discardedReason: "when_false"
417
+ });
418
+ }
419
+ continue;
420
+ }
421
+ if (!effectType) {
422
+ if (wantTrace) {
423
+ traceBase.rules.push({
424
+ policyKey,
425
+ ruleId,
426
+ priority,
427
+ effectType: void 0,
428
+ matched: false,
429
+ discardedReason: "invalid_effect"
430
+ });
431
+ }
432
+ continue;
433
+ }
434
+ if (wantTrace) {
435
+ traceBase.summary.matched += 1;
436
+ traceBase.rules.push({
437
+ policyKey,
438
+ ruleId,
439
+ priority,
440
+ effectType,
441
+ matched: true
442
+ });
443
+ }
444
+ const m = { policyKey, ruleId, priority, effect: r.effect };
445
+ if (effectType === "kill_switch") {
446
+ kills.push(m);
447
+ if (wantTrace) traceBase.summary.considered.kill_switch += 1;
448
+ } else if (effectType === "deny") {
449
+ denies.push(m);
450
+ if (wantTrace) traceBase.summary.considered.deny += 1;
451
+ } else if (effectType === "throttle") {
452
+ throttles.push(m);
453
+ if (wantTrace) traceBase.summary.considered.throttle += 1;
454
+ } else if (effectType === "allow") {
455
+ allows.push(m);
456
+ if (wantTrace) traceBase.summary.considered.allow += 1;
457
+ }
458
+ }
459
+ }
460
+ if (kills.length) {
461
+ const w = kills.sort(byRuleOrder)[0];
462
+ const decision2 = {
463
+ decision: "kill_switch",
464
+ reason: "rule",
465
+ policyKey: w.policyKey,
466
+ ruleId: w.ruleId,
467
+ killSwitch: w.effect.killSwitch
468
+ };
469
+ if (!wantTrace) return decision2;
470
+ return {
471
+ ...decision2,
472
+ trace: {
473
+ ...traceBase,
474
+ winner: { policyKey: w.policyKey, ruleId: w.ruleId, effectType: "kill_switch", priority: w.priority }
475
+ }
476
+ };
477
+ }
478
+ if (denies.length) {
479
+ const w = denies.sort(byRuleOrder)[0];
480
+ const decision2 = {
481
+ decision: "deny",
482
+ reason: "rule",
483
+ policyKey: w.policyKey,
484
+ ruleId: w.ruleId
485
+ };
486
+ if (!wantTrace) return decision2;
487
+ return {
488
+ ...decision2,
489
+ trace: {
490
+ ...traceBase,
491
+ winner: { policyKey: w.policyKey, ruleId: w.ruleId, effectType: "deny", priority: w.priority }
492
+ }
493
+ };
494
+ }
495
+ if (throttles.length) {
496
+ const w = throttles.sort((a, b) => {
497
+ const A = throttleStrictness(a.effect.throttle);
498
+ const B = throttleStrictness(b.effect.throttle);
499
+ if (A.rate !== B.rate) return A.rate - B.rate;
500
+ if (A.limit !== B.limit) return A.limit - B.limit;
501
+ if (A.windowSeconds !== B.windowSeconds) return A.windowSeconds - B.windowSeconds;
502
+ return byRuleOrder(a, b);
503
+ })[0];
504
+ const decision2 = {
505
+ decision: "throttle",
506
+ reason: "rule",
507
+ policyKey: w.policyKey,
508
+ ruleId: w.ruleId,
509
+ throttle: w.effect.throttle
510
+ };
511
+ if (!wantTrace) return decision2;
512
+ return {
513
+ ...decision2,
514
+ trace: {
515
+ ...traceBase,
516
+ winner: { policyKey: w.policyKey, ruleId: w.ruleId, effectType: "throttle", priority: w.priority }
517
+ }
518
+ };
519
+ }
520
+ if (allows.length) {
521
+ const w = allows.sort(byRuleOrder)[0];
522
+ const decision2 = {
523
+ decision: "allow",
524
+ reason: "rule",
525
+ policyKey: w.policyKey,
526
+ ruleId: w.ruleId
527
+ };
528
+ if (!wantTrace) return decision2;
529
+ return {
530
+ ...decision2,
531
+ trace: {
532
+ ...traceBase,
533
+ winner: { policyKey: w.policyKey, ruleId: w.ruleId, effectType: "allow", priority: w.priority }
534
+ }
535
+ };
536
+ }
537
+ const decision = { decision: "deny", reason: "default" };
538
+ if (!wantTrace) return decision;
539
+ return {
540
+ ...decision,
541
+ trace: {
542
+ ...traceBase,
543
+ winner: void 0
544
+ }
545
+ };
546
+ }
547
+ return {
548
+ evaluate(input) {
549
+ return coreEvaluate(input, false);
550
+ },
551
+ evaluateWithTrace(input, options) {
552
+ const merged = {
553
+ ...opts.traceDefaults ?? {},
554
+ ...options ?? {}
555
+ };
556
+ const level = merged.level ?? "sampled";
557
+ const sampling = clamp01(merged.sampling ?? 0.01);
558
+ const force = merged.force === true;
559
+ const isOff = level === "off";
560
+ const isErrorLevel = level === "errors";
561
+ const isFull = level === "full";
562
+ const isSampled = level === "sampled";
563
+ if (isOff) {
564
+ return coreEvaluate(input, false);
565
+ }
566
+ const baseDecision = coreEvaluate(input, false);
567
+ if (isErrorLevel) {
568
+ const isErrDecision = baseDecision.decision === "deny" || baseDecision.decision === "kill_switch";
569
+ if (!isErrDecision && !force) {
570
+ return baseDecision;
571
+ }
572
+ if (!force) {
573
+ const ok = budget.allow();
574
+ if (!ok) return baseDecision;
575
+ }
576
+ const out = coreEvaluate(input, true);
577
+ if (out?.trace) out.trace.sampled = force ? "forced" : "errors";
578
+ const { rules: _rules, ...compact } = out.trace ?? {};
579
+ const finalOut = { ...out, trace: compact };
580
+ emitTraceIfAny({ level: "errors", decision: out, trace: out.trace, sink: opts.traceSink, dispatcher, onSinkError: opts.onTraceSinkError });
581
+ return finalOut;
582
+ }
583
+ if (isFull) {
584
+ if (!force) {
585
+ const ok = budget.allow();
586
+ if (!ok) return baseDecision;
587
+ }
588
+ const out = coreEvaluate(input, true);
589
+ if (out?.trace) out.trace.sampled = force ? "forced" : "random";
590
+ emitTraceIfAny({ level: "full", decision: out, trace: out.trace, sink: opts.traceSink, dispatcher, onSinkError: opts.onTraceSinkError });
591
+ return out;
592
+ }
593
+ if (isSampled) {
594
+ const include = force ? true : Math.random() < sampling;
595
+ if (!include) return baseDecision;
596
+ if (!force) {
597
+ const ok = budget.allow();
598
+ if (!ok) return baseDecision;
599
+ }
600
+ const out = coreEvaluate(input, true);
601
+ if (out?.trace) out.trace.sampled = force ? "forced" : "random";
602
+ const { rules: _rules, ...compact } = out.trace ?? {};
603
+ const finalOut = { ...out, trace: compact };
604
+ emitTraceIfAny({ level: "sampled", decision: out, trace: out.trace, sink: opts.traceSink, dispatcher, onSinkError: opts.onTraceSinkError });
605
+ return finalOut;
606
+ }
607
+ return baseDecision;
608
+ },
609
+ flushTraces: async () => {
610
+ if (!dispatcher) return;
611
+ await dispatcher.flush();
612
+ }
613
+ };
614
+ }
615
+
616
+ // src/client/RuntimeClient.ts
617
+ var import_node_fs = require("fs");
618
+ var RuntimeClient = class {
619
+ cfg;
620
+ cache = {};
621
+ timer = null;
622
+ isRunning = false;
623
+ hasValidBundle = false;
624
+ inFlightRefresh = null;
625
+ burstUntil = 0;
626
+ // health / degraded state
627
+ consecutiveFailures = 0;
628
+ lastError = null;
629
+ nextRetryAtMs = null;
630
+ updateListeners = [];
631
+ statusListeners = [];
632
+ // Policy engine
633
+ engine;
634
+ // Incident controls
635
+ incidentPollTimer = null;
636
+ incidentFileLastMtimeMs = null;
637
+ installedSignalHandler = false;
638
+ sigHandler;
639
+ constructor(config) {
640
+ this.cfg = {
641
+ ...config,
642
+ pollMs: config.pollMs ?? 5e3,
643
+ burstPollMs: config.burstPollMs ?? 500,
644
+ burstDurationMs: config.burstDurationMs ?? 3e4,
645
+ timeoutMs: config.timeoutMs ?? 5e3,
646
+ backoffBaseMs: config.backoffBaseMs ?? 500,
647
+ backoffMaxMs: config.backoffMaxMs ?? 3e4,
648
+ backoffJitter: config.backoffJitter ?? 0.2,
649
+ degradeAfterFailures: config.degradeAfterFailures ?? 3,
650
+ incidentEnvFlag: config.incidentEnvFlag ?? "GP_RUNTIME_INCIDENT",
651
+ incidentFilePollMs: config.incidentFilePollMs ?? 1e3,
652
+ incidentSignal: config.incidentSignal ?? "SIGUSR1"
653
+ };
654
+ this.engine = createPolicyEngine({
655
+ getBundle: () => this.cache.bundle,
656
+ validateContext: config.engine?.validateContext !== false,
657
+ contextPolicy: config.engine?.contextPolicy,
658
+ traceDefaults: config.trace?.defaults,
659
+ traceSink: config.trace?.onDecisionTrace,
660
+ traceSinkAsync: config.trace?.onDecisionTraceAsync,
661
+ traceQueueMax: config.trace?.queueMax,
662
+ traceQueueDropPolicy: config.trace?.dropPolicy,
663
+ onTraceSinkError: config.trace?.onTraceError
664
+ });
665
+ }
666
+ /** Subscribe to bundle updates (only when changed). */
667
+ onUpdate(fn) {
668
+ const wrapped = (res) => {
669
+ if (res.changed) fn(res);
670
+ };
671
+ this.updateListeners.push(wrapped);
672
+ return () => {
673
+ this.updateListeners = this.updateListeners.filter((x) => x !== wrapped);
674
+ };
675
+ }
676
+ /** Subscribe to status changes (ok/degraded). */
677
+ onStatus(fn) {
678
+ this.statusListeners.push(fn);
679
+ fn(this.getStatus());
680
+ return () => {
681
+ this.statusListeners = this.statusListeners.filter((x) => x !== fn);
682
+ };
683
+ }
684
+ /** Evaluate a decision using the cached bundle (deny-by-default if bundle missing/invalid). */
685
+ evaluate(input) {
686
+ return this.engine.evaluate(input);
687
+ }
688
+ /** Evaluate and (optionally) attach trace based on trace defaults/overrides. */
689
+ evaluateWithTrace(input, options) {
690
+ return this.engine.evaluateWithTrace(input, options);
691
+ }
692
+ /** Flush async trace queue (only does something if traceSinkAsync is configured). */
693
+ flushTraces() {
694
+ return this.engine.flushTraces();
695
+ }
696
+ getCached() {
697
+ return { ...this.cache };
698
+ }
699
+ getStatus() {
700
+ const b = this.cache.bundle;
701
+ const valid = !!(b && typeof b === "object" && b.schemaVersion === 1 && Array.isArray(b.policies));
702
+ if (!this.hasValidBundle && !valid) return { state: "warming_up" };
703
+ if (this.consecutiveFailures >= this.cfg.degradeAfterFailures) {
704
+ return {
705
+ state: "degraded",
706
+ consecutiveFailures: this.consecutiveFailures,
707
+ lastError: this.lastError ?? { message: "unknown", at: (/* @__PURE__ */ new Date()).toISOString() },
708
+ nextRetryAt: this.nextRetryAtMs ? new Date(this.nextRetryAtMs).toISOString() : void 0
709
+ };
710
+ }
711
+ return { state: "ok" };
712
+ }
713
+ start() {
714
+ if (this.isRunning) return;
715
+ this.isRunning = true;
716
+ this.applyIncidentFromEnv();
717
+ this.startIncidentFilePoller();
718
+ this.installIncidentSignal();
719
+ this.scheduleNext(0);
720
+ }
721
+ async warmStart(opts) {
722
+ this.applyIncidentFromEnv();
723
+ this.startIncidentFilePoller();
724
+ this.installIncidentSignal();
725
+ const timeoutMs = opts?.timeoutMs ?? 1e4;
726
+ const until = Date.now() + timeoutMs;
727
+ if (opts?.burst) {
728
+ await this.refreshNow({ burst: true }).catch(() => void 0);
729
+ } else {
730
+ await this.refreshNow().catch(() => void 0);
731
+ }
732
+ while (Date.now() < until) {
733
+ const b = this.cache.bundle;
734
+ const valid = !!(b && typeof b === "object" && b.schemaVersion === 1 && Array.isArray(b.policies));
735
+ if (valid) {
736
+ this.hasValidBundle = true;
737
+ return;
738
+ }
739
+ await new Promise((r) => setTimeout(r, 100));
740
+ }
741
+ throw new Error("warmStart timeout: no valid runtime bundle received");
742
+ }
743
+ stop() {
744
+ this.isRunning = false;
745
+ if (this.timer) clearTimeout(this.timer);
746
+ this.timer = null;
747
+ if (this.incidentPollTimer) clearInterval(this.incidentPollTimer);
748
+ this.incidentPollTimer = null;
749
+ this.uninstallIncidentSignal();
750
+ }
751
+ async refreshNow(opts) {
752
+ if (opts?.burst) {
753
+ this.burstUntil = Date.now() + this.cfg.burstDurationMs;
754
+ if (this.isRunning) this.scheduleNext(0);
755
+ }
756
+ return this.refresh();
757
+ }
758
+ emitStatusIfNeeded(prev) {
759
+ const next = this.getStatus();
760
+ const key = (s) => {
761
+ if (s.state === "warming_up") return "warming_up";
762
+ if (s.state === "ok") return "ok";
763
+ return `degraded:${s.consecutiveFailures}:${s.lastError.message}`;
764
+ };
765
+ if (key(prev) !== key(next)) {
766
+ for (const l of this.statusListeners) l(next);
767
+ }
768
+ }
769
+ async refresh() {
770
+ if (this.inFlightRefresh) return this.inFlightRefresh;
771
+ const prevStatus = this.getStatus();
772
+ this.inFlightRefresh = (async () => {
773
+ try {
774
+ const head = await this.headBundle();
775
+ if (!head) {
776
+ this.markSuccess();
777
+ this.emitStatusIfNeeded(prevStatus);
778
+ return { changed: false, meta: this.cache.meta };
779
+ }
780
+ const prevEtag = this.cache.meta?.etag;
781
+ if (prevEtag && head.etag === prevEtag) {
782
+ this.cache.meta = head;
783
+ this.markSuccess();
784
+ this.emitStatusIfNeeded(prevStatus);
785
+ return { changed: false, meta: head };
786
+ }
787
+ const got = await this.getBundle();
788
+ this.markSuccess();
789
+ this.emitStatusIfNeeded(prevStatus);
790
+ return got.changed ? got : { changed: false, meta: got.meta };
791
+ } catch (err) {
792
+ this.markFailure(err);
793
+ this.emitStatusIfNeeded(prevStatus);
794
+ throw err;
795
+ } finally {
796
+ this.inFlightRefresh = null;
797
+ }
798
+ })();
799
+ return this.inFlightRefresh;
800
+ }
801
+ scheduleNext(delayMs) {
802
+ if (!this.isRunning) return;
803
+ if (this.timer) clearTimeout(this.timer);
804
+ this.timer = setTimeout(async () => {
805
+ const prevStatus = this.getStatus();
806
+ try {
807
+ this.applyIncidentFromEnv();
808
+ if (this.nextRetryAtMs && Date.now() < this.nextRetryAtMs) {
809
+ this.scheduleNext(Math.max(0, this.nextRetryAtMs - Date.now()));
810
+ return;
811
+ }
812
+ const res = await this.refresh();
813
+ if (res.changed) {
814
+ for (const l of this.updateListeners) l(res);
815
+ }
816
+ } catch {
817
+ } finally {
818
+ this.emitStatusIfNeeded(prevStatus);
819
+ const next = this.nextRetryAtMs && Date.now() < this.nextRetryAtMs ? Math.max(0, this.nextRetryAtMs - Date.now()) : this.effectivePollMs();
820
+ this.scheduleNext(next);
821
+ }
822
+ }, delayMs);
823
+ }
824
+ effectivePollMs() {
825
+ const now = Date.now();
826
+ if (now < this.burstUntil) return this.cfg.burstPollMs;
827
+ return this.cfg.pollMs;
828
+ }
829
+ markSuccess() {
830
+ this.consecutiveFailures = 0;
831
+ this.lastError = null;
832
+ this.nextRetryAtMs = null;
833
+ }
834
+ markFailure(err) {
835
+ this.consecutiveFailures += 1;
836
+ this.lastError = { message: String(err?.message ?? err ?? "unknown"), at: (/* @__PURE__ */ new Date()).toISOString() };
837
+ const delay = this.computeBackoffDelayMs(this.consecutiveFailures);
838
+ this.nextRetryAtMs = Date.now() + delay;
839
+ }
840
+ computeBackoffDelayMs(failures) {
841
+ const base = this.cfg.backoffBaseMs;
842
+ const max = this.cfg.backoffMaxMs;
843
+ const exp = base * Math.pow(2, Math.max(0, failures - 1));
844
+ const capped = Math.min(max, exp);
845
+ const jitter = Math.max(0, Math.min(1, this.cfg.backoffJitter));
846
+ const delta = capped * jitter;
847
+ const min = Math.max(0, capped - delta);
848
+ const maxJ = capped + delta;
849
+ const r = Math.random();
850
+ return Math.floor(min + r * (maxJ - min));
851
+ }
852
+ bundleUrl() {
853
+ const u = new URL(this.cfg.baseUrl);
854
+ u.pathname = "/v1/runtime/bundle";
855
+ u.searchParams.set("projectId", this.cfg.projectId);
856
+ u.searchParams.set("env", this.cfg.env);
857
+ return u.toString();
858
+ }
859
+ commonHeaders(extra) {
860
+ return {
861
+ Authorization: `Bearer ${this.cfg.runtimeKey}`,
862
+ "User-Agent": this.cfg.userAgent ?? "govplane-runtime-sdk/0.x",
863
+ ...extra
864
+ };
865
+ }
866
+ async headBundle() {
867
+ const url = this.bundleUrl();
868
+ const { statusCode, headers, body } = await (0, import_undici.request)(url, {
869
+ method: "HEAD",
870
+ headers: this.commonHeaders(),
871
+ bodyTimeout: this.cfg.timeoutMs,
872
+ headersTimeout: this.cfg.timeoutMs
873
+ });
874
+ await body.text().catch(() => void 0);
875
+ if (statusCode === 401 || statusCode === 403) throw new Error(`Unauthorized (${statusCode})`);
876
+ if (statusCode >= 500) throw new Error(`Runtime server error (${statusCode})`);
877
+ if (statusCode !== 200 && statusCode !== 304) return this.cache.meta;
878
+ const etag = headers["etag"] ?? "";
879
+ if (!etag) return this.cache.meta;
880
+ const bundleVersionRaw = headers["x-gp-bundle-version"];
881
+ const updatedAt = headers["x-gp-updated-at"];
882
+ return {
883
+ etag,
884
+ bundleVersion: bundleVersionRaw ? Number(bundleVersionRaw) : void 0,
885
+ updatedAt
886
+ };
887
+ }
888
+ async getBundle() {
889
+ const url = this.bundleUrl();
890
+ const ifNoneMatch = this.cache.meta?.etag;
891
+ const { statusCode, headers, body } = await (0, import_undici.request)(url, {
892
+ method: "GET",
893
+ headers: this.commonHeaders(ifNoneMatch ? { "If-None-Match": ifNoneMatch } : void 0),
894
+ bodyTimeout: this.cfg.timeoutMs,
895
+ headersTimeout: this.cfg.timeoutMs
896
+ });
897
+ const txt = await body.text();
898
+ if (statusCode === 304) {
899
+ const etag2 = headers["etag"] ?? ifNoneMatch ?? "";
900
+ const bundleVersionRaw2 = headers["x-gp-bundle-version"];
901
+ const updatedAt2 = headers["x-gp-updated-at"];
902
+ const meta2 = etag2 ? { etag: etag2, bundleVersion: bundleVersionRaw2 ? Number(bundleVersionRaw2) : void 0, updatedAt: updatedAt2 } : this.cache.meta;
903
+ if (meta2) this.cache.meta = meta2;
904
+ return { changed: false, meta: meta2 };
905
+ }
906
+ if (statusCode === 401 || statusCode === 403) throw new Error(`Unauthorized (${statusCode})`);
907
+ if (statusCode >= 400) throw new Error(`Runtime HTTP error (${statusCode}): ${txt.slice(0, 200)}`);
908
+ const etag = headers["etag"] ?? "";
909
+ const bundleVersionRaw = headers["x-gp-bundle-version"];
910
+ const updatedAt = headers["x-gp-updated-at"];
911
+ const meta = {
912
+ etag: etag || (ifNoneMatch ?? ""),
913
+ bundleVersion: bundleVersionRaw ? Number(bundleVersionRaw) : void 0,
914
+ updatedAt
915
+ };
916
+ const parsed = JSON.parse(txt);
917
+ const normalized = parsed?.schemaVersion === 1 ? parsed : parsed?.data?.schemaVersion === 1 ? parsed.data : parsed?.data?.body?.schemaVersion === 1 ? parsed.data.body : parsed?.body?.schemaVersion === 1 ? parsed.body : parsed;
918
+ this.cache = { meta, bundle: normalized };
919
+ const nb = normalized;
920
+ if (nb && typeof nb === "object" && nb.schemaVersion === 1 && Array.isArray(nb.policies)) {
921
+ this.hasValidBundle = true;
922
+ }
923
+ return { changed: true, meta, bundle: normalized };
924
+ }
925
+ // Incident controls
926
+ enableBurst(input) {
927
+ const now = Date.now();
928
+ const until = now + Math.max(0, input.durationMs);
929
+ if (until > this.burstUntil) this.burstUntil = until;
930
+ if (Number.isFinite(input.pollMs) && input.pollMs > 0) {
931
+ this.cfg.burstPollMs = input.pollMs;
932
+ }
933
+ console.log(`[govplane][incident] burst enabled until ${new Date(this.burstUntil).toISOString()} (pollMs=${this.cfg.burstPollMs})`);
934
+ }
935
+ disableBurst() {
936
+ this.burstUntil = 0;
937
+ console.log(`[govplane][incident] burst disabled`);
938
+ }
939
+ applyIncidentFromEnv() {
940
+ const flag = this.cfg.incidentEnvFlag;
941
+ if (!flag) return;
942
+ const v = String(process.env[flag] ?? "").trim().toLowerCase();
943
+ const on = v === "1" || v === "true" || v === "yes" || v === "on";
944
+ if (on) {
945
+ this.enableBurst({ durationMs: this.cfg.burstDurationMs, pollMs: this.cfg.burstPollMs });
946
+ }
947
+ }
948
+ startIncidentFilePoller() {
949
+ const filePath = this.cfg.incidentFilePath;
950
+ if (!filePath) return;
951
+ if (this.incidentPollTimer) clearInterval(this.incidentPollTimer);
952
+ this.incidentPollTimer = setInterval(() => {
953
+ void this.checkIncidentFileOnce(filePath).catch(() => void 0);
954
+ }, this.cfg.incidentFilePollMs);
955
+ void this.checkIncidentFileOnce(filePath).catch(() => void 0);
956
+ }
957
+ async checkIncidentFileOnce(filePath) {
958
+ let mtimeMs;
959
+ try {
960
+ const st = await import_node_fs.promises.stat(filePath);
961
+ mtimeMs = st.mtimeMs;
962
+ } catch {
963
+ return;
964
+ }
965
+ if (this.incidentFileLastMtimeMs !== null && mtimeMs === this.incidentFileLastMtimeMs) return;
966
+ this.incidentFileLastMtimeMs = mtimeMs;
967
+ let txt;
968
+ try {
969
+ txt = await import_node_fs.promises.readFile(filePath, "utf8");
970
+ } catch {
971
+ return;
972
+ }
973
+ let payload;
974
+ try {
975
+ payload = JSON.parse(txt);
976
+ } catch {
977
+ return;
978
+ }
979
+ if (!payload || typeof payload !== "object") return;
980
+ if (payload.burst === true) {
981
+ this.enableBurst({
982
+ durationMs: payload.burstDurationMs ?? this.cfg.burstDurationMs,
983
+ pollMs: payload.burstPollMs ?? this.cfg.burstPollMs
984
+ });
985
+ if (this.isRunning) this.scheduleNext(0);
986
+ } else if (payload.burst === false) {
987
+ this.disableBurst();
988
+ }
989
+ if (payload.refreshNow === true) {
990
+ void this.refreshNow({ burst: payload.burst === true }).catch(() => void 0);
991
+ }
992
+ }
993
+ installIncidentSignal() {
994
+ if (this.installedSignalHandler) return;
995
+ if (this.cfg.incidentSignal === false) return;
996
+ const sig = this.cfg.incidentSignal;
997
+ if (!sig) return;
998
+ try {
999
+ this.sigHandler = () => {
1000
+ this.enableBurst({ durationMs: this.cfg.burstDurationMs, pollMs: this.cfg.burstPollMs });
1001
+ if (this.isRunning) this.scheduleNext(0);
1002
+ void this.refreshNow({ burst: true }).catch(() => void 0);
1003
+ };
1004
+ process.on(sig, this.sigHandler);
1005
+ this.installedSignalHandler = true;
1006
+ } catch {
1007
+ }
1008
+ }
1009
+ uninstallIncidentSignal() {
1010
+ if (!this.installedSignalHandler) return;
1011
+ if (this.cfg.incidentSignal === false) return;
1012
+ const sig = this.cfg.incidentSignal;
1013
+ if (!sig) return;
1014
+ try {
1015
+ if (this.sigHandler) process.off(sig, this.sigHandler);
1016
+ } catch {
1017
+ } finally {
1018
+ this.installedSignalHandler = false;
1019
+ this.sigHandler = void 0;
1020
+ }
1021
+ }
1022
+ };
1023
+
1024
+ // src/errors/Errors.ts
1025
+ var GovplaneError = class extends Error {
1026
+ constructor(message, code = "GP_ERROR", details) {
1027
+ super(message);
1028
+ this.code = code;
1029
+ this.details = details;
1030
+ this.name = "GovplaneError";
1031
+ }
1032
+ };
1033
+ var HttpError = class extends GovplaneError {
1034
+ constructor(message, status, headers, details) {
1035
+ super(message, "HTTP_ERROR", details);
1036
+ this.status = status;
1037
+ this.headers = headers;
1038
+ this.name = "HttpError";
1039
+ }
1040
+ };
1041
+
1042
+ // src/engine/formatTrace.ts
1043
+ function formatTrace(trace, opts = {}) {
1044
+ const lines = [];
1045
+ const hdr = `[govplane] traceId=${trace.traceId} sampled=${trace.sampled} policies=${trace.summary.policiesSeen} rules=${trace.summary.rulesSeen} matched=${trace.summary.matched}`;
1046
+ lines.push(hdr);
1047
+ if (trace.winner) {
1048
+ lines.push(
1049
+ `winner \u2192 policy=${trace.winner.policyKey} rule=${trace.winner.ruleId} effect=${trace.winner.effectType} priority=${trace.winner.priority}`
1050
+ );
1051
+ } else {
1052
+ lines.push(`winner \u2192 none (default decision)`);
1053
+ }
1054
+ if ("rules" in trace && opts.includeDiscarded) {
1055
+ const discarded = trace.rules.filter((r) => !r.matched);
1056
+ if (discarded.length) {
1057
+ lines.push(`discarded rules:`);
1058
+ for (const r of discarded) {
1059
+ lines.push(
1060
+ `- policy=${r.policyKey} rule=${r.ruleId} effect=${r.effectType ?? "?"} reason=${r.discardedReason}`
1061
+ );
1062
+ }
1063
+ }
1064
+ }
1065
+ if (opts.multiline) {
1066
+ return lines.join("\n");
1067
+ }
1068
+ return lines.join(" | ");
1069
+ }
1070
+ // Annotate the CommonJS export names for ESM import in node:
1071
+ 0 && (module.exports = {
1072
+ DEFAULT_CONTEXT_POLICY,
1073
+ GovplaneError,
1074
+ HttpError,
1075
+ RuntimeClient,
1076
+ createPolicyEngine,
1077
+ formatTrace,
1078
+ validateContext
1079
+ });
1080
+ //# sourceMappingURL=index.cjs.map