Backend Development¶
The backend is packages/backend (@ronl/backend) — a Node.js 20 Express application written in TypeScript.
Project structure¶
packages/backend/src/
├── index.ts # Express app bootstrap, middleware registration
├── routes/
│ ├── index.ts # Route registration (v1/* and legacy api/*)
│ ├── health.routes.ts # GET /v1/health
│ ├── decision.routes.ts # POST /v1/decision/:key/evaluate
│ ├── m2m.routes.ts # GET|POST|DELETE /v1/m2m/* (M2M, jwtMiddleware only)
│ ├── process.routes.ts # POST /v1/process/:key/start, GET/DELETE
│ ├── template.routes.ts # GET /v1/chains/templates
│ ├── triplydb.routes.ts # GET /v1/triplydb (TriplyDB proxy)
│ ├── vendor.routes.ts # GET /v1/vendors
│ └── cache.routes.ts # GET/DELETE /v1/cache
├── middleware/
│ ├── error.middleware.ts # Global error handler, 404 handler
│ ├── version.middleware.ts # Adds API-Version header to all responses
│ ├── audit.middleware.ts # Writes audit log entry for every request
│ └── tenant.middleware.ts # Extracts and validates municipality claim
├── auth/
│ └── jwt.middleware.ts # JWT signature validation, JWKS caching
├── services/
│ ├── operaton.service.ts # Operaton REST API client
│ ├── audit.service.ts # Audit log database writes
│ ├── edocs.service.ts # OpenText eDOCS REST API client (stub + live)
│ └── externalTaskWorker.service.ts # Operaton external task long-poll worker
└── utils/
├── config.ts # Typed configuration from environment variables
├── logger.ts # Winston logger (JSON format in prod)
└── errors.ts # getErrorMessage() helper
Middleware stack¶
Middleware is registered in this order in src/index.ts:
helmet()— security headers (CSP, HSTS, etc.)cors()— CORS policy fromCORS_ORIGINenv varrateLimit()— request rate limitingexpress.json()— body parsing, limit 1 MB- Request logging — logs method, path, IP
versionMiddleware— addsAPI-Versionresponse headerauditMiddleware— writes audit entry post-response- Route handlers (
routes/index.ts) notFoundHandler— 404 for unmatched pathserrorHandler— catches and formats all thrown errors
JWT validation (jwt.middleware.ts) is applied per-route on protected endpoints, not globally. Public endpoints (e.g. GET /v1/health) do not require authentication.
Adding a new route¶
- Create
src/routes/myfeature.routes.tsfollowing the existing pattern:
import { Router, Request, Response } from 'express';
import { ApiResponse } from '@ronl/shared';
import logger from '@utils/logger';
const router = Router();
router.get('/', async (req: Request, res: Response) => {
try {
res.json({
success: true,
data: { ... },
timestamp: new Date().toISOString(),
} as ApiResponse);
} catch (error) {
logger.error('myfeature error', error);
res.status(500).json({ success: false, error: { code: 'ERROR', message: String(error) } });
}
});
export default router;
- Register it in
src/routes/index.ts:
import myfeatureRoutes from './myfeature.routes';
router.use('/v1/myfeature', myfeatureRoutes);
router.use('/api/myfeature', deprecationMiddleware('/v1/myfeature'), myfeatureRoutes);
eDOCS service and external task worker¶
edocs.service.ts wraps the OpenText eDOCS REST API. It authenticates once via POST /connect, caches the X-DM-DST session token extracted from the Set-Cookie response header, and re-authenticates automatically on 401/403. 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' }>
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 two topics:
| Topic | Reads | Writes |
|---|---|---|
rip-edocs-workspace |
projectNumber, projectName |
edocsWorkspaceId, edocsWorkspaceName, edocsWorkspaceCreated |
rip-edocs-document |
edocsWorkspaceId, documentTemplateId, edocsDocumentVariableName, + template variables |
<edocsDocumentVariableName> (e.g. edocsIntakeReportId) |
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 the full Operaton surface to machine-to-machine clients without tenant scoping. It applies jwtMiddleware only — tenantMiddleware is intentionally absent.
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.
The route group is instantiated with a dedicated OperatonService when OPERATON_M2M_BASE_URL is set, otherwise it reuses the shared singleton:
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 any route that requires a logged-in user:
import { authenticateJWT } from '@auth/jwt.middleware';
router.post('/sensitive', authenticateJWT, async (req, res) => {
const { sub, municipality, roles } = req.user!;
// ...
});
After authenticateJWT, req.user is populated with the decoded JWT claims.
TypeScript path aliases¶
The tsconfig.json configures path aliases for clean imports:
@routes/* → src/routes/*
@services/* → src/services/*
@middleware/* → src/middleware/*
@auth/* → src/auth/*
@utils/* → src/utils/*
tsc-alias resolves these aliases during the build step (npm run build).
Development commands¶
npm run dev # Start with tsx watch (hot-reload)
npm run build # Compile TypeScript → dist/
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
npm run test:unit # Unit tests only
npm run test:integration # Integration tests only
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¶
Two policies applied in src/index.ts:
import rateLimit from 'express-rate-limit';
// General API: 100 requests per 15 minutes per IP
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
message: 'Too many requests from this IP',
});
// Auth endpoints: 5 requests per 15 minutes per IP
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
message: 'Too many login attempts',
});
app.use('/v1', apiLimiter);
app.use('/v1/auth', authLimiter);
CORS¶
import cors from 'cors';
const allowedOrigins = config.corsOrigin.map((o) => o.trim());
const corsOptions: cors.CorsOptions = {
origin: (origin, callback) => {
if (!origin) { callback(null, true); return; } // allow server-to-server
if (allowedOrigins.includes(origin)) { callback(null, true); return; }
callback(new Error(`CORS blocked for origin: ${origin}`));
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
};
app.use(cors(corsOptions));
app.options('*', cors(corsOptions));
CORS_ORIGIN in .env is a comma-separated list. In production it is https://mijn.open-regels.nl. In ACC it is https://acc.mijn.open-regels.nl. In local development it is http://localhost:5173.
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.
CORS_ORIGIN in .env is a comma-separated list. In production it is https://mijn.open-regels.nl. In ACC it is https://acc.mijn.open-regels.nl. In local development it is http://localhost:5173.
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.