Guides
Node.js
Node.js 22.18 and later run ulang-lite's TypeScript source directly. This guide serves POST /decide from the example project.
Load the contract once
src/policy.tsexample project// Load a µLang v1 contract once, then decide many requests with it.
import { checkFacts, compileSource, contractDigest, formatDiagnostic } from '../vendor/ulang-lite/src/index.ts';
export interface Outcome {
readonly verdict: 'PERMIT' | 'DENY';
readonly clause_id: string;
readonly contract_sha256: string;
}
export interface Rejected { readonly error: string }
export function loadPolicy(source: string, file = 'policy.ul') {
const result = compileSource(source);
if (!result.ok) throw new Error(formatDiagnostic(result.diagnostic, file));
if (result.contracts.length !== 1) throw new Error(`${file}: expected one contract, found ${result.contracts.length}`);
const valid = result.valid[0]!;
const contract = result.contracts[0]!;
const digest = contractDigest(valid.contract);
return {
name: contract.name,
entity: contract.entity,
actions: contract.actions,
digest,
/** Check the facts against the contract's entity, then decide. Bad input is returned, never thrown. */
decide(action: string, facts: unknown): Outcome | Rejected {
if (!contract.actions.has(action)) return { error: `${contract.name} does not govern ${action}` };
const problem = checkFacts(valid, facts);
if (problem) return { error: problem };
const values = (facts as Record<string, Record<string, string | number | boolean>>)[contract.entity]!;
const decision = contract.decide(action, values);
return { verdict: decision.verdict, clause_id: decision.clause_id, contract_sha256: digest };
},
};
}loadPolicy compiles at startup and throws a precise diagnostic if the contract is invalid, so a bad contract stops the deploy instead of failing requests. decide checks the facts first, and returns problems as values.
The endpoint
src/server.tsexample project// A decision endpoint on Node.js: POST /decide {"action", "facts"} -> {"verdict", "clause_id", "contract_sha256"}.
import { readFileSync } from 'node:fs';
import { createServer } from 'node:http';
import { loadPolicy } from './policy.ts';
const policy = loadPolicy(readFileSync(new URL('../policy/deploy.ul', import.meta.url), 'utf8'), 'policy/deploy.ul');
const server = createServer(async (req, res) => {
const reply = (status: number, body: unknown) => {
res.writeHead(status, { 'content-type': 'application/json' });
res.end(JSON.stringify(body) + '\n');
};
if (req.method !== 'POST' || req.url !== '/decide') return reply(404, { error: 'POST /decide' });
let text = '';
for await (const chunk of req) {
text += chunk;
if (text.length > 65_536) return reply(413, { error: 'request too large' });
}
let body: { action?: unknown; facts?: unknown } | null;
try { body = JSON.parse(text); } catch { return reply(400, { error: 'invalid JSON' }); }
if (typeof body?.action !== 'string') return reply(400, { error: 'action must be a string' });
const outcome = policy.decide(body.action, body.facts);
reply('error' in outcome ? 400 : 200, outcome);
});
const port = Number(process.env.PORT ?? 8787);
server.listen(port, '127.0.0.1', () => {
console.log(`${policy.name} ${policy.digest.slice(0, 12)} listening on http://127.0.0.1:${port}`);
});$ node src/server.ts
ProductionDeploy 686358ddc154 listening on http://127.0.0.1:8787Requests and responses
These are real requests to that server, made when this site was built:
POST /decide
{
"action": "deploy",
"facts": {
"DeployRequest": {
"environment": "production",
"tests_passed": true,
"approvals": 2,
"author_approved_own_change": false,
"error_rate_pct": 0.4,
"change_freeze": false
}
}
}200
{"verdict":"PERMIT","clause_id":"ProductionDeploy.deploy.permit","contract_sha256":"686358ddc154b77b5b7b3a24870b8dd6f79b23d376ed039d6824a923284e4059"}POST /decide
{
"action": "deploy",
"facts": {
"DeployRequest": {
"environment": "production",
"tests_passed": true,
"approvals": 2,
"author_approved_own_change": false,
"error_rate_pct": 0.4,
"change_freeze": true
}
}
}200
{"verdict":"DENY","clause_id":"ProductionDeploy.change_freeze","contract_sha256":"686358ddc154b77b5b7b3a24870b8dd6f79b23d376ed039d6824a923284e4059"}POST /decide
{
"action": "rollback",
"facts": {
"DeployRequest": {
"environment": "production",
"tests_passed": true,
"approvals": 2,
"author_approved_own_change": false,
"error_rate_pct": 0.4,
"change_freeze": true
}
}
}200
{"verdict":"PERMIT","clause_id":"ProductionDeploy.rollback.permit","contract_sha256":"686358ddc154b77b5b7b3a24870b8dd6f79b23d376ed039d6824a923284e4059"}POST /decide
{
"action": "deploy",
"facts": {
"DeployRequest": {
"environment": "production",
"tests_passed": true,
"approvals": 2.5,
"author_approved_own_change": false,
"error_rate_pct": 0.4,
"change_freeze": false
}
}
}400
{"error":"invalid type or domain for DeployRequest.approvals"}POST /decide
{
"action": "hotfix",
"facts": {
"DeployRequest": {
"environment": "production",
"tests_passed": true,
"approvals": 2,
"author_approved_own_change": false,
"error_rate_pct": 0.4,
"change_freeze": false
}
}
}400
{"error":"ProductionDeploy does not govern hotfix"}POST /decide
{"action": "deploy", 400
{"error":"invalid JSON"}GET /
no body
404
{"error":"POST /decide"}Notes
- Compile once.
compileSourcedoes all the parsing and validation;decideonly compares. - Answer bad facts and unknown actions with
400. They are mistakes by the caller, not denials. - Log the
contract_sha256with each decision. See Audit trail.