@govplane/runtime-sdk 0.2.4 → 0.5.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/dist/index.cjs CHANGED
@@ -30,19 +30,16 @@ __export(index_exports, {
30
30
  });
31
31
  module.exports = __toCommonJS(index_exports);
32
32
 
33
- // src/client/RuntimeClient.ts
34
- var import_undici = require("undici");
35
-
36
33
  // src/engine/context.ts
37
34
  var DEFAULT_CONTEXT_POLICY = {
38
35
  allowedKeys: [
39
- "ctx.plan",
40
- "ctx.country",
41
- "ctx.requestTier",
42
- "ctx.feature",
43
- "ctx.amount",
44
- "ctx.isAuthenticated",
45
- "ctx.role"
36
+ "plan",
37
+ "country",
38
+ "requestTier",
39
+ "feature",
40
+ "amount",
41
+ "isAuthenticated",
42
+ "role"
46
43
  ],
47
44
  maxStringLen: 64,
48
45
  maxArrayLen: 10,
@@ -70,19 +67,18 @@ var DEFAULT_POLICY = {
70
67
  blockLikelyPiiKeys: true
71
68
  };
72
69
  function validateContext(ctx, policy) {
73
- const allowed = new Set(policy.allowedKeys);
70
+ const allowed = new Set(policy.allowedKeys.map((k) => k.startsWith("ctx.") ? k.slice(4) : k));
74
71
  const maxStringLen = policy.maxStringLen ?? DEFAULT_POLICY.maxStringLen;
75
72
  const maxArrayLen = policy.maxArrayLen ?? DEFAULT_POLICY.maxArrayLen;
76
73
  const blockLikelyPiiKeys = policy.blockLikelyPiiKeys ?? DEFAULT_POLICY.blockLikelyPiiKeys;
77
74
  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}`);
75
+ if (!allowed.has(k)) {
76
+ throw new Error(`Context key not allowed: ${k}`);
81
77
  }
82
78
  if (blockLikelyPiiKeys) {
83
79
  for (const re of PII_KEY_PATTERNS) {
84
80
  if (re.test(k)) {
85
- throw new Error(`Context key looks like PII and is blocked: ${path}`);
81
+ throw new Error(`Context key looks like PII and is blocked: ${k}`);
86
82
  }
87
83
  }
88
84
  }
@@ -90,25 +86,26 @@ function validateContext(ctx, policy) {
90
86
  const t = typeof v;
91
87
  if (t === "boolean" || t === "number") continue;
92
88
  if (t === "string") {
93
- if (v.length > maxStringLen) throw new Error(`Context value too long: ${path}`);
89
+ if (v.length > maxStringLen) throw new Error(`Context value too long: ${k}`);
94
90
  continue;
95
91
  }
96
92
  if (Array.isArray(v)) {
97
- if (v.length > maxArrayLen) throw new Error(`Context array too long: ${path}`);
93
+ if (v.length > maxArrayLen) throw new Error(`Context array too long: ${k}`);
98
94
  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}`);
95
+ if (typeof it !== "string") throw new Error(`Invalid array value type: ${k}`);
96
+ if (it.length > maxStringLen) throw new Error(`Invalid array value length: ${k}`);
101
97
  }
102
98
  continue;
103
99
  }
104
- throw new Error(`Invalid context type for ${path}`);
100
+ throw new Error(`Invalid context type for ${k}`);
105
101
  }
106
102
  }
107
103
 
108
104
  // src/engine/when.ts
109
- function getPath(obj, path) {
110
- const parts = path.split(".");
111
- let cur = obj;
105
+ function getPath(ctx, path) {
106
+ const normalizedPath = path.startsWith("ctx.") ? path.slice(4) : path;
107
+ const parts = normalizedPath.split(".");
108
+ let cur = ctx;
112
109
  for (const p of parts) {
113
110
  if (!cur || typeof cur !== "object") return void 0;
114
111
  cur = cur[p];
@@ -117,30 +114,40 @@ function getPath(obj, path) {
117
114
  }
118
115
  function evalWhen(node, ctx) {
119
116
  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;
117
+ case "and": {
118
+ const children = node.conditions ?? node.args ?? [];
119
+ return children.every((n) => evalWhen(n, ctx));
120
+ }
121
+ case "or": {
122
+ const children = node.conditions ?? node.args ?? [];
123
+ return children.some((n) => evalWhen(n, ctx));
124
+ }
125
+ case "not": {
126
+ const child = node.condition ?? node.arg;
127
+ return !evalWhen(child, ctx);
128
+ }
129
+ case "exists": {
130
+ const v = getPath(ctx, node.path);
131
+ return v !== void 0 && v !== null;
132
+ }
128
133
  case "in": {
129
- const v = getPath({ ctx }, node.path);
134
+ const v = getPath(ctx, node.path);
130
135
  return node.values.some((x) => x === v);
131
136
  }
132
137
  case "eq":
133
- return getPath({ ctx }, node.path) === node.value;
138
+ return getPath(ctx, node.path) === node.value;
139
+ // `neq` is the canonical spelling; `ne` is kept for legacy bundles
140
+ case "neq":
134
141
  case "ne":
135
- return getPath({ ctx }, node.path) !== node.value;
142
+ return getPath(ctx, node.path) !== node.value;
136
143
  case "gt":
137
- return Number(getPath({ ctx }, node.path)) > Number(node.value);
144
+ return Number(getPath(ctx, node.path)) > Number(node.value);
138
145
  case "gte":
139
- return Number(getPath({ ctx }, node.path)) >= Number(node.value);
146
+ return Number(getPath(ctx, node.path)) >= Number(node.value);
140
147
  case "lt":
141
- return Number(getPath({ ctx }, node.path)) < Number(node.value);
148
+ return Number(getPath(ctx, node.path)) < Number(node.value);
142
149
  case "lte":
143
- return Number(getPath({ ctx }, node.path)) <= Number(node.value);
150
+ return Number(getPath(ctx, node.path)) <= Number(node.value);
144
151
  default:
145
152
  return false;
146
153
  }
@@ -350,7 +357,7 @@ function createPolicyEngine(opts) {
350
357
  policiesSeen: 0,
351
358
  rulesSeen: 0,
352
359
  matched: 0,
353
- considered: { kill_switch: 0, deny: 0, throttle: 0, allow: 0 }
360
+ considered: { kill_switch: 0, deny: 0, throttle: 0, allow: 0, custom: 0 }
354
361
  },
355
362
  rules: []
356
363
  } : null;
@@ -369,23 +376,24 @@ function createPolicyEngine(opts) {
369
376
  const denies = [];
370
377
  const throttles = [];
371
378
  const allows = [];
379
+ const customs = [];
372
380
  const policies = bundle.policies ?? [];
373
381
  if (wantTrace) traceBase.summary.policiesSeen = policies.length;
374
382
  for (const p of policies) {
375
383
  const policyKey = String(p.policyKey ?? "");
376
384
  const rules = p.rules ?? [];
385
+ let policyHadMatch = false;
377
386
  for (const r of rules) {
378
387
  if (wantTrace) traceBase.summary.rulesSeen += 1;
379
388
  const ruleId = String(r?.id ?? "");
380
389
  const priority = Number(r?.priority ?? 0);
381
- const effectType = safeEffectType(r?.effect);
382
390
  if (r?.status !== "active") {
383
391
  if (wantTrace) {
384
392
  traceBase.rules.push({
385
393
  policyKey,
386
394
  ruleId,
387
395
  priority,
388
- effectType,
396
+ effectType: safeEffectType(r?.effect),
389
397
  matched: false,
390
398
  discardedReason: "disabled"
391
399
  });
@@ -398,26 +406,39 @@ function createPolicyEngine(opts) {
398
406
  policyKey,
399
407
  ruleId,
400
408
  priority,
401
- effectType,
409
+ effectType: safeEffectType(r?.effect),
402
410
  matched: false,
403
411
  discardedReason: "target_mismatch"
404
412
  });
405
413
  }
406
414
  continue;
407
415
  }
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
- });
416
+ let resolvedEffect;
417
+ if (r.when !== void 0) {
418
+ const whenResult = evalWhen(r.when, ctx);
419
+ if (whenResult) {
420
+ resolvedEffect = r.thenEffect ?? r.effect;
421
+ } else {
422
+ if (r.elseEffect) {
423
+ resolvedEffect = r.elseEffect;
424
+ } else {
425
+ if (wantTrace) {
426
+ traceBase.rules.push({
427
+ policyKey,
428
+ ruleId,
429
+ priority,
430
+ effectType: safeEffectType(r?.effect),
431
+ matched: false,
432
+ discardedReason: "when_false"
433
+ });
434
+ }
435
+ continue;
436
+ }
418
437
  }
419
- continue;
438
+ } else {
439
+ resolvedEffect = r.effect;
420
440
  }
441
+ const effectType = safeEffectType(resolvedEffect);
421
442
  if (!effectType) {
422
443
  if (wantTrace) {
423
444
  traceBase.rules.push({
@@ -431,6 +452,7 @@ function createPolicyEngine(opts) {
431
452
  }
432
453
  continue;
433
454
  }
455
+ policyHadMatch = true;
434
456
  if (wantTrace) {
435
457
  traceBase.summary.matched += 1;
436
458
  traceBase.rules.push({
@@ -441,7 +463,7 @@ function createPolicyEngine(opts) {
441
463
  matched: true
442
464
  });
443
465
  }
444
- const m = { policyKey, ruleId, priority, effect: r.effect };
466
+ const m = { policyKey, ruleId, priority, effect: resolvedEffect };
445
467
  if (effectType === "kill_switch") {
446
468
  kills.push(m);
447
469
  if (wantTrace) traceBase.summary.considered.kill_switch += 1;
@@ -454,16 +476,55 @@ function createPolicyEngine(opts) {
454
476
  } else if (effectType === "allow") {
455
477
  allows.push(m);
456
478
  if (wantTrace) traceBase.summary.considered.allow += 1;
479
+ } else if (effectType === "custom") {
480
+ customs.push(m);
481
+ if (wantTrace) traceBase.summary.considered.custom += 1;
482
+ }
483
+ }
484
+ if (!policyHadMatch) {
485
+ const defaults = p.defaults;
486
+ if (defaults?.effect) {
487
+ const defEffectType = String(defaults.effect);
488
+ let syntheticEffect;
489
+ if (defEffectType === "custom") {
490
+ syntheticEffect = { type: "custom", value: String(defaults.customEffect ?? "") };
491
+ } else if (defEffectType === "throttle" && defaults.throttle) {
492
+ syntheticEffect = { type: "throttle", throttle: defaults.throttle };
493
+ } else if (defEffectType === "kill_switch" && defaults.killSwitch) {
494
+ syntheticEffect = { type: "kill_switch", killSwitch: defaults.killSwitch };
495
+ } else if (defEffectType === "allow" || defEffectType === "deny") {
496
+ syntheticEffect = { type: defEffectType };
497
+ }
498
+ if (syntheticEffect) {
499
+ const dm = { policyKey, ruleId: "__default__", priority: -1, effect: syntheticEffect };
500
+ if (defEffectType === "kill_switch") {
501
+ kills.push(dm);
502
+ if (wantTrace) traceBase.summary.considered.kill_switch += 1;
503
+ } else if (defEffectType === "deny") {
504
+ denies.push(dm);
505
+ if (wantTrace) traceBase.summary.considered.deny += 1;
506
+ } else if (defEffectType === "throttle") {
507
+ throttles.push(dm);
508
+ if (wantTrace) traceBase.summary.considered.throttle += 1;
509
+ } else if (defEffectType === "allow") {
510
+ allows.push(dm);
511
+ if (wantTrace) traceBase.summary.considered.allow += 1;
512
+ } else if (defEffectType === "custom") {
513
+ customs.push(dm);
514
+ if (wantTrace) traceBase.summary.considered.custom += 1;
515
+ }
516
+ }
457
517
  }
458
518
  }
459
519
  }
460
520
  if (kills.length) {
461
521
  const w = kills.sort(byRuleOrder)[0];
522
+ const isDefault = w.ruleId === "__default__";
462
523
  const decision2 = {
463
524
  decision: "kill_switch",
464
- reason: "rule",
525
+ reason: isDefault ? "default" : "rule",
465
526
  policyKey: w.policyKey,
466
- ruleId: w.ruleId,
527
+ ruleId: isDefault ? void 0 : w.ruleId,
467
528
  killSwitch: w.effect.killSwitch
468
529
  };
469
530
  if (!wantTrace) return decision2;
@@ -477,11 +538,12 @@ function createPolicyEngine(opts) {
477
538
  }
478
539
  if (denies.length) {
479
540
  const w = denies.sort(byRuleOrder)[0];
541
+ const isDefault = w.ruleId === "__default__";
480
542
  const decision2 = {
481
543
  decision: "deny",
482
- reason: "rule",
544
+ reason: isDefault ? "default" : "rule",
483
545
  policyKey: w.policyKey,
484
- ruleId: w.ruleId
546
+ ruleId: isDefault ? void 0 : w.ruleId
485
547
  };
486
548
  if (!wantTrace) return decision2;
487
549
  return {
@@ -501,11 +563,12 @@ function createPolicyEngine(opts) {
501
563
  if (A.windowSeconds !== B.windowSeconds) return A.windowSeconds - B.windowSeconds;
502
564
  return byRuleOrder(a, b);
503
565
  })[0];
566
+ const isDefault = w.ruleId === "__default__";
504
567
  const decision2 = {
505
568
  decision: "throttle",
506
- reason: "rule",
569
+ reason: isDefault ? "default" : "rule",
507
570
  policyKey: w.policyKey,
508
- ruleId: w.ruleId,
571
+ ruleId: isDefault ? void 0 : w.ruleId,
509
572
  throttle: w.effect.throttle
510
573
  };
511
574
  if (!wantTrace) return decision2;
@@ -519,11 +582,12 @@ function createPolicyEngine(opts) {
519
582
  }
520
583
  if (allows.length) {
521
584
  const w = allows.sort(byRuleOrder)[0];
585
+ const isDefault = w.ruleId === "__default__";
522
586
  const decision2 = {
523
587
  decision: "allow",
524
- reason: "rule",
588
+ reason: isDefault ? "default" : "rule",
525
589
  policyKey: w.policyKey,
526
- ruleId: w.ruleId
590
+ ruleId: isDefault ? void 0 : w.ruleId
527
591
  };
528
592
  if (!wantTrace) return decision2;
529
593
  return {
@@ -534,6 +598,34 @@ function createPolicyEngine(opts) {
534
598
  }
535
599
  };
536
600
  }
601
+ if (customs.length) {
602
+ const w = customs.sort(byRuleOrder)[0];
603
+ const isDefault = w.ruleId === "__default__";
604
+ const rawValue = String(w.effect.value ?? "");
605
+ let parsedValue;
606
+ if (opts.parseCustomEffect) {
607
+ try {
608
+ parsedValue = JSON.parse(rawValue);
609
+ } catch {
610
+ }
611
+ }
612
+ const decision2 = {
613
+ decision: "custom",
614
+ reason: isDefault ? "default" : "rule",
615
+ policyKey: w.policyKey,
616
+ ruleId: isDefault ? void 0 : w.ruleId,
617
+ value: rawValue,
618
+ ...parsedValue !== void 0 ? { parsedValue } : {}
619
+ };
620
+ if (!wantTrace) return decision2;
621
+ return {
622
+ ...decision2,
623
+ trace: {
624
+ ...traceBase,
625
+ winner: { policyKey: w.policyKey, ruleId: w.ruleId, effectType: "custom", priority: w.priority }
626
+ }
627
+ };
628
+ }
537
629
  const decision = { decision: "deny", reason: "default" };
538
630
  if (!wantTrace) return decision;
539
631
  return {
@@ -637,6 +729,7 @@ var RuntimeClient = class {
637
729
  installedSignalHandler = false;
638
730
  sigHandler;
639
731
  constructor(config) {
732
+ console.log("[RuntimeClient] initializing with config");
640
733
  this.cfg = {
641
734
  ...config,
642
735
  pollMs: config.pollMs ?? 5e3,
@@ -655,6 +748,7 @@ var RuntimeClient = class {
655
748
  getBundle: () => this.cache.bundle,
656
749
  validateContext: config.engine?.validateContext !== false,
657
750
  contextPolicy: config.engine?.contextPolicy,
751
+ parseCustomEffect: config.engine?.parseCustomEffect,
658
752
  traceDefaults: config.trace?.defaults,
659
753
  traceSink: config.trace?.onDecisionTrace,
660
754
  traceSinkAsync: config.trace?.onDecisionTraceAsync,
@@ -851,34 +945,38 @@ var RuntimeClient = class {
851
945
  }
852
946
  bundleUrl() {
853
947
  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
948
  return u.toString();
858
949
  }
859
950
  commonHeaders(extra) {
860
951
  return {
861
- Authorization: `Bearer ${this.cfg.runtimeKey}`,
952
+ Authorization: this.cfg.runtimeKey,
862
953
  "User-Agent": this.cfg.userAgent ?? "govplane-runtime-sdk/0.x",
863
954
  ...extra
864
955
  };
865
956
  }
957
+ async fetchWithTimeout(url, init) {
958
+ const ac = new AbortController();
959
+ const t = setTimeout(() => ac.abort(), this.cfg.timeoutMs);
960
+ try {
961
+ return await fetch(url, { ...init, signal: ac.signal });
962
+ } finally {
963
+ clearTimeout(t);
964
+ }
965
+ }
866
966
  async headBundle() {
867
967
  const url = this.bundleUrl();
868
- const { statusCode, headers, body } = await (0, import_undici.request)(url, {
968
+ const res = await this.fetchWithTimeout(url, {
869
969
  method: "HEAD",
870
- headers: this.commonHeaders(),
871
- bodyTimeout: this.cfg.timeoutMs,
872
- headersTimeout: this.cfg.timeoutMs
970
+ headers: this.commonHeaders()
873
971
  });
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"] ?? "";
972
+ console.log(`[RuntimeClient] HEAD bundle status=${res.status}`);
973
+ if (res.status === 401 || res.status === 403) throw new Error(`Unauthorized (${res.status})`);
974
+ if (res.status >= 500) throw new Error(`Runtime server error (${res.status})`);
975
+ if (res.status !== 200 && res.status !== 304) return this.cache.meta;
976
+ const etag = res.headers.get("etag") ?? "";
879
977
  if (!etag) return this.cache.meta;
880
- const bundleVersionRaw = headers["x-gp-bundle-version"];
881
- const updatedAt = headers["x-gp-updated-at"];
978
+ const bundleVersionRaw = res.headers.get("x-gp-bundle-version") ?? void 0;
979
+ const updatedAt = res.headers.get("x-gp-updated-at") ?? void 0;
882
980
  return {
883
981
  etag,
884
982
  bundleVersion: bundleVersionRaw ? Number(bundleVersionRaw) : void 0,
@@ -888,26 +986,24 @@ var RuntimeClient = class {
888
986
  async getBundle() {
889
987
  const url = this.bundleUrl();
890
988
  const ifNoneMatch = this.cache.meta?.etag;
891
- const { statusCode, headers, body } = await (0, import_undici.request)(url, {
989
+ const res = await this.fetchWithTimeout(url, {
892
990
  method: "GET",
893
- headers: this.commonHeaders(ifNoneMatch ? { "If-None-Match": ifNoneMatch } : void 0),
894
- bodyTimeout: this.cfg.timeoutMs,
895
- headersTimeout: this.cfg.timeoutMs
991
+ headers: this.commonHeaders(ifNoneMatch ? { "If-None-Match": ifNoneMatch } : void 0)
896
992
  });
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"];
993
+ const txt = await res.text();
994
+ if (res.status === 304) {
995
+ const etag2 = res.headers.get("etag") ?? ifNoneMatch ?? "";
996
+ const bundleVersionRaw2 = res.headers.get("x-gp-bundle-version") ?? void 0;
997
+ const updatedAt2 = res.headers.get("x-gp-updated-at") ?? void 0;
902
998
  const meta2 = etag2 ? { etag: etag2, bundleVersion: bundleVersionRaw2 ? Number(bundleVersionRaw2) : void 0, updatedAt: updatedAt2 } : this.cache.meta;
903
999
  if (meta2) this.cache.meta = meta2;
904
1000
  return { changed: false, meta: meta2 };
905
1001
  }
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"];
1002
+ if (res.status === 401 || res.status === 403) throw new Error(`Unauthorized (${res.status})`);
1003
+ if (res.status >= 400) throw new Error(`Runtime HTTP error (${res.status}): ${txt.slice(0, 200)}`);
1004
+ const etag = res.headers.get("etag") ?? "";
1005
+ const bundleVersionRaw = res.headers.get("x-gp-bundle-version") ?? void 0;
1006
+ const updatedAt = res.headers.get("x-gp-updated-at") ?? void 0;
911
1007
  const meta = {
912
1008
  etag: etag || (ifNoneMatch ?? ""),
913
1009
  bundleVersion: bundleVersionRaw ? Number(bundleVersionRaw) : void 0,