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. TheauthResolverset 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 rightContent-Type,Content-Length, and RFC 5987Content-Disposition.throw badRequest()/throw notFound()/throw conflict()— every thrownHttpErrorbecomesapplication/problem+jsonautomatically.
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 |