@rules-cascade/server
v1.0.0-alpha.11
Published
Stateless HTTP rule server for Rule Cascade: serves manifests and evaluates operations. Run as many replicas as you need.
Maintainers
Readme
@rules-cascade/server
A stateless HTTP service that implements the evaluation API, described in OpenAPI 3.1. Use it for callers that cannot embed a runtime. It keeps nothing between requests, so you run as many replicas as you need behind a load balancer.
npm ci && npm run build # at the repository root
RULE_SERVER_TOKEN=$(openssl rand -hex 32) RULES_DIR=examples/contracts node packages/server/dist/main.js| Variable | Meaning | Default |
|---|---|---|
| RULES_DIR | Directory with the rulesets to serve; see below | ./rules |
| PORT | Listen port, 0 to 65535 | 8080 |
| RULE_SERVER_TOKEN | Bearer token for evaluations, the server manifest and bundles | required |
| RULE_SERVER_ALLOW_OPEN | 1 or true: start without a token and serve every endpoint openly | unset |
| DRAIN_SECONDS | Time between "not ready" and shutdown on SIGTERM, in whole seconds | 5 |
| RULE_SERVER_MAX_BODY_BYTES | Largest accepted request body; a larger one is answered 413 | 1048576 (1 MiB) |
| RULE_SERVER_KEEP_ALIVE_TIMEOUT_MS | Close an idle keep-alive connection after this long; keep it above the idle timeout of the load balancer in front; at least 1 | 65000 |
| RULE_SERVER_HEADERS_TIMEOUT_MS | Longest time to receive the request headers; longer than the keep-alive timeout, no longer than the request timeout | 66000 |
| RULE_SERVER_REQUEST_TIMEOUT_MS | Longest time to receive a whole request, body included; 0 for no limit | 120000 |
| RULES_REFRESH | manual (SIGHUP only), interval or cron: also reload on a schedule when the files changed | manual |
| RULES_REFRESH_INTERVAL | With interval: 30s, 5m, 1h or seconds; at least 1s | none |
| RULES_REFRESH_CRON | With cron: minute hour day-of-month month day-of-week | none |
| RULES_REFRESH_TZ | Time zone of the cron schedule | UTC |
| RULE_SERVER_WORKERS | Evaluate on this many worker threads; 0 evaluates on the event loop | 0 |
| RULE_EVALUATION_TIMEOUT_MS | With workers: answer 503 and replace the worker after this long | 5000 |
| RULE_SERVER_CORS_ORIGINS | Comma-separated browser origins (https://app.example.com) or * that may read the ruleset list, client manifests and rule details from another site; see Caching | none |
| RULE_SERVER_CORS_MAX_AGE | Seconds a browser may keep a CORS preflight answer | 600 |
| RULE_SERVER_REQUEST_LOG | on or off: one JSON log line per request (health probes excepted). Turn it off when the platform in front already logs requests: the load test then answers about 13 % more requests per second | on |
| RULE_SERVER_MAX_QUEUE | With workers: evaluations waiting for a worker beyond this many are answered 503 with Retry-After: 1 | 256 per worker |
Every number is checked at start-up. A value that is not a whole number in range, an empty one
included, stops the process with exit code 1 and a fatal line that names the variable, as do
timeouts that contradict each other.
Scheduled refresh, the cache policy and the checksum URL are described in Caching; the worker pool and measurements in Performance.
The rules directory
The server loads two kinds of files from RULES_DIR:
| File | What happens at start-up and on reload |
|---|---|
| *.ruleset.yaml, *.ruleset.yml, *.ruleset.json | Compiled here: schema validation, inheritance and every load check. Entity schemas are read from the same directory |
| *.bundle.json | Read as a precompiled bundle. Only the bundle format is checked; the compiler that produced it already ran the load checks |
An optional cache-policy.yaml (or .yml, .json) sets the Cache-Control of client manifests
per ruleset: revalidate (the default), ttl or permanent. It is checked with the rulesets; a bad
one stops the start or refuses the reload. See Caching.
A ruleset id may be defined once, by one file. A source ruleset can only extend a parent that is also present as a source document, because a bundle does not hold what inheritance needs.
A bundle carries no title, scope or status. GET /rulesets therefore lists a ruleset that came from a
bundle with its id, version and checksum only, and a ?scope= filter does not match it.
Endpoints
| Method and path | Purpose | Needs the token |
|---|---|---|
| GET /healthz | Liveness | no |
| GET /readyz | Readiness; 503 while draining | no |
| GET /rulesets?scope= | List rulesets, optionally under a hierarchy path | no |
| GET /rulesets/{id}/manifest?channel=client | Client-safe manifest, with ETag | no |
| GET /rulesets/{id}/manifest?channel=server | Full manifest for backend runtimes | yes |
| GET /rulesets/{id}/bundle | The compiled bundle, for runtimes that evaluate in process; with ETag | yes |
| GET /rulesets/{id}/rules/{ruleId} | One resolved rule | server-only rules: yes |
| POST /evaluations | Evaluate an operation | yes |
The endpoints marked "yes" answer 401 without Authorization: Bearer <token>. The bundle contains
the server manifest, so it is protected exactly like it: the token is checked before the ruleset is
looked up and before a conditional request is answered. The endpoints marked "no" are public on
purpose: browsers fetch the client manifest directly, and load balancers probe the health endpoints.
The server fails closed. Without RULE_SERVER_TOKEN the process logs a fatal line and exits 1
before it loads any rules. To serve every endpoint without a token, which is only acceptable on a
private network, set RULE_SERVER_ALLOW_OPEN=1; the server then logs a warn line at start-up and
marks its listen line "auth":"open". An empty token counts as unset. Embedded, createRuleServer
throws unless it is given token or allowOpen: true.
curl -s localhost:8080/evaluations -H "Authorization: Bearer $RULE_SERVER_TOKEN" -H 'Content-Type: application/json' -d '{
"ruleset": "acme.payments.transfer", "entity": "Transfer", "operation": "create",
"data": {"type": "international", "amount": 500, "currency": "USD", "memo": "gift",
"beneficiary": {"name": "X", "country": "KP", "swiftCode": "ABCDKPPY"}}}'
# {"decision":"deny","findings":[{"code":"ORG-TRF-001","message":"Transfers to KP are not permitted.", ...}], ...}The request body is the evaluation request of the specification plus ruleset, and optionally
version and channel. The body is checked before the ruleset is looked up or anything is
evaluated, with the shape check every runtime applies (specification section 8). A malformed body is
answered with 400 and a problem document whose title says what is wrong, for example
'view' must be an object; an unknown ruleset or version is 404. Optional members may be null;
a null channel is the default channel, server. A body with a number too large for a double
(1e999) is refused with 400 as well. A body larger than 1 MiB (RULE_SERVER_MAX_BODY_BYTES, or maxBodyBytes when embedded) is
413, and a path with malformed percent-encoding is 400.
trigger, view, resolutions and locale are passed to the runtime as given. A view narrows the evaluation to one place in the user interface, and a finding reports the
place its rule names as location:
curl -s localhost:8080/evaluations -H "Authorization: Bearer $RULE_SERVER_TOKEN" -H 'Content-Type: application/json' -d '{
"ruleset": "acme.payments.transfer", "entity": "Transfer", "operation": "create",
"view": {"component": "beneficiary-panel"},
"data": {"type": "international", "amount": 500, "currency": "USD", "memo": "gift",
"beneficiary": {"name": "X", "country": "ES"}}}'
# {"decision":"deny","findings":[{"code":"PAY-TRF-002", ..., "location":{"component":"beneficiary-panel"}}],"effects":[],"commands":[]}Without the view the same request also returns the fee that a rule of the amount-panel computes.
A runtime without a compiler downloads the bundle and evaluates it in process:
curl -s -H "Authorization: Bearer $RULE_SERVER_TOKEN" localhost:8080/rulesets/acme.payments.transfer/bundle -o transfer.bundle.json
# ETag: "sha256:c192...2c7e-bundle"; send it back as If-None-Match and the answer is 304 until the ruleset changesCustom operators
A ruleset may use custom operators (x-*), which are functions of the host application. The
rule-cascade-server command registers none: it serves such a ruleset, logs a warning that names the
missing operators at start-up and after every reload, and the rules that need them fail closed (the
evaluation denies with a RULE-EVALUATION-ERROR finding).
To supply operators, embed the server in a program of your own:
import { createRuleServer, RuleStore } from '@rules-cascade/server';
const store = new RuleStore('./rules');
const { server, missingOperators } = createRuleServer(store, {
token: process.env.RULE_SERVER_TOKEN, // required; createRuleServer throws without it unless allowOpen: true
operators: { 'x-luhn': (text) => typeof text === 'string' && luhn(text) },
});
missingOperators(); // {} when everything is registered, otherwise { '<ruleset id>': ['x-...'] }
server.listen(8080);An operator receives plain JSON values and returns a JSON value. It must be synchronous and pure. Every runtime that evaluates the ruleset needs the same operators: a browser that evaluates the client manifest registers them too.
Behaviour that matters in production
- Fails fast. If any ruleset fails a load check, or any bundle is unusable, the process exits before it listens.
- Fails closed. Without
RULE_SERVER_TOKEN(or the explicitRULE_SERVER_ALLOW_OPEN=1) the process exits1. - Constant-time token check. The token and the one a caller sends are compared as SHA-256
digests in constant time, so a refusal takes as long whatever was sent and reveals neither the
token nor its length. A refusal is
401withWWW-Authenticate: Bearer. - Timeouts for load balancers and slow clients. An idle keep-alive connection is closed after
65 s, after the 60 s idle timeout of common load balancers, so the server never closes a connection
the load balancer is about to reuse (which shows up as an occasional
502). If yours waits longer, raiseRULE_SERVER_KEEP_ALIVE_TIMEOUT_MSabove it andRULE_SERVER_HEADERS_TIMEOUT_MSabove that. A client gets 66 s to send its headers and 120 s for the whole request. Embedded, passkeepAliveTimeoutMs,headersTimeoutMsandrequestTimeoutMs. - CORS, opt-in and for public endpoints only. With
RULE_SERVER_CORS_ORIGINS(embedded:corsOrigins,corsMaxAgeSeconds) a browser on a listed origin can read the ruleset list, client manifests and rule details, revalidate withIf-None-Matchand see theETag. Protected endpoints never answer another origin. - No content sniffing. Every answer carries
X-Content-Type-Options: nosniff. - Scheduled refresh. With
RULES_REFRESH=intervalorcronthe server also checks the directory on a schedule and reloads, with the same safety, only when a file changed. Reloads and refusals are logged with theirtrigger; unchanged checks are not. - Safe reload.
SIGHUPreloads the directory. If the new rules fail to load, the server logsreload refused, still serving the previous ruleswith the reason and keeps serving the previous ones;/readyzkeeps reporting the previousloadedAt. - Graceful shutdown. On
SIGTERM(orSIGINT) readiness turns503at once; afterDRAIN_SECONDSthe server stops accepting connections, in-flight requests finish, and the process exits0. - Cacheable manifests and bundles. The ETag is the ruleset checksum plus the channel, or plus
bundle; revalidation returns304.If-None-Matchmay list several entity tags, use weak validators (W/"..."), or be*. Client manifests follow the cache policy; server manifests and bundles are alwaysprivate, no-cache. By default the client manifest is sent withCache-Control: public, max-age=0, must-revalidate, so every use is revalidated. A browser that usescreateManifestClientof the runtime keeps evaluating its last manifest while this endpoint is unreachable; the evaluation on the server remains the decision. - Checksum URLs.
GET /rulesets/{id}/manifest?channel=client&checksum=sha256:...answers the manifest only when that is the checksum being served (inpermanentmode withCache-Control: public, max-age=31536000, immutable), and409withCache-Control: no-storeand the servedchecksumin the problem body otherwise. This parameter is an extension of this server; it is not in the OpenAPI description. - Worker threads. With
RULE_SERVER_WORKERS=Nevaluations run on N worker threads with the same results; one that outlivesRULE_EVALUATION_TIMEOUT_MSis answered503and its worker replaced. Requests waiting for a worker beyondRULE_SERVER_MAX_QUEUEare answered503withRetry-After: 1, so a burst fails fast instead of piling up memory. Embedded, passworkers,evaluationTimeoutMs,maxQueueand, for custom operators,operatorsModule(an ES module exporting them) tocreateRuleServer, and callterminateWorkers()on shutdown. - Structured logs. One JSON line per request; health probes are not logged. Request lines are
written in batches, once per turn of the event loop; warnings and errors are written at once.
RULE_SERVER_REQUEST_LOG=off(embedded:requestLog: false) leaves request lines out.
Container and Kubernetes files are in the Dockerfile and the Kubernetes deployment.
