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}`);
});
example projectreal output · keeps running
$ node src/server.ts
ProductionDeploy 686358ddc154 listening on http://127.0.0.1:8787

Requests 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