Docs/Web applicationsv1.0.0-rc.1

Production composition recipe

End-to-end example for a UDIOT app that needs the full production checklist: configurable security headers, custom middleware, route-level validation, typed errors, file downloads, auth, named rate limits, OpenAPI docs, liveness / readiness / startup probes, redacted config diagnostics, and Postgres migrations.

Every primitive shown here is additive — nothing breaks existing defineWebApp({ server: { securityHeaders: true } }) style apps.

1. The web server entry point

import 'reflect-metadata';
import { Pool } from 'pg';
import { Container, globalContainer } from '@tagna/udiot';
import { createWebServer, defineWebApp } from '@tagna/udiot/web';
import {
  createPostgresMigrationCheck,
  withTransaction,
} from '@tagna/udiot/database';

import { ProductsController } from './controllers/products.controller';
import { LeadsController } from './controllers/leads.controller';
import { ReportsController } from './controllers/reports.controller';
import { config } from './config';
import { sessionStore } from './services/session-store';
import { requestId } from './middleware/request-id';

const pool = new Pool({ connectionString: config.databaseUrl });

globalContainer.registerMany([ProductsController, LeadsController, ReportsController]);

await createWebServer({
  rootDir: process.cwd(),
  port: config.port,
  // Behind one TLS-terminating proxy: needed for secure cookies and client IPs.
  trustProxy: 1,
  controllers: [ProductsController, LeadsController, ReportsController],

  app: defineWebApp({
    site: { url: config.siteUrl },
  }),

  // Strict CSP, HSTS, frame-ancestors etc. — full options bag.
  securityHeaders: {
    contentSecurityPolicy:
      "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; frame-ancestors 'none'",
    strictTransportSecurity: 'max-age=31536000; includeSubDomains; preload',
    frameOptions: 'DENY',
    permissionsPolicy: 'camera=(), microphone=(), geolocation=()',
  },

  // Custom Express middleware applied across the lifecycle.
  middleware: {
    beforeAll: [requestId()],
    beforeControllers: [],
    error: [],
  },

  // Named rate-limit policies — opt routes in via @UseRateLimit('checkout').
  rateLimits: {
    default: { maxRequests: 120, windowMs: 60_000 },
    checkout: { maxRequests: 20, windowMs: 60_000, key: 'userOrIp' },
    reportDownload: { maxRequests: 60, windowMs: 60_000, key: 'userOrIp' },
  },

  // Pluggable auth resolver — protected routes (decorated with @RequireAuth)
  // call this; falsy result fails closed with a 401 Problem Details.
  authResolver: async (req) => {
    const auth = req.headers['authorization'];
    if (typeof auth !== 'string') return undefined;
    const token = auth.replace(/^Bearer\s+/i, '');
    return sessionStore.findUserByToken(token);
  },

  // /health/live, /health/ready, /health/startup, /health (legacy).
  readiness: {
    timeoutMs: 3_000,
    checks: {
      database: async () => {
        const client = await pool.connect();
        try {
          await client.query('SELECT 1');
          return { name: 'database', ok: true };
        } finally {
          client.release();
        }
      },
      migrations: createPostgresMigrationCheck({
        pool,
        directory: 'migrations',
      }),
    },
  },

  // /diagnostics/config — secrets are auto-redacted.
  diagnosticsConfig: {
    config: () => config,
  },

  // Generates /openapi.json from controller metadata.
  openapi: {
    info: { title: 'Astra Archive API', version: '2.0.0' },
    servers: [{ url: config.siteUrl, description: 'production' }],
    securitySchemes: { bearer: { type: 'http', scheme: 'bearer' } },
  },
});

2. A controller that uses every production primitive

import {
  Body,
  Controller,
  CurrentUser,
  Get,
  Param,
  Post,
  Query,
  RequireAuth,
  UseRateLimit,
} from '@tagna/udiot';
import {
  badRequest,
  conflict,
  fileResponse,
  notFound,
  ServerHttpError,
  unprocessableEntity,
} from '@tagna/udiot/web';

interface AuthenticatedUser {
  id: string;
  email: string;
  scopes: string[];
}

@Controller('/api/v2/reports')
export class ReportsController {
  constructor(private readonly reports: ReportsService) {}

  @RequireAuth({ scopes: ['reports:read'] })
  @UseRateLimit('default')
  @Get({
    path: '/',
    query: {
      page: { type: 'number', min: 1 },
      limit: { type: 'number', min: 1, max: 100 },
    },
    responses: {
      200: { description: 'Paginated reports for the caller' },
      401: { description: 'Authentication required' },
    },
    tags: ['reports'],
  })
  async list(
    @CurrentUser() user: AuthenticatedUser,
    @Query('page') page?: string,
    @Query('limit') limit?: string,
  ) {
    return this.reports.list(user.id, {
      page: Number(page ?? 1),
      limit: Number(limit ?? 20),
    });
  }

  @RequireAuth({ scopes: ['reports:read'] })
  @UseRateLimit('reportDownload')
  @Get({
    path: '/:id/download',
    params: { id: { type: 'uuid', required: true } },
    responses: {
      200: { description: 'PDF report', contentType: 'application/pdf' },
      404: { description: 'Report not found' },
    },
    tags: ['reports'],
  })
  async download(@CurrentUser() user: AuthenticatedUser, @Param('id') id: string) {
    const report = await this.reports.findOwnedReport(id, user.id);
    if (!report) throw notFound('Report not found', { code: 'REPORT_NOT_FOUND' });

    return fileResponse(report.pdfBytes, {
      fileName: `${report.slug}.pdf`,
      contentType: 'application/pdf',
      cacheControl: 'private, no-store',
    });
  }

  @UseRateLimit('checkout')
  @Post({
    path: '/checkout/capture',
    body: {
      paymentToken: { type: 'string', required: true, min: 8 },
      productId: { type: 'uuid', required: true },
    },
    responses: {
      200: { description: 'Capture succeeded' },
      409: { description: 'Already captured' },
      422: { description: 'Validation failed' },
    },
    tags: ['checkout'],
  })
  async capture(@Body() body: { paymentToken: string; productId: string }) {
    try {
      return await this.reports.captureCheckout(body);
    } catch (error) {
      if (error instanceof DuplicateCaptureError) {
        throw conflict('Checkout already captured', {
          code: 'DUPLICATE_CAPTURE',
          extensions: { previousCaptureId: error.id },
        });
      }
      throw error; // anything else falls through to the 500 handler
    }
  }
}

Key things to notice:

  • @RequireAuth + @CurrentUser() — no manual bearer token parsing. The authResolver set on the server runs once per protected request.
  • Schemas declared at the route boundary drive both validation (unprocessableEntity + Problem Details on failure) and OpenAPI emit.
  • fileResponse(...) returns binary downloads with the right Content-Type, Content-Length, and RFC 5987 Content-Disposition.
  • throw badRequest() / throw notFound() / throw conflict() — every thrown HttpError becomes application/problem+json automatically.

3. Postgres migrations

Hand-rolled SQL files in migrations/ — applied in lexical order with a Postgres advisory lock so rolling deploys don't race.

migrations/
  0001_init.sql
  0002_add_reports.sql
  0003_lead_capture.sql
import { Pool } from 'pg';
import { createPostgresMigrationRunner } from '@tagna/udiot/database';

const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const runner = createPostgresMigrationRunner({ pool, directory: 'migrations' });

const result = await runner.up();
console.log(`applied: ${result.applied.length}, skipped: ${result.skipped.length}`);

The runner writes to __udiot_migrations, records SHA-256 checksums, and refuses to proceed when a previously applied file has been edited (unless allowDrift: true is set).

For transactional repository methods, wrap them in withTransaction:

import { withTransaction } from '@tagna/udiot/database';

await withTransaction(pool, async (client) => {
  await client.query('INSERT INTO orders ...');
  await client.query('INSERT INTO order_items ...');
});

4. Production tests with createUdiotTestClient

import { createUdiotTestClient, scenario } from '@tagna/udiot';

describe('reports flow', () => {
  let server: Awaited<ReturnType<typeof createWebServer>>;
  let client: Awaited<ReturnType<typeof createUdiotTestClient>>;

  beforeAll(async () => {
    server = await createWebServer({
      rootDir: __dirname,
      listen: false,
      controllers: [ReportsController],
      rateLimits: { default: { maxRequests: 100, windowMs: 60_000 } },
      authResolver: async () => ({ id: 'u1', email: 't@e.com', scopes: ['reports:read'] }),
    });
    client = await createUdiotTestClient({ app: server.app, bearerToken: 'test-token' });
  });

  afterAll(async () => {
    await client.close();
  });

  it('lists and downloads a report end-to-end', async () => {
    const result = await scenario('paid report lifecycle')
      .post('/api/v2/reports/checkout/capture', { paymentToken: 'pm-123abc', productId: '11111111-1111-4111-8111-111111111111' })
        .expect(200)
        .capture((ctx, body) => { ctx.values.captureId = (body as any).id; })
      .get('/api/v2/reports/').expect(200)
      .run(client);

    expect(result.steps.map((step) => step.status)).toEqual([200, 200]);
  });
});

The client manages its own ephemeral port; tests never touch raw fetch or supertest. The scenario chain captures values from earlier responses into ctx.values so later steps can reference them.

Like a browser, the client keeps cookies the app sets (a session after login, a CSRF cookie) and sends them on later requests; getCookies() and clearCookies() inspect and reset them, and cookies: false turns this off. Redirects are not followed, so a test sees the 302 and its Location.

This paragraph describes the current source correction. The published RC's declarations do not expose getCookies()/clearCookies() or the cookies option; verify the exact installed archive before using them. See HTTP testing.

5. Operations checklist

Endpoint Purpose
/health Legacy single endpoint; preserved for backward compat
/health/live Liveness probe (cheap, no dependencies)
/health/ready Readiness probe (runs all readiness.checks)
/health/startup Startup probe (one-shot startup check results)
/diagnostics/config Redacted snapshot of resolved config (development and test, or exposeDiagnostics)
/__udiot/routes Page route manifest (development and test, or exposeDiagnostics)
/__udiot/diagnostics Page-level warnings (development and test, or exposeDiagnostics)
/openapi.json Generated OpenAPI 3.0 spec (when openapi.info is set)

server.getFullRouteManifest() programmatically returns the complete route inventory — pages, controller endpoints, static mount, health and diagnostics endpoints — annotated with HTTP method, controller, handler, auth requirement, and rate-limit policy.

6. Migration from custom controller glue

For apps that currently hand-roll these patterns, the table maps common application helpers to the framework primitives that can replace them:

App-side workaround Framework primitive
MutableResponse.send(string | Buffer) Widened controller Response.send(string | Buffer | Uint8Array) + fileResponse()
RequestLike with ip / cookie helpers Widened Request with ip, ips, protocol, hostname, cookies, requestId, raw
Manual respond() / respondFile() wrappers throw badRequest() etc. + fileResponse()
Manual bearer parsing per route @RequireAuth + @CurrentUser + authResolver
In-controller in-memory rate limiter WebServerOptions.rateLimits + @UseRateLimit('name')
securityHeaders: true only securityHeaders: SecurityHeadersOptions (CSP, HSTS, COOP, CORP, Permissions-Policy)
Startup logging covering only page routes getFullRouteManifest()
Custom productionConfigStatus() + /api/v2/health readiness.checks + /health/ready + /diagnostics/config
Custom evaluate-production-readiness.ts createUdiotTestClient + scenario(...).run(client)
Custom SQL migration runner createPostgresMigrationRunner + createPostgresMigrationCheck
From the canonical UDIOT public documentation.Try it in the playground ↗