Docs/Serverv1.0.0-rc.1

Server Utilities Overview

Package: @tagna/udiot/server
Runtime: Node.js 22.17+

Primary use case: Express-based applications that want focused UDIOT helpers without adopting the custom router/server stack.

What This Module Provides

@tagna/udiot/server is a lightweight subpath for server-rendered and API-heavy apps. It exports utilities that compose cleanly with Express:

  • createNunjucksEngine() for Nunjucks setup, including per-request directory rendering via renderFrom() (install the optional peer nunjucks)
  • createSessionMiddleware() for session wiring with either a custom store or PostgreSQL shorthand
  • flashMiddleware() for request flash messages with accumulation support
  • csrfMiddleware() for CSRF protection with route exclusions
  • createUploadMiddleware() for disk uploads or custom Multer storage engines
  • expressCacheMiddleware() for response caching on standard Express handlers
  • createRequestScopeMiddleware() for per-request DI containers backed by AsyncLocalStorage
  • toExpressRouter() for mounting @Controller() classes as Express routers — reads @RequireAuth, @CurrentUser, @UseRateLimit, and route-level validation schemas
  • param() for normalizing Express 5 req.params[key] values to a single string
  • createEventStream() for Server-Sent Events with memory or Redis-backed publish/subscribe

Production primitives (also re-exported from @tagna/udiot/web):

  • HttpError + helpers (badRequest, unauthorized, forbidden, notFound, conflict, unprocessableEntity, tooManyRequests, internalServerError) — thrown errors auto-serialize as RFC 7807 application/problem+json
  • fileResponse(body, options) — returning a FileResponse from a controller method sends a typed binary download (Buffer, Uint8Array, string, or Readable)
  • createSecurityHeadersExpress(options) — full CSP / HSTS / COOP / CORP / Permissions-Policy as Express middleware
  • createExpressRateLimit(options) — Express adapter for the universal rate limiter, with ip/user/userOrIp/custom key strategies and pluggable store
  • createEventStream(options) — Server-Sent Events helper for browser live updates, with heartbeat, auth gate, memory backend, and Redis backend
  • generateOpenApi({ controllers, info, servers, securitySchemes }) — emits an OpenAPI 3.0 document from controller decorator metadata
  • redactSecrets(value, options) — masks values of secret-shaped keys (password, secret, token, api_key, …) for safe diagnostics
  • compileRouteValidator / runRouteValidation — building blocks behind the route-level schemas wired into toExpressRouter()

Quick Start

import express from 'express';
import {
  createNunjucksEngine,
  createRequestScopeMiddleware,
  createSessionMiddleware,
  csrfMiddleware,
  flashMiddleware,
  toExpressRouter,
} from '@tagna/udiot/server';
import { Container } from '@tagna/udiot';

const app = express();
const container = new Container();
const views = createNunjucksEngine(['./views', './themes/default/views']);
const csrf = csrfMiddleware({
  exclude: ['/webhooks/*'],
});

views.express(app);
app.use(express.json());
app.use(createSessionMiddleware({ secret: process.env.SESSION_SECRET! }));
app.use(flashMiddleware());
app.use(createRequestScopeMiddleware({ container }));
app.use(csrf.token);
app.use('/api', toExpressRouter(MyController, { container, includeBasePath: false }));

Server-Sent Events

Use createEventStream() for one-way browser updates such as live agent progress, build logs, notifications, or run-event tails:

import { createEventStream } from '@tagna/udiot/server';

const runs = createEventStream({
  channel: 'runs',
  heartbeatMs: 15000,
  // Application-owned: verify credentials AND access to this stream/channel.
  authorize: async (req) => verifyRunAccess(req),
});

app.get('/events/runs', runs.handler);

await runs.publish({ runId: 'run_123', status: 'started' }, 'run');

For multi-instance deployments, pass backend: 'redis' plus separate ioredis publisher and subscriber clients. See event-streams.md.

Templates

Use renderFrom() when the template search path depends on the request, such as multi-site theming:

const engine = createNunjucksEngine('./views');

app.get('/:siteSlug', async (req, res, next) => {
  try {
    const html = engine.renderFrom(
      `./themes/${req.params.siteSlug}/views`,
      'home.njk',
      { site: req.params.siteSlug },
    );
    res.send(html);
  } catch (error) {
    next(error);
  }
});

For absolute or already-resolved template paths, use renderFile(). Async Nunjucks filters can be registered with addAsyncFilter(). renderFrom() reuses one environment per set of view directories, so template caches survive between requests, and globals or filters added later reach every environment.

Built-in filters are autoescape-safe. {{ comment | nl2br }} escapes the text and only marks the inserted <br> tags as safe, so never add | safe after it. truncate(length, killwords = true, end = '…') accepts Nunjucks' built-in arguments; pass killwords: false to cut at a word boundary.

Sessions, Flash, and CSRF

Sessions

createSessionMiddleware() accepts:

  • store for any express-session compatible store
  • pgPool as a shorthand for connect-pg-simple
  • cookieName, defaulting to udiot.sid

If you omit both store and pgPool, Express uses its default in-memory store, which is useful for local development.

Cookies are secure in production. Behind a reverse proxy that terminates TLS, set trustProxy on createWebServer() (or app.set('trust proxy', 1) on a plain Express app); otherwise Express sees an insecure connection and never sends the cookie. Prefer a hop count or the proxies' addresses over true, which trusts X-Forwarded-For from any client and lets callers choose the IP that rate limits see. Sessions expire after 7 days without activity (maxAgeDays; set rolling: false to count from sign-in instead), and pgPool stores them in the udiot_sessions table (tableName).

Flash

flashMiddleware() now accumulates multiple messages per type. Read arrays from res.locals.flashMessages:

req.flash('error', 'Title is required');
req.flash('error', 'Slug already exists');

// Later in the request lifecycle:
res.locals.flashMessages.error;
// ['Title is required', 'Slug already exists']

CSRF

Use csrfMiddleware({ exclude: [...] }) to skip CSRF validation for webhooks, health checks, or bearer-token APIs:

const csrf = csrfMiddleware({
  exclude: ['/webhooks/*', '/health'],
  // Other sites allowed to post forms here, if any.
  trustedOrigins: ['https://admin.example.com'],
});

app.post('/login', csrf.protect, async (req, res) => {
  // ...authenticate, then regenerate the session and issue a fresh token.
  res.json({ csrfToken: csrf.rotate(req) });
});

At sign-in, give the session a new id so an id planted before login is useless afterwards (session fixation), then issue a fresh CSRF token:

import { regenerateSession } from '@tagna/udiot/server';

app.post('/login', csrf.protect, async (req, res) => {
  const user = await authenticate(req.body);
  await regenerateSession(req); // new session id, existing session data kept
  req.session.userId = user.id;
  res.json({ csrfToken: csrf.rotate(req) });
});

As defense in depth, protect also rejects unsafe requests whose Origin header (or, without one, Referer) names a different host, and Origin: null. Requests without either header still need a valid token. Set checkOrigin: false only if a trusted proxy rewrites these headers. Call rotate(req) after login or privilege changes so a token captured before sign-in stops working.

Single-page apps

A single-page app has no server-rendered form to carry the _csrf field. Expose the token through an endpoint and send it in the X-CSRF-Token header (or the headerName you configured) on every unsafe request:

app.get('/api/csrf', csrf.token, (_req, res) => {
  res.json({ csrfToken: res.locals.csrfToken });
});
// In the browser
const { csrfToken } = await fetch('/api/csrf', { credentials: 'same-origin' }).then((r) => r.json());

await fetch('/api/orders', {
  method: 'POST',
  credentials: 'same-origin',
  headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken },
  body: JSON.stringify({ sku: 'lamp-1' }),
});

Keep the token in memory rather than localStorage, and fetch it again after sign-in, because rotate() invalidates the old one. The session cookie defaults to SameSite=Lax, which already stops cross-site form posts; the token covers the rest. When the app and the API are on different origins, add the app's origin to trustedOrigins and allow it with credentials in your CORS settings. Requests authenticated only by an Authorization: Bearer header, with no cookie, cannot be forged cross-site; list those routes in exclude.

Uploads

Disk uploads still work via destDir, but you can now inject any Multer storage engine:

const upload = createUploadMiddleware({
  storage: multer.memoryStorage(),
  allowedMimeTypes: ['image/png', 'image/webp'],
});

This makes it straightforward to plug in multer-s3, multer-gcs, or any custom storage backend.

With destDir, the stored file's extension always comes from the validated MIME type, never from the client's file name: evil.html uploaded as application/pdf is saved as .pdf. Types without a known extension keep the original one unless it is executable or renderable (.html, .svg, .js, ...), which becomes .bin. File contents are checked against the claimed type by default for images, PDF and ZIP (verifyMagicBytes: false turns this off).

Request-Scoped DI

UDIOT's Container already supports ServiceScope.SCOPED. createRequestScopeMiddleware() connects that scope to Express requests:

import { Inject, Injectable, ServiceScope } from '@tagna/udiot';
import { EXPRESS_REQUEST, createRequestScopeMiddleware } from '@tagna/udiot/server';

@Injectable({ scope: ServiceScope.SCOPED })
class CurrentRequest {
  constructor(@Inject(EXPRESS_REQUEST) private readonly req: Request) {}

  get id(): string {
    return String(this.req.headers['x-request-id'] ?? '');
  }
}

Each request gets a child container, and toExpressRouter() resolves controller classes against that request container automatically.

Controllers as Express Routers

If you prefer decorators but need Express compatibility, adapt controller classes directly:

import { Controller, Get, Param } from '@tagna/udiot';
import { toExpressRouter } from '@tagna/udiot/server';

@Controller('/posts')
class PostsController {
  @Get('/:id')
  show(@Param('id') id: string) {
    return { id };
  }
}

app.use(toExpressRouter(PostsController, { container }));

toExpressRouter() respects controller base paths, route middleware, request-scoped DI, and the standard parameter decorators (@Param, @Query, @Body, @Header, @Req, @Res, @CurrentUser).

ToExpressRouterOptions

Field Purpose
container DI container used to resolve controller instances. Defaults to globalContainer.
includeBasePath false returns the inner router without mounting the controller's path prefix.
mergeParams Forwarded to Router({ mergeParams }). Defaults to true.
rateLimits Record<string, RequestHandler> — pre-built named policies looked up via @UseRateLimit('name'). The web server populates this from WebAppServerConfig.rateLimits automatically.
authResolver (req) => unknown | Promise<unknown> — resolves the auth principal for @RequireAuth-tagged routes. Falsy result → 401 Problem Details (unless the route declares optional: true).
problemDetails When true (default), thrown HttpErrors serialize as application/problem+json. Set false for the legacy { error, message } envelope.

HTTP Errors and Problem Details

HttpError is the server-side error meant to be thrown from controller methods. Distinct from the existing client-side HttpError in enterprise/http.ts (which represents a failed fetch() response) — this one is exported from @tagna/udiot/server and re-exported from @tagna/udiot/web as ServerHttpError to avoid collision at the package root.

import {
  badRequest,
  conflict,
  notFound,
  unprocessableEntity,
} from '@tagna/udiot/server';

@Post('/leads')
async create(@Body() body: { email?: string }) {
  if (!body.email) {
    throw badRequest('Email is required', { code: 'EMAIL_REQUIRED' });
  }
  // ...
}

Default response shape (RFC 7807):

{
  "type": "about:blank",
  "title": "Bad Request",
  "status": 400,
  "detail": "Email is required",
  "code": "EMAIL_REQUIRED"
}

Pass errors: [...] for field-level validation details, extensions: {...} for arbitrary additional members, and headers: {...} for headers to set alongside the response (e.g. Retry-After, WWW-Authenticate).

File and Binary Responses

import { fileResponse } from '@tagna/udiot/server';

@Get('/reports/:id/download')
async download(@Param('id') id: string) {
  const report = await reports.findById(id);
  return fileResponse(report.pdfBytes, {
    fileName: `${report.slug}.pdf`,
    contentType: 'application/pdf',
    cacheControl: 'private, no-store',
  });
}

Returning a FileResponse from a controller is enough — the adapter sets Content-Type, Content-Length, and an RFC 5987 Content-Disposition (with safe filename escaping). Body accepts Buffer | Uint8Array | string | NodeJS.ReadableStream.

Configurable Security Headers

createSecurityHeadersExpress(options) is the Express adapter for the options bag accepted by WebAppServerConfig.securityHeaders:

import { createSecurityHeadersExpress } from '@tagna/udiot/server';

app.use(createSecurityHeadersExpress({
  contentSecurityPolicy:
    "default-src 'self'; script-src 'self'; img-src 'self' data:; object-src 'none'; frame-ancestors 'none'",
  strictTransportSecurity: 'max-age=31536000; includeSubDomains; preload',
  frameOptions: 'DENY',
  permissionsPolicy: 'camera=(), microphone=(), geolocation=()',
}));

Pass false for any header to skip it. Sensible production defaults applied when fields are omitted.

Express Rate Limiting

import { createExpressRateLimit } from '@tagna/udiot/server';

app.use('/api/checkout', createExpressRateLimit({
  maxRequests: 20,
  windowMs: 60_000,
  key: 'userOrIp',         // 'ip' | 'user' | 'userOrIp' | (req) => string
}));

Returns Retry-After and X-RateLimit-Limit / X-RateLimit-Remaining headers. With @RequireAuth routes, user and userOrIp limits run after the principal is resolved, while IP limits run first so anonymous floods are cut off before authentication work; override with afterAuth. Behind a proxy, IP keys need trustProxy to see the real client address. Distributed deployments can pass an ExpressRateLimitStore for shared state (e.g. Redis). When wiring through createWebServer, prefer named policies (WebAppServerConfig.rateLimits) plus @UseRateLimit on the route so policy stays out of the handler.

OpenAPI Generation

import { generateOpenApi } from '@tagna/udiot/server';

const doc = generateOpenApi({
  info: { title: 'Catalog API', version: '1.0.0' },
  controllers: [ProductsController, OrdersController],
  servers: [{ url: 'https://api.example.com' }],
  securitySchemes: { bearer: { type: 'http', scheme: 'bearer' } },
});

The generator walks every controller's route metadata, translates UDIOT ValidationSchema maps to JSON Schema, infers path parameters, and emits security for @RequireAuth-tagged routes plus x-rateLimit for @UseRateLimit. When using createWebServer, pass the same options under the openapi key and the framework serves the document at /openapi.json automatically — see web overview.

Redacting Secrets

import { redactSecrets } from '@tagna/udiot/server';

const safe = redactSecrets({
  databaseUrl: 'postgres://user:pass@host/db',
  paymentApiKey: 'sk-secret-12345',
  features: { newCheckout: true },
});
// { databaseUrl: '...', paymentApiKey: '***', features: { newCheckout: true } }

Default patterns mask password, secret, token, api_key, private_key, access_key, authorization, credential (case-insensitive, key-name match). Pass patterns to extend, maxDepth to bound recursion.

Response Caching

expressCacheMiddleware() mirrors the existing cache manager but for Express request/response objects:

app.get(
  '/api/posts',
  expressCacheMiddleware({ ttl: 300, tags: ['posts'] }),
  postsHandler,
);

Responses are marked with X-Cache: MISS on fill and X-Cache: HIT on replay. Only complete 200 responses are stored, and binary bodies are stored as base64 so they replay byte for byte from JSON-based stores such as Redis.

Param Helper

Express 5 parameter values can be string | string[]. Use param(req, 'id') when you need a single string consistently:

import { param } from '@tagna/udiot/server';

const id = param(req, 'id');
From the canonical UDIOT public documentation.Try it in the playground ↗