---
title: Request pipeline
description: 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

```ts title="src/app.module.ts"
providers: [
  { provide: APP_INTERCEPTOR, useClass: ClassSerializerInterceptor },
  { provide: APP_INTERCEPTOR, useClass: ActivityLogsInterceptor },
  { provide: APP_GUARD, useClass: ThrottlerGuard },
  { provide: APP_FILTER, useClass: AllExceptionsFilter },
];
```

## Rate limiting

```ts
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

```ts title="src/main.ts"
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.

:::warning[Unknown fields are dropped silently, not rejected]
There is no `forbidNonWhitelisted`. Sending a key the DTO does not declare returns **200 with the key ignored**, not a 400. If a write appears to succeed but nothing changed, check the DTO first.
:::

## Security headers

```ts
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

```ts
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.

:::note[Before the Openship cutover]
`FRONTEND_URL` must carry the real origins — `https://app.hqtala.com` and anything else that will call the API. It was hardcoded to localhost until recently.
:::

## 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 `HttpException`s 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:

```ts title="src/lib/filters/all-exceptions.filter.ts"
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.
