Request pipeline
The global throttler, auth and org guards, validation pipe, activity interceptor and exception filter that every TALA request passes through.
All of this is wired in src/app.module.ts and src/main.ts. None of it is opt-in per route — adding a controller inherits the whole pipeline.
Global providers
providers: [
{ provide: APP_INTERCEPTOR, useClass: ClassSerializerInterceptor },
{ provide: APP_INTERCEPTOR, useClass: ActivityLogsInterceptor },
{ provide: APP_GUARD, useClass: ThrottlerGuard },
{ provide: APP_FILTER, useClass: AllExceptionsFilter },
];
Rate limiting
ThrottlerModule.forRoot([{ ttl: 60000, limit: 60 }]);
60 requests per 60 seconds per IP, globally. It blunts credential stuffing, refresh-token brute force, and seeder write amplification. It is per-IP, not per-account — a shared NAT shares the budget.
Validation
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
transformOptions: { enableImplicitConversion: true },
}),
);
whitelist: true strips any body property without a matching decorator on the DTO. This prevents mass assignment, and it is also the single most common source of “the field I sent disappeared” confusion.
Security headers
app.use(helmet({ referrerPolicy: { policy: "no-referrer" } }));
app.use(cookieParser());
no-referrer is deliberate: the OAuth flow passes a code in a redirect URL, and a default referrer policy would leak it to any third-party resource the landing page loads.
CORS
const frontendUrl = configService.get<string>("FRONTEND_URL");
const allowedOrigins = frontendUrl
? frontendUrl.split(",").map((origin) => origin.trim())
: ["http://localhost:5173"];
app.enableCors({ origin: allowedOrigins, credentials: true });
FRONTEND_URL is a comma-separated allowlist, so one deployment can serve several origins. Unset, it falls back to the Vite dev server only.
Activity logging
ActivityLogsInterceptor is global and logs after the response via tap(), without awaiting. A logging failure can never fail or slow a request — and a request that 500s before the response still produces no activity row.
Rows land in activity_logs with endpoint, status code, resource type and ID, IP, user agent, jsonb metadata, and duration. A retention cron in ActivityLogsRetentionService trims them.
Exception filter
AllExceptionsFilter catches everything. Intentional HttpExceptions keep their status and body. Everything else becomes a generic 500 with the stack logged server-side and never returned.
One special case is handled before the generic path:
if (!isHttp && pgCode === "23505") {
response.status(HttpStatus.CONFLICT).json({
statusCode: HttpStatus.CONFLICT,
message: "Resource conflict, please retry",
});
return;
}
A Postgres unique violation reaching the filter is an unhandled collision — a rare generated-ID clash, or a race on slug, email, or a membership pair. It surfaces as a retryable 409 instead of a 500 that would leak the constraint name.
5xx responses and any non-HTTP exception are logged with method, url, status, and stack. Nothing of that reaches the client.
Serialization
ClassSerializerInterceptor runs globally, so @Exclude() on an entity property is honoured everywhere. Asset uses it to keep createdAt, updatedAt, and deletedAt out of responses; User.password is additionally select: false at the column level, so it is never even loaded.
