@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/README.md +400 -216
- package/dist/index.cjs +187 -91
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +65 -14
- package/dist/index.d.ts +65 -14
- package/dist/index.js +187 -91
- package/dist/index.js.map +1 -1
- package/docs/README.md +89 -0
- package/docs/SUMMARY.md +34 -0
- package/docs/installation/GettingStarted.md +474 -0
- package/docs/reference/Configuration.md +198 -0
- package/docs/reference/TypesAndInterfaces.md +373 -0
- package/docs/usage/BundleLifecycle.md +209 -0
- package/docs/usage/ConditionalRules.md +251 -0
- package/docs/usage/ContextPolicy.md +196 -0
- package/docs/usage/CustomEffect.md +217 -0
- package/docs/usage/DecisionTrace.md +289 -0
- package/docs/usage/Effects.md +213 -0
- package/docs/usage/Evaluate.md +164 -0
- package/docs/usage/PolicyDefaults.md +220 -0
- package/package.json +3 -5
|
@@ -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 %}
|