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:
helmet()— security headers (CSP, HSTS), only whenconfig.security.helmetEnabledcors()—originiscorsOriginCallback(...); see CORSrateLimit()— one global limiter, skipped for the ValidSign callback path, which has its own; its 429 israteLimitHandlerfromerror.middleware.tsexpress.json({ limit: '1mb' })— wrapped so the ValidSign callback path skips it and parses its own bodyexpress.urlencoded({ limit: '1mb' })- Request logging — method, path, query, IP, user agent
API-Versionheader — set by an inline handler and again byversionMiddleware; both writepackage.json's version, so the duplicate is harmlessauditMiddleware— writes an audit entry once the response is sent- The root router at
/— the service banner - The route registry —
app.use(mount, router)for each entry ofroutes/registry.ts, in array order notFoundHandlerfromerror.middleware.ts—404 NOT_FOUNDerrorHandlerfromerror.middleware.ts— a body the parser refused answers400 MALFORMED_BODY(does not parse),413 PAYLOAD_TOO_LARGE(over the limit) orINVALID_BODY(any other body-parser refusal, keeping its 4xx status); anything else is500 INTERNAL_ERROR, with the message hidden whenNODE_ENVisproduction
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.
- Create
src/routes/myfeature.routes.tsfollowing 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;
- Add it to
routeRegistryinsrc/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.
-
Describe every operation it serves in
openapi/openapi.yaml.coverage.test.tsfails 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. Runnpm run lint:openapias well — both backend workflows run it. See API specification for the published document. -
Call
expectToMatchOperation(res, '<method>', '<path>')fromsrc/openapi/testing/conformance.tsin the route's own test, for each operation.<path>is the document's path — no/v1prefix,{braces}for parameters. The helper fails the test unless the status is documented for that operation, a 2xx carriesAPI-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 inCONFORMANCE_LOG. After Jest,scripts/check-conformance-coverage.cjsfailsnpm testwhen 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 /connectandEDOCS_USER_ID/EDOCS_PASSWORD. The exported singletonedocsServiceis this principal; background archiving and machine clients use it. - A person connects with
POST /connectcarrying their Entra ID token in theX-DM-AUTHheader, no password. A route derives that client withedocsService.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:
After modifying shared types, rebuild the package before the backend picks up the changes:
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-caseandfeedbackrequire a solved PoW challenge before a GitLab work item is created.GET /v1/public/altcha/challengeissues a signed challenge (max 50,000 iterations, 10-minute expiry) viaaltcha-lib;verifySolution()checks it on submit.ALTCHA_HMAC_KEYconfigures the HMAC secret — when unset, the check bypasses gracefully so local dev isn't blocked.upload-fileis intentionally excluded, since it's a pre-upload step rather than the final submission gate. - Upload type whitelist —
upload-filechecks both MIME type and file extension independently againstALLOWED_UPLOAD_MIMETYPES, blocking extension spoofing (a file claiming an allowed MIME type with a disallowed extension, or vice versa). - Dedicated rate limit —
publicWriteLimitercaps these three routes at 10 requests per 15 minutes per IP, on top of the global limiter, with standardisedRateLimit-*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
Originheader — 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 ishttps://mijn.open-regels.nl, in ACChttps://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 ishttps://<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.netpattern 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_SLUGSholds: the guard readsDEPLOYMENT_ENV, notNODE_ENV, because acceptance runsNODE_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—AuditLogEntryinterface, the single source of truth for the shape of an audit recordsrc/middleware/audit.middleware.ts—auditMiddleware(automatic per-request logging) andauditLog()(explicit action logging from route handlers); re-exportsAuditLogEntryfor backward compatibilitysrc/services/audit.service.ts— pg-promise connection pool andpersistAuditLog()
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:
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:
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:
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.