@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.
@@ -0,0 +1,251 @@
1
+ ---
2
+ description: >-
3
+ Use when / thenEffect / elseEffect to apply different effects based on
4
+ run-time context, without writing separate rules.
5
+ ---
6
+
7
+ # Conditional Rules
8
+
9
+ ## Overview
10
+
11
+ Every rule supports an optional `when` clause — a condition AST evaluated client-side against the `context` you supply at call time. The server compiles and distributes the AST as part of the bundle; it never executes it.
12
+
13
+ **Effect resolution:**
14
+
15
+ ```
16
+ when is absent → always apply effect
17
+ when evaluates to true → apply thenEffect (fallback: effect)
18
+ when evaluates to false → apply elseEffect (fallback: skip rule entirely)
19
+ ```
20
+
21
+ ---
22
+
23
+ ## Rule shape
24
+
25
+ ```typescript
26
+ {
27
+ id: string;
28
+ status: "active" | "disabled";
29
+ priority: number;
30
+ target: { service: string; resource: string; action: string };
31
+
32
+ when?: ConditionAST; // optional condition
33
+ thenEffect?: Effect; // applied when when == true (defaults to effect)
34
+ elseEffect?: Effect; // applied when when == false (defaults to skip)
35
+ effect: Effect; // unconditional / fallback effect
36
+ }
37
+ ```
38
+
39
+ | Field | Required | Description |
40
+ |---|---|---|
41
+ | `when` | No | Condition AST evaluated against the call-time context. |
42
+ | `thenEffect` | No | Effect when `when` is `true`. Defaults to `effect` if absent. |
43
+ | `elseEffect` | No | Effect when `when` is `false`. Rule is skipped if absent. |
44
+ | `effect` | Yes | Unconditional effect (used when `when` is absent, or as fallback for `thenEffect`). |
45
+
46
+ ---
47
+
48
+ ## Condition operators
49
+
50
+ ### Comparison — `eq`, `neq`, `gt`, `gte`, `lt`, `lte`
51
+
52
+ Compares the value at `path` in the evaluation context with a scalar `value`.
53
+
54
+ ```json
55
+ { "op": "eq", "path": "role", "value": "admin" }
56
+ { "op": "neq", "path": "plan", "value": "free" }
57
+ { "op": "gte", "path": "amount","value": 1000 }
58
+ { "op": "lt", "path": "amount","value": 50 }
59
+ ```
60
+
61
+ `value` may be `string`, `number`, `boolean`, or `null`.
62
+
63
+ ### Membership — `in`
64
+
65
+ True when the value at `path` equals one of the entries in `values`.
66
+
67
+ ```json
68
+ { "op": "in", "path": "role", "values": ["admin", "superuser", "moderator"] }
69
+ ```
70
+
71
+ ### Existence — `exists`
72
+
73
+ True when the value at `path` is present and is not `null` or `undefined`.
74
+
75
+ ```json
76
+ { "op": "exists", "path": "requestTier" }
77
+ ```
78
+
79
+ ### Logical — `and` / `or`
80
+
81
+ Short-circuit combinators over a non-empty `conditions` array.
82
+
83
+ ```json
84
+ {
85
+ "op": "and",
86
+ "conditions": [
87
+ { "op": "eq", "path": "isAuthenticated", "value": true },
88
+ { "op": "exists", "path": "plan" }
89
+ ]
90
+ }
91
+ ```
92
+
93
+ ```json
94
+ {
95
+ "op": "or",
96
+ "conditions": [
97
+ { "op": "eq", "path": "role", "value": "admin" },
98
+ { "op": "eq", "path": "role", "value": "superuser" }
99
+ ]
100
+ }
101
+ ```
102
+
103
+ ### Negation — `not`
104
+
105
+ Inverts a single child condition.
106
+
107
+ ```json
108
+ {
109
+ "op": "not",
110
+ "condition": { "op": "eq", "path": "role", "value": "banned" }
111
+ }
112
+ ```
113
+
114
+ ### `path` notation
115
+
116
+ `path` is a dot-separated key path resolved against the `context` object:
117
+
118
+ ```typescript
119
+ client.evaluate({
120
+ target: { service: "api", resource: "export", action: "create" },
121
+ context: {
122
+ role: "editor", // → "role"
123
+ plan: "enterprise", // → "plan"
124
+ amount: 250, // → "amount"
125
+ },
126
+ });
127
+ ```
128
+
129
+ | Namespace | Example paths |
130
+ |---|---|
131
+ | Top-level | `role`, `plan`, `isAuthenticated` |
132
+ | Nested | `user.role`, `tenant.plan`, `tenant.flags.beta` |
133
+
134
+ {% hint style="info" %}
135
+ The `ctx.` prefix is optional and stripped automatically. `ctx.plan` and `plan` are equivalent.
136
+ {% endhint %}
137
+
138
+ ---
139
+
140
+ ## Examples
141
+
142
+ ### Simple boolean flag
143
+
144
+ ```json
145
+ {
146
+ "id": "r_beta",
147
+ "status": "active",
148
+ "priority": 10,
149
+ "target": { "service": "app", "resource": "feature/beta-dashboard", "action": "read" },
150
+ "when": { "op": "eq", "path": "isAuthenticated", "value": true },
151
+ "thenEffect": { "type": "allow" },
152
+ "elseEffect": { "type": "deny" },
153
+ "effect": { "type": "deny" }
154
+ }
155
+ ```
156
+
157
+ ### Role-based access using `in`
158
+
159
+ ```json
160
+ {
161
+ "id": "r_admin_write",
162
+ "status": "active",
163
+ "priority": 5,
164
+ "target": { "service": "control", "resource": "settings", "action": "write" },
165
+ "when": {
166
+ "op": "in",
167
+ "path": "role",
168
+ "values": ["admin", "superuser"]
169
+ },
170
+ "thenEffect": { "type": "allow" },
171
+ "effect": { "type": "deny" }
172
+ }
173
+ ```
174
+
175
+ Evaluated with `context: { role: "viewer" }` → `deny` (elseEffect absent → rule skipped → falls to policy default or next rule).
176
+ Evaluated with `context: { role: "admin" }` → `allow`.
177
+
178
+ ### Plan-gated throttling
179
+
180
+ Free-plan users are throttled; paid users are allowed freely:
181
+
182
+ ```json
183
+ {
184
+ "id": "r_plan_throttle",
185
+ "status": "active",
186
+ "priority": 20,
187
+ "target": { "service": "api", "resource": "export", "action": "create" },
188
+ "when": { "op": "eq", "path": "plan", "value": "free" },
189
+ "thenEffect": {
190
+ "type": "throttle",
191
+ "throttle": { "limit": 5, "windowSeconds": 3600, "key": "tenant" }
192
+ },
193
+ "elseEffect": { "type": "allow" },
194
+ "effect": { "type": "allow" }
195
+ }
196
+ ```
197
+
198
+ ### Compound condition — nested `and` / `or`
199
+
200
+ ```json
201
+ {
202
+ "id": "r_report_export",
203
+ "status": "active",
204
+ "priority": 15,
205
+ "target": { "service": "app", "resource": "report", "action": "export" },
206
+ "when": {
207
+ "op": "and",
208
+ "conditions": [
209
+ { "op": "eq", "path": "isAuthenticated", "value": true },
210
+ {
211
+ "op": "or",
212
+ "conditions": [
213
+ { "op": "eq", "path": "role", "value": "admin" },
214
+ { "op": "eq", "path": "plan", "value": "enterprise" }
215
+ ]
216
+ }
217
+ ]
218
+ },
219
+ "thenEffect": { "type": "allow" },
220
+ "effect": { "type": "deny" }
221
+ }
222
+ ```
223
+
224
+ ---
225
+
226
+ ## Context at call time
227
+
228
+ ```typescript
229
+ client.evaluate({
230
+ target: { service: "app", resource: "report", action: "export" },
231
+ context: {
232
+ isAuthenticated: true,
233
+ role: "admin",
234
+ plan: "pro",
235
+ },
236
+ });
237
+ ```
238
+
239
+ {% hint style="warning" %}
240
+ Only keys declared in your `ContextPolicy.allowedKeys` are permitted. Passing undeclared keys throws a validation error at evaluation time. See [Context Policy](ContextPolicy.md).
241
+ {% endhint %}
242
+
243
+ ---
244
+
245
+ {% content-ref url="ContextPolicy.md" %}
246
+ [Context Policy & PII Safety](ContextPolicy.md)
247
+ {% endcontent-ref %}
248
+
249
+ {% content-ref url="PolicyDefaults.md" %}
250
+ [Policy Defaults](PolicyDefaults.md)
251
+ {% endcontent-ref %}
@@ -0,0 +1,196 @@
1
+ ---
2
+ description: >-
3
+ Control which keys and values are permitted in the evaluation context to keep
4
+ PII out of the policy evaluation path.
5
+ ---
6
+
7
+ # Context Policy & PII Safety
8
+
9
+ ## Overview
10
+
11
+ The `context` object you pass to `evaluate()` is tested against rule `when` conditions. To ensure no personally identifiable information enters the evaluation path, the engine enforces a configurable **context policy** before every evaluation.
12
+
13
+ Validation happens synchronously, in-process, before any rule is tested. A violation throws an `Error` immediately.
14
+
15
+ ---
16
+
17
+ ## Default context policy
18
+
19
+ Out of the box the following keys are allowed:
20
+
21
+ ```typescript
22
+ const DEFAULT_CONTEXT_POLICY: ContextPolicy = {
23
+ allowedKeys: [
24
+ "plan",
25
+ "country",
26
+ "requestTier",
27
+ "feature",
28
+ "amount",
29
+ "isAuthenticated",
30
+ "role",
31
+ ],
32
+ maxStringLen: 64,
33
+ maxArrayLen: 10,
34
+ blockLikelyPiiKeys: true,
35
+ };
36
+ ```
37
+
38
+ ---
39
+
40
+ ## `ContextPolicy` shape
41
+
42
+ ```typescript
43
+ type ContextPolicy = {
44
+ allowedKeys: string[]; // keys permitted in context
45
+ maxStringLen: number; // max length for string values
46
+ maxArrayLen: number; // max length for array values
47
+ blockLikelyPiiKeys: boolean; // block heuristic PII key names
48
+ };
49
+ ```
50
+
51
+ | Field | Default | Description |
52
+ |---|---|---|
53
+ | `allowedKeys` | see above | Exhaustive list of permitted top-level context keys. Any unlisted key throws. |
54
+ | `maxStringLen` | `64` | String values exceeding this length throw. |
55
+ | `maxArrayLen` | `10` | Arrays exceeding this length throw. Array elements must all be strings. |
56
+ | `blockLikelyPiiKeys` | `true` | Blocks keys matching common PII patterns even if they appear in `allowedKeys` (see [Blocked patterns](#blocked-key-patterns) below). |
57
+
58
+ ---
59
+
60
+ ## Configuring a custom policy
61
+
62
+ Pass `engine.contextPolicy` when constructing `RuntimeClient`:
63
+
64
+ ```typescript
65
+ const client = new RuntimeClient({
66
+ baseUrl: "https://runtime.govplane.io",
67
+ runtimeKey: "rk_live_••••••••",
68
+ engine: {
69
+ contextPolicy: {
70
+ allowedKeys: ["plan", "role", "country", "requestTier", "featureSet"],
71
+ maxStringLen: 128,
72
+ maxArrayLen: 20,
73
+ blockLikelyPiiKeys: true,
74
+ },
75
+ },
76
+ });
77
+ ```
78
+
79
+ Or with `createPolicyEngine` directly:
80
+
81
+ ```typescript
82
+ import { createPolicyEngine } from "@govplane/runtime-sdk";
83
+
84
+ const engine = createPolicyEngine({
85
+ getBundle: () => bundle,
86
+ contextPolicy: {
87
+ allowedKeys: ["plan", "role", "country"],
88
+ maxStringLen: 64,
89
+ maxArrayLen: 10,
90
+ blockLikelyPiiKeys: true,
91
+ },
92
+ });
93
+ ```
94
+
95
+ ---
96
+
97
+ ## Disabling validation
98
+
99
+ {% hint style="danger" %}
100
+ Disabling context validation removes all PII safety guarantees. Only use this in **test environments** or when you control the call sites 100%.
101
+ {% endhint %}
102
+
103
+ ```typescript
104
+ const client = new RuntimeClient({
105
+ ...
106
+ engine: { validateContext: false },
107
+ });
108
+ ```
109
+
110
+ ---
111
+
112
+ ## Blocked key patterns
113
+
114
+ When `blockLikelyPiiKeys: true`, the following patterns are tested against every key name (case-insensitive). A match throws even if the key is in `allowedKeys`:
115
+
116
+ | Pattern | Blocks keys containing… |
117
+ |---|---|
118
+ | `email` | `email`, `userEmail`, `contactEmail` |
119
+ | `phone` / `mobile` | `phone`, `mobileNumber` |
120
+ | `name` / `firstname` / `lastname` | `displayName`, `firstName` |
121
+ | `address` / `street` | `address`, `streetAddress` |
122
+ | `postcode` / `postal` | `postalCode`, `postcode` |
123
+ | `city` | `city`, `hometown` |
124
+ | `ip` | `ip`, `ipAddress`, `clientIp` |
125
+ | `ssn` | `ssn` |
126
+ | `dni` / `nie` | `dni`, `nie` |
127
+ | `passport` | `passportNumber` |
128
+
129
+ ---
130
+
131
+ ## Permitted value types
132
+
133
+ | Type | Notes |
134
+ |---|---|
135
+ | `string` | Length must not exceed `maxStringLen`. |
136
+ | `number` | No length restriction. |
137
+ | `boolean` | No restriction. |
138
+ | `null` / `undefined` | Allowed; the key is resolved as not-present in `when` conditions. |
139
+ | `string[]` | Length must not exceed `maxArrayLen`; each element must be a string within `maxStringLen`. |
140
+ | `object` | **Not permitted** at the top level. Nested structures are not validated and will throw. |
141
+
142
+ ---
143
+
144
+ ## Example: valid and invalid contexts
145
+
146
+ ```typescript
147
+ // ✅ Valid
148
+ client.evaluate({
149
+ target: { service: "api", resource: "export", action: "create" },
150
+ context: {
151
+ plan: "enterprise",
152
+ role: "editor",
153
+ isAuthenticated: true,
154
+ amount: 1500,
155
+ country: "US",
156
+ },
157
+ });
158
+
159
+ // ❌ Throws — "email" is blocked by PII heuristic
160
+ client.evaluate({
161
+ context: { email: "user@example.com" },
162
+ ...
163
+ });
164
+
165
+ // ❌ Throws — "orgId" is not in allowedKeys
166
+ client.evaluate({
167
+ context: { orgId: "org_abc" },
168
+ ...
169
+ });
170
+
171
+ // ❌ Throws — string value exceeds maxStringLen
172
+ client.evaluate({
173
+ context: { plan: "x".repeat(100) },
174
+ ...
175
+ });
176
+ ```
177
+
178
+ ---
179
+
180
+ ## Nested context paths in `when` conditions
181
+
182
+ Even though the context policy only validates top-level keys, `when` conditions can reference nested paths using dot notation. The keys used in `path` must still correspond to allowed top-level namespaces.
183
+
184
+ {% hint style="info" %}
185
+ If you use nested context objects (e.g. `user.role`), the top-level key `user` must be in `allowedKeys`. The validation engine does not recurse into nested objects — it only validates the top-level keys and their scalar/array values.
186
+ {% endhint %}
187
+
188
+ ---
189
+
190
+ {% content-ref url="ConditionalRules.md" %}
191
+ [Conditional Rules](ConditionalRules.md)
192
+ {% endcontent-ref %}
193
+
194
+ {% content-ref url="../reference/Configuration.md" %}
195
+ [Configuration Reference](../reference/Configuration.md)
196
+ {% endcontent-ref %}
@@ -0,0 +1,217 @@
1
+ ---
2
+ description: >-
3
+ Return arbitrary string or JSON payloads from policy rules — useful for
4
+ feature flags, A/B variants, and structured metadata.
5
+ ---
6
+
7
+ # Custom Effects
8
+
9
+ ## Overview
10
+
11
+ A `custom` effect lets a rule or policy default carry an arbitrary **string value** back to the caller. That string can be anything — a plain flag, a JSON object, a variant name, or a serialised configuration blob.
12
+
13
+ Custom effects fill the gap when none of the standard effects (`allow`, `deny`, `throttle`, `kill_switch`) are expressive enough. Common use cases:
14
+
15
+ * Feature-flag payloads per user/plan/experiment
16
+ * A/B test variant assignments
17
+ * Per-tenant configuration overlays
18
+ * Structured response metadata injected by the policy layer
19
+
20
+ ---
21
+
22
+ ## Bundle shape
23
+
24
+ ### In a rule
25
+
26
+ ```json
27
+ {
28
+ "id": "r_flags_pro",
29
+ "status": "active",
30
+ "priority": 10,
31
+ "target": { "service": "app", "resource": "features", "action": "read" },
32
+ "effect": {
33
+ "type": "custom",
34
+ "value": "{\"newCheckout\": true, \"aiSearch\": true, \"variant\": \"B\"}"
35
+ }
36
+ }
37
+ ```
38
+
39
+ ### As a policy default
40
+
41
+ ```json
42
+ {
43
+ "policyKey": "feature-flags",
44
+ "activeVersion": 3,
45
+ "defaults": {
46
+ "effect": "custom",
47
+ "customEffect": "{\"newCheckout\": false, \"aiSearch\": false, \"variant\": \"A\"}"
48
+ },
49
+ "rules": []
50
+ }
51
+ ```
52
+
53
+ {% hint style="info" %}
54
+ In a **rule** the field is `effect.value`. In a **policy default** the field is `defaults.customEffect`. The engine normalises both internally.
55
+ {% endhint %}
56
+
57
+ ---
58
+
59
+ ## Receiving the decision
60
+
61
+ ### Raw string (default)
62
+
63
+ By default no parsing is performed. The raw `value` string is available on the decision:
64
+
65
+ ```typescript
66
+ const result = client.evaluate({
67
+ target: { service: "app", resource: "features", action: "read" },
68
+ context: { plan: "pro", isAuthenticated: true },
69
+ });
70
+
71
+ if (result.decision === "custom") {
72
+ console.log(result.value);
73
+ // '{"newCheckout": true, "aiSearch": true, "variant": "B"}'
74
+ }
75
+ ```
76
+
77
+ ### Automatic JSON parsing
78
+
79
+ Pass `engine.parseCustomEffect: true` when constructing `RuntimeClient` (or `createPolicyEngine`). The SDK will call `JSON.parse()` on `value` and attach the result as `parsedValue`. Non-JSON strings leave `parsedValue` as `undefined`; the raw `value` is always present as a fallback.
80
+
81
+ ```typescript
82
+ const client = new RuntimeClient({
83
+ baseUrl: "https://runtime.govplane.io",
84
+ runtimeKey: "rk_live_••••••••",
85
+ engine: {
86
+ parseCustomEffect: true, // ← enable JSON parsing
87
+ },
88
+ });
89
+
90
+ const result = client.evaluate({ target, context });
91
+
92
+ if (result.decision === "custom") {
93
+ const flags = result.parsedValue as {
94
+ newCheckout: boolean;
95
+ aiSearch: boolean;
96
+ variant: string;
97
+ };
98
+
99
+ if (flags?.newCheckout) renderNewCheckout();
100
+ if (flags?.aiSearch) enableAiSearch();
101
+ }
102
+ ```
103
+
104
+ {% hint style="warning" %}
105
+ `parsedValue` is typed as `unknown`. Always perform a type-guard or cast before accessing its properties. JSON parsing errors are caught silently — always check `parsedValue !== undefined` before use.
106
+ {% endhint %}
107
+
108
+ ---
109
+
110
+ ## Using `createPolicyEngine` directly
111
+
112
+ ```typescript
113
+ import { createPolicyEngine } from "@govplane/runtime-sdk";
114
+
115
+ const engine = createPolicyEngine({
116
+ getBundle: () => bundle,
117
+ parseCustomEffect: true,
118
+ });
119
+ ```
120
+
121
+ ---
122
+
123
+ ## Real-world examples
124
+
125
+ ### Feature flags — plan-based
126
+
127
+ Different flag sets depending on the user's plan using `thenEffect` / `elseEffect`:
128
+
129
+ ```json
130
+ {
131
+ "id": "r_flags_by_plan",
132
+ "status": "active",
133
+ "priority": 10,
134
+ "target": { "service": "app", "resource": "features", "action": "read" },
135
+ "when": { "op": "eq", "path": "plan", "value": "enterprise" },
136
+ "thenEffect": {
137
+ "type": "custom",
138
+ "value": "{\"analytics\": true, \"exports\": true, \"aiSearch\": true}"
139
+ },
140
+ "elseEffect": {
141
+ "type": "custom",
142
+ "value": "{\"analytics\": false, \"exports\": false, \"aiSearch\": false}"
143
+ },
144
+ "effect": { "type": "deny" }
145
+ }
146
+ ```
147
+
148
+ ```typescript
149
+ const result = client.evaluate({
150
+ target: { service: "app", resource: "features", action: "read" },
151
+ context: { plan: "enterprise" },
152
+ });
153
+
154
+ if (result.decision === "custom" && result.parsedValue) {
155
+ const flags = result.parsedValue as Record<string, boolean>;
156
+ // flags = { analytics: true, exports: true, aiSearch: true }
157
+ }
158
+ ```
159
+
160
+ ### A/B experiment variant
161
+
162
+ ```json
163
+ {
164
+ "id": "r_ab_checkout",
165
+ "status": "active",
166
+ "priority": 5,
167
+ "target": { "service": "app", "resource": "checkout", "action": "render" },
168
+ "effect": {
169
+ "type": "custom",
170
+ "value": "\"variant-B\""
171
+ }
172
+ }
173
+ ```
174
+
175
+ ```typescript
176
+ const result = client.evaluate({ target, context });
177
+ if (result.decision === "custom") {
178
+ renderCheckout(result.value === '"variant-B"' ? "B" : "A");
179
+ }
180
+ ```
181
+
182
+ ### Structured per-tenant config overlay
183
+
184
+ ```json
185
+ {
186
+ "id": "r_tenant_cfg",
187
+ "status": "active",
188
+ "priority": 1,
189
+ "target": { "service": "app", "resource": "config", "action": "get" },
190
+ "effect": {
191
+ "type": "custom",
192
+ "value": "{\"maxUploadsPerDay\": 500, \"allowedFileTypes\": [\"pdf\", \"csv\"]}"
193
+ }
194
+ }
195
+ ```
196
+
197
+ ---
198
+
199
+ ## Precedence
200
+
201
+ `custom` sits **below** `allow` in the precedence chain:
202
+
203
+ ```
204
+ kill_switch > deny > throttle > allow > custom > deny-by-default
205
+ ```
206
+
207
+ This means: if any rule in any policy matches with `allow`, the custom effect is ignored. Design your policies so that if you want a `custom` response to be the primary output for a target, no `allow` rules target the same service/resource/action.
208
+
209
+ ---
210
+
211
+ {% content-ref url="PolicyDefaults.md" %}
212
+ [Policy Defaults](PolicyDefaults.md)
213
+ {% endcontent-ref %}
214
+
215
+ {% content-ref url="ConditionalRules.md" %}
216
+ [Conditional Rules](ConditionalRules.md)
217
+ {% endcontent-ref %}