Skip to content

Backend Development

The backend is packages/backend (@ronl/backend) — a Node.js 22 Express application written in TypeScript. The repository pins the runtime in .nvmrc at 22.23.3, and the root engines.node is >=22; both App Service plans run NODE|22-lts.


Project structure

packages/backend/
├── openapi/
│   ├── openapi.yaml              # The OpenAPI 3.1 description, written by hand
│   ├── .spectral.yaml            # Spectral config: extends the ruleset below, with recorded exceptions
│   └── adr-ruleset-2.2.1.yaml    # NL API Design Rules 2.2.1 ruleset, vendored
│                                 # (openapi.json is generated beside these and gitignored)
├── scripts/
│   ├── build-openapi.cjs         # openapi.yaml → openapi.json; info.version from package.json
│   ├── jest-global-setup.cjs     # Builds openapi.json before every Jest run; sets CONFORMANCE_LOG
│   ├── check-conformance-coverage.cjs  # After Jest: fails unless every documented operation was checked
│   ├── jest-setup-env.cjs        # The one variable validateConfig() demands in tests
│   └── edocs-healthcheck.ts, doccle-healthcheck.ts, reset-pa-data.ts
└── src/
    ├── index.ts                  # Bootstrap: middleware, mounts from the registry, startup
    ├── routes/
    │   ├── registry.ts           # The /v1 route topology: mounts, banner, coverage gate
    │   ├── root.routes.ts        # Service banner at /
    │   ├── openapi.routes.ts     # GET /v1/openapi.json
    │   ├── health.routes.ts      # /v1/health, /live, /ready, /external
    │   ├── process.routes.ts     # /v1/process
    │   ├── decision.routes.ts    # /v1/decision
    │   ├── task.routes.ts        # /v1/task
    │   ├── besluitvorming.routes.ts  # /v1/besluitvorming: active and completed besluiten
    │   ├── brp.routes.ts, hr.routes.ts, capacity.routes.ts, public.routes.ts
    │   ├── rip.routes.ts, edocs.routes.ts, doccle.routes.ts, validsign.routes.ts
    │   └── admin.routes.ts, m2m.routes.ts, mcp.routes.ts
    ├── openapi/
    │   ├── document.ts           # Reads the built openapi/openapi.json
    │   ├── coverage.test.ts      # Document vs. served routes, in both directions
    │   └── testing/
    │       ├── routeOperations.ts    # Lists served and documented operations
    │       └── conformance.ts        # expectToMatchOperation(): a real response against its operation
    ├── middleware/
    │   ├── version.middleware.ts # API-Version response header
    │   ├── audit.middleware.ts   # Audit entry per request, and auditLog()
    │   ├── error.middleware.ts   # 404, body-parser refusals, the last error handler, the rate-limit 429
    │   └── tenant.middleware.ts  # Municipality claim extraction and validation
    ├── auth/
    │   ├── jwt.middleware.ts     # JWT validation, role and assurance-level guards
    │   └── tenant-access.ts      # Every tenant decision for process and task access
    ├── pa-monitoring/            # Policy analysis: /v1/pa routers, sources, curation
    ├── media-aggregator/         # /v1/media-aggregator
    ├── rip-swimlane/             # BPMN swimlane parsing: Infra-board phases and any laned process
    │   └── __fixtures__/         # RIP processes; awb/ (Awb-phase processes); declared/ (processes that declare their own phases)
    ├── mcp-servers/              # eDOCS, LDE and TriplyDB MCP servers
    ├── services/                 # Operaton, audit, eDOCS, ValidSign, Doccle, … plus llm/, mcp/, document/
    ├── types/                    # audit.types.ts, auth.types.ts
    └── utils/                    # config, cors-origin, client-ip, logger, errors, problem (sendProblem), …

Tests sit beside the file they cover as *.test.ts.


Middleware stack

Middleware is registered in this order in src/index.ts:

  1. helmet() — security headers (CSP, HSTS), only when config.security.helmetEnabled
  2. cors() — origin is corsOriginCallback(...); see CORS
  3. rateLimit() — one global limiter, skipped for the ValidSign callback path, which has its own; its 429 is rateLimitHandler from error.middleware.ts
  4. express.json({ limit: '1mb' }) — wrapped so the ValidSign callback path skips it and parses its own body
  5. express.urlencoded({ limit: '1mb' })
  6. Request logging — method, path, query, IP, user agent
  7. API-Version header — set by an inline handler and again by versionMiddleware; both write package.json's version, so the duplicate is harmless
  8. auditMiddleware — writes an audit entry once the response is sent
  9. The root router at / — the service banner
  10. The route registry — app.use(mount, router) for each entry of routes/registry.ts, in array order
  11. notFoundHandler from error.middleware.ts — 404 NOT_FOUND
  12. errorHandler from error.middleware.ts — a body the parser refused answers 400 MALFORMED_BODY (does not parse), 413 PAYLOAD_TOO_LARGE (over the limit) or INVALID_BODY (any other body-parser refusal, keeping its 4xx status); anything else is 500 INTERNAL_ERROR, with the message hidden when NODE_ENV is production

Every error the API sends, these included, is RFC 9457 problem details, written with sendProblem from utils/problem.ts (buildProblem gives the body alone). The one exception is the stub ValidSign ceremony's failed signing, an HTML page. See API Design — Error handling.

JWT validation (jwt.middleware.ts) is applied per router or per route, not globally. Public endpoints (e.g. GET /v1/health) do not require authentication.


Adding a new route

A route is one entry in src/routes/registry.ts. That array drives three things: index.ts mounts from it, the banner at / advertises from it, and src/openapi/coverage.test.ts compares the OpenAPI document against it.

  1. Create src/routes/myfeature.routes.ts following the existing pattern:
import { Router, Request, Response } from 'express';
import { jwtMiddleware } from '@auth/jwt.middleware';
import { createLogger } from '@utils/logger';
import { sendProblem } from '@utils/problem';

const router = Router();
const logger = createLogger('myfeature-routes');

router.use(jwtMiddleware);

router.get('/', async (req: Request, res: Response) => {
  try {
    res.json({ success: true, data: { ... } });
  } catch (error) {
    logger.error('myfeature error', {
      error: error instanceof Error ? error.message : String(error),
    });
    sendProblem(res, req, {
      status: 500,
      code: 'MYFEATURE_FAILED',
      detail: 'Failed to load myfeature',
    });
  }
});

export default router;
  1. Add it to routeRegistry in src/routes/registry.ts:
import myfeatureRoutes from './myfeature.routes';

// in routeRegistry:
{
  mount: '/v1/myfeature',
  router: myfeatureRoutes,
  advertiseAs: 'myfeature',
  summary: 'What this mount serves',
},

Every mount is under /v1 — registry.test.ts checks it — and there is no /api alias. Omit advertiseAs only for a second router on a mount the first already advertises, as /v1/validsign and /v1/pa do.

  1. Describe every operation it serves in openapi/openapi.yaml. coverage.test.ts fails when a served operation is not documented and when a documented operation is not served; there is no pending list, so a new route is described, not parked. Run npm run lint:openapi as well — both backend workflows run it. See API specification for the published document.

  2. Call expectToMatchOperation(res, '<method>', '<path>') from src/openapi/testing/conformance.ts in the route's own test, for each operation. <path> is the document's path — no /v1 prefix, {braces} for parameters. The helper fails the test unless the status is documented for that operation, a 2xx carries API-Version, the content type is documented and the body matches its schema (Ajv 2020-12 with strict schema mode, so a misspelled keyword in the document fails too). It also records the operation in CONFORMANCE_LOG. After Jest, scripts/check-conformance-coverage.cjs fails npm test when any documented operation was never checked this way — so a description no test compares against a response cannot land.

Mount order is data. Express matches in mount order, and the ValidSign callback router must precede the authenticated router on the same /v1/validsign path, because ValidSign sends no token. That ordering is the array order in registry.ts, and registry.test.ts asserts it.


eDOCS service and external task worker

edocs.service.ts wraps the OpenText eDOCS REST API. It keeps one eDOCS session per principal — the service account ('service') or a person ('user:<sub>') — in a shared session store. People's sessions are bounded (500, least recently used evicted first); the service session never counts against the bound, so a busy day cannot push the archiving session out. Concurrent first requests for one principal share a single connect.

  • The service account connects with POST /connect and EDOCS_USER_ID/EDOCS_PASSWORD. The exported singleton edocsService is this principal; background archiving and machine clients use it.
  • A person connects with POST /connect carrying their Entra ID token in the X-DM-AUTH header, no password. A route derives that client with edocsService.forUser(...), which shares the singleton's store.

Either connect yields the X-DM-DST session token (and X-DM-CSRF-TOKEN) from the Set-Cookie response header. On a failed call the two principals differ: the service account reconnects on 401 or 403 and retries once. For a person a 403 is eDOCS refusing them — no rights on that workspace or document — and is thrown as EdocsAccessDeniedError at once; only a 401 fetches a fresh ID token, reconnects and retries once, and whatever still refuses after that is a refusal too. Key methods:

ensureWorkspace(projectNumber: string, projectName: string): Promise<EdocsWorkspaceResult>
uploadDocument(workspaceId: string, filename: string, contentBase64: string, metadata: EdocsDocumentMetadata): Promise<EdocsDocumentResult>
getWorkspaceDocuments(workspaceId: string): Promise<...>
healthCheck(): Promise<{ status: 'up' | 'down' | 'stub'; reachable: boolean; authenticated: boolean; latency?: number; error?: string }>

When EDOCS_STUB_MODE=true (the default), all methods return realistic fake data and log what they would have done. The stub is transparent — callers cannot distinguish it from a live server.

externalTaskWorker.service.ts polls Operaton's external task API (POST /external-task/fetchAndLock) using long-polling (asyncResponseTimeout: 20 000 ms). It handles three topics:

Topic Reads Writes
rip-edocs-workspace projectNumber, projectName edocsWorkspaceId, edocsWorkspaceName, edocsWorkspaceCreated
rip-relatics-workspace (simulated — no Relatics integration yet) projectNumber, projectName relaticsWorkspaceId, relaticsWorkspaceName, relaticsWorkspaceCreated, relaticsWorkspaceSimulated
rip-edocs-document edocsWorkspaceId, edocsAuthor, edocsAuthorName, documentTemplateId, edocsDocumentVariableName, + template variables <edocsDocumentVariableName> (e.g. edocsIntakeReportId) and <edocsDocumentVariableName>_docId

The worker uploads as the service account. When the process carries edocsAuthor — the employee who last started or completed a step, stamped by the backend from their token — the document's title ends in — namens <naam> (<e-mail>) (attributedDocName()), kept within eDOCS' 254-character title limit.

The worker is started inside the app.listen() callback and stopped in both SIGTERM and SIGINT handlers. It will not begin polling until the HTTP server is fully bound.

For configuration and live-mode switchover, see Copilot Studio — eDOCS OAuth Integration.


M2M route group

m2m.routes.ts exposes 18 Operaton operations, on 18 routes, to machine-to-machine clients without tenant scoping. Process history is POST /v1/m2m/process/history only; GET /v1/m2m/process/history answers 404. It applies jwtMiddleware and then requireM2mClient — tenantMiddleware is intentionally absent.

requireM2mClient admits only a token whose azp is on M2M_ALLOWED_CLIENTS (comma-separated; default operaton-mcp-client). Every person in the realm holds a token for the ronl-business-api audience, so a valid token is not enough: anything else, a person's token included, gets 403 M2M_CLIENT_NOT_ALLOWED before any engine call.

A M2M_ALLOWED_OPERATIONS constant at the top of the file controls which operations are active. Commenting out an entry returns 403 OPERATION_NOT_PERMITTED for that operation with no other code changes required.

A start refuses a body that sets municipality, originTenantId, edocsAuthor or edocsAuthorName, and a task completion one that sets any of those or applicantId: both answer 400 RESERVED_VARIABLE, with the refused names in a reserved extension member, before any engine call.

The route group uses the main engine. config.operaton.m2mBaseUrl is OPERATON_M2M_BASE_URL, empty when the variable is unset — which it is on every tier — so the : operatonService branch below is the one taken. Setting the variable points M2M at a different engine on purpose:

const m2mOperatonService = config.operaton.m2mBaseUrl
  ? new OperatonService(
      config.operaton.m2mBaseUrl,
      config.operaton.m2mUsername,
      config.operaton.m2mPassword
    )
  : operatonService;

See Operaton MCP Client for the full endpoint reference and Keycloak setup.


Authentication on protected routes

Apply the JWT middleware to a whole router with router.use(jwtMiddleware), or to a single route:

import { jwtMiddleware } from '@auth/jwt.middleware';

router.post('/sensitive', jwtMiddleware, async (req, res) => {
  const { municipality, roles } = req.user!;
  // ...
});

After jwtMiddleware, req.user is populated from the validated token. jwt.middleware.ts also exports optionalJwtMiddleware, requireRoles(...) and requireAssuranceLevel(...). Tenant decisions for process and task access — tenantAllows, caseReadAllowed, denyTenant, resolveStartTenant — live in auth/tenant-access.ts, and every tenant refusal answers 403 TENANT_MISMATCH.


TypeScript path aliases

The tsconfig.json configures path aliases for clean imports:

@/*          → src/*
@routes/*    → src/routes/*
@services/*  → src/services/*
@middleware/* → src/middleware/*
@auth/*      → src/auth/*
@models/*    → src/models/*
@types/*     → src/types/*
@utils/*     → src/utils/*

tsc-alias resolves these aliases during the build step (npm run build).

tsconfig.json uses module: node16 / moduleResolution: node16 (upgraded from CommonJS/Node10) — this enables subpath-exports resolution and aligns TypeScript's module semantics with the Node.js runtime actually executing the compiled output.


Development commands

npm run dev                    # tsx watch (hot-reload); predev builds openapi.json first
npm run build                  # tsc + tsc-alias → dist/; prebuild builds openapi.json first
npm run start                  # Run compiled dist/index.js
npm run lint                   # ESLint 9 flat config
npm run lint:fix               # ESLint with auto-fix
npm run type-check             # tsc --noEmit (no output, type check only)
npm test                       # Jest with coverage, then check-conformance-coverage.cjs
npm run test:serial            # The same, with Jest --runInBand
npm run test:unit              # Unit tests only
npm run test:integration       # Integration tests only
npm run build:openapi          # openapi/openapi.yaml → openapi/openapi.json
npm run lint:openapi           # build:openapi, then Spectral against openapi/.spectral.yaml (fails on error)
npm run test:openapi-coverage  # Jest over src/openapi — the coverage gate, without coverage reporting
npm run test:contract          # Jest over src/openapi and src/routes, without coverage reporting

Run these in packages/backend, or from the root with --workspace=@ronl/backend. scripts/jest-global-setup.cjs also builds openapi.json before every Jest run, because both backend workflows run the tests before they build, and truncates the file CONFORMANCE_LOG names (conformance-operations.log). npm test and npm run test:serial end with node scripts/check-conformance-coverage.cjs, which reads that log and exits non-zero when a documented operation was never checked against a real response. A filtered run (npx jest -t …, one file) does not go through that step; with no log at all, the script skips with a note.


Shared types

Types shared between backend and frontend are in packages/shared/src/. Import them as @ronl/shared:

import { ApiResponse, TenantConfig } from '@ronl/shared';

After modifying shared types, rebuild the package before the backend picks up the changes:

npm run build --workspace=@ronl/shared

Security implementation

The following code patterns are used across the middleware stack. These are the actual implementations — not configuration values — for reference when modifying security behaviour.

JWT validation

auth/jwt.middleware.ts validates every protected request:

const authHeader = req.headers.authorization;
const token = authHeader?.split(' ')[1];

// Fetch JWKS from Keycloak (cached in Redis, TTL 300s)
const jwks = await fetchJWKS(config.keycloakUrl, config.keycloakRealm);
const decoded = jwt.verify(token, jwks);

// Validate standard claims
if (decoded.exp < Date.now() / 1000) {
  throw new Error('Token expired');
}
if (decoded.aud !== config.jwtAudience) {
  throw new Error('Invalid audience');
}
if (!decoded.iss.startsWith(config.keycloakUrl)) {
  throw new Error('Invalid issuer');
}

// Attach to request for downstream handlers
req.user = decoded;

Rate limiting

src/index.ts applies one global limiter to every request:

const limiter = rateLimit({
  windowMs: config.rateLimit.windowMs,        // RATE_LIMIT_WINDOW_MS, default 60000
  max: config.rateLimit.maxRequests,          // RATE_LIMIT_MAX_REQUESTS, default 1000
  standardHeaders: true,
  legacyHeaders: false,
  keyGenerator: (req) =>
    rateLimitKey(req.ip, config.rateLimit.perTenant ? req.user?.tenantId : undefined),
  skip: (req) => isCallbackPath(req.path),
});
app.use(limiter);

rateLimitKey() in utils/client-ip.ts strips the port Azure writes into X-Forwarded-For, so each new connection does not get a fresh budget. A refusal answers 429 RATE_LIMIT_EXCEEDED. The ValidSign callback is skipped here and has its own limiter in validsign.routes.ts (60 per minute), so a busy board cannot exhaust the budget a signature callback needs.

Public write-endpoint hardening

POST /v1/public/use-case, POST /v1/public/upload-file, and POST /v1/public/feedback accept unauthenticated citizen submissions, so they carry extra hardening beyond the global rate limiter above:

  • ALTCHA proof-of-work — use-case and feedback require a solved PoW challenge before a GitLab work item is created. GET /v1/public/altcha/challenge issues a signed challenge (max 50,000 iterations, 10-minute expiry) via altcha-lib; verifySolution() checks it on submit. ALTCHA_HMAC_KEY configures the HMAC secret — when unset, the check bypasses gracefully so local dev isn't blocked. upload-file is intentionally excluded, since it's a pre-upload step rather than the final submission gate.
  • Upload type whitelist — upload-file checks both MIME type and file extension independently against ALLOWED_UPLOAD_MIMETYPES, blocking extension spoofing (a file claiming an allowed MIME type with a disallowed extension, or vice versa).
  • Dedicated rate limit — publicWriteLimiter caps these three routes at 10 requests per 15 minutes per IP, on top of the global limiter, with standardised RateLimit-* response headers.

CORS

src/index.ts passes cors a function rather than an array:

const isProductionTier = config.deploymentEnv === 'production';

app.use(
  cors({
    origin: corsOriginCallback(config.corsOrigin, config.corsPreviewSlugs, isProductionTier),
    credentials: true,
    methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
    allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
  })
);

corsOriginCallback() in utils/cors-origin.ts decides per request:

  • No Origin header — allowed. Server-to-server calls, curl and health probes are not browsers, and CORS is not an authentication boundary.
  • Configured origins — CORS_ORIGIN, a comma-separated list, matched by exact string equality. In production it is https://mijn.open-regels.nl, in ACC https://acc.mijn.open-regels.nl; unset, it defaults to the local dev servers on ports 3000, 5173, 5175 and 3002.
  • Pull-request previews — CORS_PREVIEW_SLUGS, a comma-separated list of Static Web Apps slugs, not hostnames. An origin matches when it is https://<slug>-<number>.<region>.<n>.azurestaticapps.net, the hostname Azure gives a numbered preview of that app. The slug is regex-escaped, and nothing broader than the slug matches — a *.azurestaticapps.net pattern would admit any Static Web App in the world to credentialed requests. Set on the ACC App Service only.
  • Production refuses previews in code, whatever CORS_PREVIEW_SLUGS holds: the guard reads DEPLOYMENT_ENV, not NODE_ENV, because acceptance runs NODE_ENV=production. A production tier with the setting present logs a warning at startup and ignores it.

A disallowed origin gets no CORS headers rather than an error. See Pull-request previews for why the preview allowance exists.

GET /v1/openapi.json is the exception: openapi.routes.ts mounts its own cors({ origin: '*', methods: ['GET', 'OPTIONS'] }), because /core/publish-openapi requires any origin to be able to fetch the document. It is public, read-only and carries no credentials.

Secrets in production (Azure Key Vault)

Azure App Settings are the standard secrets store (injected as environment variables). For an additional layer, the Key Vault SDK can be used:

import { SecretClient } from '@azure/keyvault-secrets';
import { DefaultAzureCredential } from '@azure/identity';

const credential = new DefaultAzureCredential();
const client = new SecretClient(process.env.KEY_VAULT_URL!, credential);

const dbPassword = await client.getSecret('db-password');

The App Service managed identity must be granted Key Vault Secrets User on the vault.


Audit logging

Architecture

Audit logging spans two files:

  • src/types/audit.types.ts — AuditLogEntry interface, the single source of truth for the shape of an audit record
  • src/middleware/audit.middleware.ts — auditMiddleware (automatic per-request logging) and auditLog() (explicit action logging from route handlers); re-exports AuditLogEntry for backward compatibility
  • src/services/audit.service.ts — pg-promise connection pool and persistAuditLog()

Automatic vs. explicit logging

Every authenticated request is logged automatically by auditMiddleware, which wraps res.end and calls createAuditLog() after the response is sent. The action is ${req.method} ${req.path} and the result is derived from the HTTP status code:

Status range Result
200–399 success
400–499 failure
500+ error

Route handlers can additionally call auditLog() directly to record domain-level actions with richer detail:

auditLog(req, 'process.start.zorgtoeslag', 'success', {
  processInstanceId: instance.id,
});

Database persistence

persistAuditLog() in audit.service.ts writes each entry to the audit_logs table on Azure PostgreSQL Flexible Server using a pg-promise named-parameter INSERT. It is called fire-and-forget from createAuditLog() — errors are caught and logged but never propagated to the request cycle, so a database outage does not affect API availability.

initDb() is called at server startup to verify connectivity. If the database is unreachable at startup, the backend falls back to in-memory logging and logs a warning. In-memory entries are not persisted to the database later; they exist only for the lifetime of the process.

See PostgreSQL Deployment for schema, firewall, and connection string setup.

Skipping self-referential entries

GET /audit requests are excluded from the audit log to prevent the Audit Log viewer from recording its own page loads. The skip is applied inside auditMiddleware before createAuditLog() is called:

if (req.path === '/audit') {
  return originalEnd.apply(this, args as any);
}

Known issues fixed

IP address format on Azure App Service

req.ip on Azure App Service includes the port (77.161.155.118:40796). PostgreSQL's inet type does not accept a port suffix, causing every INSERT to fail silently. Fixed in audit.service.ts by stripping the port before the insert:

ipAddress: entry.ipAddress ? entry.ipAddress.replace(/:\d+$/, '') : null,

This only affects Azure — local Express sets req.ip without a port.

Audit log viewer pagination reset

The useEffect that triggers loadAuditLogs(0) on section entry incorrectly included auditLogs.length in its dependency array. When "Meer laden" appended records, the length change re-fired the effect and reset pagination to offset 0. Fixed by removing auditLogs.length from the dependency array — activeSection changing to audit-overzicht or audit-details is sufficient to trigger the initial load.