From b9f8d95f1d5f27afa8ecdf23a6e7d97b52515d79 Mon Sep 17 00:00:00 2001 From: devgrace100 <307987547+devgrace100@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:32:56 +0100 Subject: [PATCH 1/2] feat: add shared pagination query DTO and Paginated response Add PaginationQueryDto in src/common (page default 1, limit default 20, max 100) and a Paginated type with paginate() / toSkipTake() helpers. PaginatedResponseInterceptor turns a handler's [items, total] result, the shape of prisma.$transaction([findMany, count]), into { items, total, page, limit }. GET /events now uses both: it takes ?page=&limit=, orders by startsAt then id so rows can't shift between pages, and returns a Paginated body instead of a bare array. Documented in docs/API.md. Closes #245 Closes #246 --- CHANGELOG.md | 7 +++ docs/API.md | 31 ++++++++++++ src/common/dto/pagination-query.dto.spec.ts | 45 +++++++++++++++++ src/common/dto/pagination-query.dto.ts | 25 ++++++++++ .../paginated-response.interceptor.spec.ts | 46 +++++++++++++++++ .../paginated-response.interceptor.ts | 50 +++++++++++++++++++ src/common/pagination/paginated.spec.ts | 28 +++++++++++ src/common/pagination/paginated.ts | 30 +++++++++++ src/events/events.controller.ts | 18 +++++-- src/events/events.service.spec.ts | 41 +++++++++++++++ src/events/events.service.ts | 28 +++++++---- 11 files changed, 337 insertions(+), 12 deletions(-) create mode 100644 src/common/dto/pagination-query.dto.spec.ts create mode 100644 src/common/dto/pagination-query.dto.ts create mode 100644 src/common/interceptors/paginated-response.interceptor.spec.ts create mode 100644 src/common/interceptors/paginated-response.interceptor.ts create mode 100644 src/common/pagination/paginated.spec.ts create mode 100644 src/common/pagination/paginated.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 737fb9c..8db6190 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,3 +11,10 @@ This project follows [Keep a Changelog](https://keepachangelog.com/). - Non-custodial ticket lifecycle: issue, purchase, transfer, check-in, revoke, resale marketplace - Users module: profile, wallet connect, email lookup +- Shared `PaginationQueryDto` (`?page=&limit=`, default 20, max 100) and + `Paginated` response body (`items`, `total`, `page`, `limit`) with a + `PaginatedResponseInterceptor`; see docs/API.md + +### Changed +- `GET /events` is paginated and returns `{ items, total, page, limit }` + instead of a bare array diff --git a/docs/API.md b/docs/API.md index 583fedc..92ed0af 100644 --- a/docs/API.md +++ b/docs/API.md @@ -16,3 +16,34 @@ full non-custodial flow. `offline-public-keys` / `:ticketId/offline-token` support gate verification with no network at the door — see `docs/OFFLINE_VERIFICATION.md`. + +## Pagination + +Offset-paginated listings take `?page=&limit=` (`PaginationQueryDto`, +`src/common/dto/pagination-query.dto.ts`): + +| Param | Default | Rules | +|---|---|---| +| `page` | `1` | integer ≥ 1 (1-based) | +| `limit` | `20` | integer 1–100 | + +An out-of-range value is rejected with `400`. The response body is a +`Paginated` (`src/common/pagination/paginated.ts`): + +```json +{ "items": [ ... ], "total": 57, "page": 2, "limit": 20 } +``` + +`total` counts matching rows across all pages. A page past the end +returns `items: []` with the real `total`. + +Currently paginated: `GET /events` (published events, ordered by +`startsAt` then `id` so rows don't shift between pages). +`GET /tickets/resale` uses cursor pagination instead +(`?cursor=&limit=` → `{ items, nextCursor, limit }`). + +To paginate a new endpoint, accept `@Query() query: PaginationQueryDto`, +have the service return `prisma.$transaction([findMany({ ...toSkipTake(query) }), count()])`, +and add `@UseInterceptors(PaginatedResponseInterceptor)` to the handler. The +interceptor turns the `[items, total]` result into a `Paginated` body. +Handlers can also build the body directly with `paginate(items, total, query)`. diff --git a/src/common/dto/pagination-query.dto.spec.ts b/src/common/dto/pagination-query.dto.spec.ts new file mode 100644 index 0000000..607a20c --- /dev/null +++ b/src/common/dto/pagination-query.dto.spec.ts @@ -0,0 +1,45 @@ +import 'reflect-metadata'; +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { + DEFAULT_PAGE_LIMIT, + MAX_PAGE_LIMIT, + PaginationQueryDto, +} from './pagination-query.dto'; + +describe('PaginationQueryDto', () => { + it('defaults to the first page of 20', async () => { + const dto = plainToInstance(PaginationQueryDto, {}); + expect(await validate(dto)).toHaveLength(0); + expect(dto.page).toBe(1); + expect(dto.limit).toBe(DEFAULT_PAGE_LIMIT); + expect(DEFAULT_PAGE_LIMIT).toBe(20); + }); + + it('converts query-string values to numbers', async () => { + const dto = plainToInstance(PaginationQueryDto, { page: '3', limit: '50' }); + expect(await validate(dto)).toHaveLength(0); + expect(dto).toMatchObject({ page: 3, limit: 50 }); + }); + + it('accepts the maximum limit', async () => { + const dto = plainToInstance(PaginationQueryDto, { + limit: String(MAX_PAGE_LIMIT), + }); + expect(await validate(dto)).toHaveLength(0); + }); + + it.each([ + ['limit', String(MAX_PAGE_LIMIT + 1)], + ['limit', '0'], + ['limit', '2.5'], + ['limit', 'ten'], + ['page', '0'], + ['page', '-1'], + ['page', '1.5'], + ])('rejects %s=%s', async (property, value) => { + const dto = plainToInstance(PaginationQueryDto, { [property]: value }); + const errors = await validate(dto); + expect(errors.map((e) => e.property)).toContain(property); + }); +}); diff --git a/src/common/dto/pagination-query.dto.ts b/src/common/dto/pagination-query.dto.ts new file mode 100644 index 0000000..0ba1839 --- /dev/null +++ b/src/common/dto/pagination-query.dto.ts @@ -0,0 +1,25 @@ +import { Type } from 'class-transformer'; +import { IsInt, IsOptional, Max, Min } from 'class-validator'; + +export const DEFAULT_PAGE_LIMIT = 20; +export const MAX_PAGE_LIMIT = 100; + +/** + * Shared `?page=&limit=` query for offset-paginated listing endpoints. + * `page` is 1-based; `limit` is capped so a client can't ask for the + * whole table in one request. + */ +export class PaginationQueryDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page: number = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(MAX_PAGE_LIMIT) + limit: number = DEFAULT_PAGE_LIMIT; +} diff --git a/src/common/interceptors/paginated-response.interceptor.spec.ts b/src/common/interceptors/paginated-response.interceptor.spec.ts new file mode 100644 index 0000000..567c00a --- /dev/null +++ b/src/common/interceptors/paginated-response.interceptor.spec.ts @@ -0,0 +1,46 @@ +import 'reflect-metadata'; +import type { CallHandler, ExecutionContext } from '@nestjs/common'; +import { lastValueFrom, of } from 'rxjs'; +import { PaginatedResponseInterceptor } from './paginated-response.interceptor'; + +function contextWithQuery(query: Record): ExecutionContext { + return { + switchToHttp: () => ({ getRequest: () => ({ query }) }), + } as unknown as ExecutionContext; +} + +function handlerReturning(value: unknown): CallHandler<[unknown[], number]> { + return { handle: () => of(value as [unknown[], number]) }; +} + +describe('PaginatedResponseInterceptor', () => { + const interceptor = new PaginatedResponseInterceptor(); + + it('wraps [items, total] with the requested page and limit', async () => { + const result = await lastValueFrom( + interceptor.intercept( + contextWithQuery({ page: '2', limit: '5' }), + handlerReturning([['f', 'g'], 7]), + ), + ); + expect(result).toEqual({ items: ['f', 'g'], total: 7, page: 2, limit: 5 }); + }); + + it('applies the default page and limit when the query omits them', async () => { + const result = await lastValueFrom( + interceptor.intercept(contextWithQuery({}), handlerReturning([[], 0])), + ); + expect(result).toEqual({ items: [], total: 0, page: 1, limit: 20 }); + }); + + it.each([[['a']], [{ items: [], total: 0 }], [[[], '3']]])( + 'rejects a handler result that is not [items, total]: %j', + async (value) => { + await expect( + lastValueFrom( + interceptor.intercept(contextWithQuery({}), handlerReturning(value)), + ), + ).rejects.toThrow('expects the handler to return [items, total]'); + }, + ); +}); diff --git a/src/common/interceptors/paginated-response.interceptor.ts b/src/common/interceptors/paginated-response.interceptor.ts new file mode 100644 index 0000000..be16335 --- /dev/null +++ b/src/common/interceptors/paginated-response.interceptor.ts @@ -0,0 +1,50 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { plainToInstance } from 'class-transformer'; +import type { Request } from 'express'; +import { Observable, map } from 'rxjs'; +import { PaginationQueryDto } from '../dto/pagination-query.dto'; +import { paginate, type Paginated } from '../pagination/paginated'; + +/** + * Wraps a handler's `[items, total]` result (the shape of + * `prisma.$transaction([findMany, count])`) into a {@link Paginated} + * body, reading `page` / `limit` from the request query with the same + * defaults as {@link PaginationQueryDto}. The handler's own + * `@Query() PaginationQueryDto` has already been validated by the global + * ValidationPipe, so the values here are known to be in range. + */ +@Injectable() +export class PaginatedResponseInterceptor implements NestInterceptor< + [T[], number], + Paginated +> { + intercept( + context: ExecutionContext, + next: CallHandler<[T[], number]>, + ): Observable> { + const request = context.switchToHttp().getRequest(); + const query = plainToInstance(PaginationQueryDto, request.query ?? {}); + + return next.handle().pipe( + map((result) => { + if ( + !Array.isArray(result) || + result.length !== 2 || + !Array.isArray(result[0]) || + typeof result[1] !== 'number' + ) { + throw new Error( + 'PaginatedResponseInterceptor expects the handler to return [items, total]', + ); + } + const [items, total] = result; + return paginate(items, total, query); + }), + ); + } +} diff --git a/src/common/pagination/paginated.spec.ts b/src/common/pagination/paginated.spec.ts new file mode 100644 index 0000000..13ec5a9 --- /dev/null +++ b/src/common/pagination/paginated.spec.ts @@ -0,0 +1,28 @@ +import { paginate, toSkipTake } from './paginated'; + +describe('toSkipTake', () => { + it('maps a 1-based page to an offset', () => { + expect(toSkipTake({ page: 1, limit: 20 })).toEqual({ skip: 0, take: 20 }); + expect(toSkipTake({ page: 3, limit: 10 })).toEqual({ skip: 20, take: 10 }); + }); +}); + +describe('paginate', () => { + it('returns items, total, page and limit', () => { + expect(paginate(['a', 'b'], 12, { page: 2, limit: 2 })).toEqual({ + items: ['a', 'b'], + total: 12, + page: 2, + limit: 2, + }); + }); + + it('keeps an empty page past the end', () => { + expect(paginate([], 3, { page: 5, limit: 20 })).toEqual({ + items: [], + total: 3, + page: 5, + limit: 20, + }); + }); +}); diff --git a/src/common/pagination/paginated.ts b/src/common/pagination/paginated.ts new file mode 100644 index 0000000..45bbcc8 --- /dev/null +++ b/src/common/pagination/paginated.ts @@ -0,0 +1,30 @@ +import type { PaginationQueryDto } from '../dto/pagination-query.dto'; + +/** Response body shared by every offset-paginated listing endpoint. */ +export interface Paginated { + items: T[]; + /** Total matching rows across all pages. */ + total: number; + /** 1-based page number this response holds. */ + page: number; + /** Page size the response was computed with. */ + limit: number; +} + +type PageParams = Pick; + +/** Prisma `skip` / `take` for the requested page. */ +export function toSkipTake({ page, limit }: PageParams): { + skip: number; + take: number; +} { + return { skip: (page - 1) * limit, take: limit }; +} + +export function paginate( + items: T[], + total: number, + { page, limit }: PageParams, +): Paginated { + return { items, total, page, limit }; +} diff --git a/src/events/events.controller.ts b/src/events/events.controller.ts index c753bb8..8a65235 100644 --- a/src/events/events.controller.ts +++ b/src/events/events.controller.ts @@ -1,4 +1,13 @@ -import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Param, + Post, + Query, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { CurrentUserPayload } from '../auth/decorators/current-user.decorator'; @@ -6,14 +15,17 @@ import { EventsService } from './events.service'; import { CreateEventDto } from './dto/create-event.dto'; import { CreateTicketTypeDto } from './dto/create-ticket-type.dto'; import { ConfirmPublishDto } from './dto/confirm-publish.dto'; +import { PaginationQueryDto } from '../common/dto/pagination-query.dto'; +import { PaginatedResponseInterceptor } from '../common/interceptors/paginated-response.interceptor'; @Controller() export class EventsController { constructor(private readonly eventsService: EventsService) {} @Get('events') - findPublished() { - return this.eventsService.findPublished(); + @UseInterceptors(PaginatedResponseInterceptor) + findPublished(@Query() query: PaginationQueryDto) { + return this.eventsService.findPublished(query); } @Get('events/:eventId') diff --git a/src/events/events.service.spec.ts b/src/events/events.service.spec.ts index 96090cf..9b39af9 100644 --- a/src/events/events.service.spec.ts +++ b/src/events/events.service.spec.ts @@ -12,11 +12,13 @@ import type { StellarService } from '../stellar/stellar.service'; describe('EventsService', () => { let service: EventsService; let prisma: { + $transaction: jest.Mock; event: { create: jest.Mock; update: jest.Mock; findUnique: jest.Mock; findMany: jest.Mock; + count: jest.Mock; }; }; let organizations: { assertMember: jest.Mock }; @@ -27,11 +29,15 @@ describe('EventsService', () => { beforeEach(() => { prisma = { + $transaction: jest.fn((operations: Promise[]) => + Promise.all(operations), + ), event: { create: jest.fn(), update: jest.fn(), findUnique: jest.fn(), findMany: jest.fn(), + count: jest.fn(), }, }; organizations = { assertMember: jest.fn().mockResolvedValue(undefined) }; @@ -158,6 +164,41 @@ describe('EventsService', () => { }); }); + describe('findPublished', () => { + it('returns one page of published events and the total count', async () => { + prisma.event.findMany.mockResolvedValue([{ id: 'event-21' }]); + prisma.event.count.mockResolvedValue(21); + + const result = await service.findPublished({ page: 2, limit: 20 }); + + expect(result).toEqual([[{ id: 'event-21' }], 21]); + expect(prisma.event.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { status: 'PUBLISHED' }, + orderBy: [{ startsAt: 'asc' }, { id: 'asc' }], + skip: 20, + take: 20, + }), + ); + expect(prisma.event.count).toHaveBeenCalledWith({ + where: { status: 'PUBLISHED' }, + }); + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + }); + + it('still hides hidden ticket types', async () => { + prisma.event.findMany.mockResolvedValue([]); + prisma.event.count.mockResolvedValue(0); + + await service.findPublished({ page: 1, limit: 20 }); + + const [args] = prisma.event.findMany.mock.calls[0] as [ + { include: { ticketTypes: unknown } }, + ]; + expect(args.include.ticketTypes).toEqual({ where: { isHidden: false } }); + }); + }); + describe('findForOrganization', () => { it('requires membership before listing an organization’s events', async () => { organizations.assertMember.mockRejectedValue(new Error('not a member')); diff --git a/src/events/events.service.ts b/src/events/events.service.ts index 3f171d1..12aa9c0 100644 --- a/src/events/events.service.ts +++ b/src/events/events.service.ts @@ -8,6 +8,8 @@ import { EventStatus, Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { OrganizationsService } from '../organizations/organizations.service'; import { StellarService } from '../stellar/stellar.service'; +import { PaginationQueryDto } from '../common/dto/pagination-query.dto'; +import { toSkipTake } from '../common/pagination/paginated'; import { CreateEventDto } from './dto/create-event.dto'; import { CreateTicketTypeDto } from './dto/create-ticket-type.dto'; @@ -116,15 +118,23 @@ export class EventsService { return event; } - findPublished() { - return this.prisma.event.findMany({ - where: { status: EventStatus.PUBLISHED }, - include: { - ticketTypes: { where: { isHidden: false } }, - organization: { select: { name: true, slug: true } }, - }, - orderBy: { startsAt: 'asc' }, - }); + /** One page of published events plus the total count, for `GET /events`. */ + findPublished(query: PaginationQueryDto) { + const where = { status: EventStatus.PUBLISHED }; + return this.prisma.$transaction([ + this.prisma.event.findMany({ + where, + include: { + ticketTypes: { where: { isHidden: false } }, + organization: { select: { name: true, slug: true } }, + }, + // `id` breaks ties between events starting at the same time so + // rows can't shift between pages. + orderBy: [{ startsAt: 'asc' }, { id: 'asc' }], + ...toSkipTake(query), + }), + this.prisma.event.count({ where }), + ]); } async findForOrganization(userId: string, organizationId: string) { From 42f3c011bf098bc5fc2236b4d389bd230473d8ff Mon Sep 17 00:00:00 2001 From: devgrace100 <307987547+devgrace100@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:32:56 +0100 Subject: [PATCH 2/2] feat: weak ETags for GET responses and TRUST_PROXY setting Move HTTP setup from main.ts into configureApp() (src/app.setup.ts) so specs exercise the same middleware and Express settings as production. - Enable weak ETags explicitly (app.set('etag', 'weak')). A matching If-None-Match gets 304 Not Modified; the spec asserts the 304 and a fresh ETag once the listing changes. Documented in docs/API.md. - Add an optional TRUST_PROXY env var, applied as Express 'trust proxy' so req.ip (which the scan rate limiter keys on) is the real client IP behind a load balancer. Accepts true/false, a hop count, or a list of IPs, CIDR ranges and presets; invalid values fail env validation at boot. Documented in docs/DEPLOYMENT.md, .env.example and docs/RATE_LIMITING.md. Closes #248 Closes #249 --- .env.example | 5 + CHANGELOG.md | 2 + docs/API.md | 23 ++++ docs/DEPLOYMENT.md | 29 +++++ docs/RATE_LIMITING.md | 5 + src/app.setup.spec.ts | 186 ++++++++++++++++++++++++++++++ src/app.setup.ts | 34 ++++++ src/config/env.validation.spec.ts | 13 +++ src/config/env.validation.ts | 16 +++ src/config/trust-proxy.spec.ts | 42 +++++++ src/config/trust-proxy.ts | 49 ++++++++ src/main.ts | 19 +-- 12 files changed, 408 insertions(+), 15 deletions(-) create mode 100644 src/app.setup.spec.ts create mode 100644 src/app.setup.ts create mode 100644 src/config/trust-proxy.spec.ts create mode 100644 src/config/trust-proxy.ts diff --git a/.env.example b/.env.example index 29bdb74..7f016c1 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,11 @@ NODE_ENV=development PORT=3000 APP_URL=http://localhost:3001 +# Express `trust proxy`: unset/false (direct connections), a hop count such as +# 1 behind a single load balancer, or a comma-separated list of proxy IPs/CIDR +# ranges. See docs/DEPLOYMENT.md. +TRUST_PROXY= + # postgresql://user:password@host:5432/db DATABASE_URL= diff --git a/CHANGELOG.md b/CHANGELOG.md index 8db6190..0701b1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ This project follows [Keep a Changelog](https://keepachangelog.com/). - Shared `PaginationQueryDto` (`?page=&limit=`, default 20, max 100) and `Paginated` response body (`items`, `total`, `page`, `limit`) with a `PaginatedResponseInterceptor`; see docs/API.md +- Weak ETags on GET responses; a matching `If-None-Match` returns `304` +- `TRUST_PROXY` env var for Express `trust proxy`; see docs/DEPLOYMENT.md ### Changed - `GET /events` is paginated and returns `{ items, total, page, limit }` diff --git a/docs/API.md b/docs/API.md index 92ed0af..c2a219a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -47,3 +47,26 @@ have the service return `prisma.$transaction([findMany({ ...toSkipTake(query) }) and add `@UseInterceptors(PaginatedResponseInterceptor)` to the handler. The interceptor turns the `[items, total]` result into a `Paginated` body. Handlers can also build the body directly with `paginate(items, total, query)`. + +## Conditional GET (ETags) + +Every `GET` response carries a weak `ETag` (`W/"..."`), computed by +Express from the response body (`app.set('etag', 'weak')` in +`src/app.setup.ts`). A client that re-sends it as `If-None-Match` gets +`304 Not Modified` with an empty body while the response is unchanged, +so polling `GET /events` does not re-download an identical list: + +```http +GET /events +→ 200 ETag: W/"1a2-Lx0..." + +GET /events +If-None-Match: W/"1a2-Lx0..." +→ 304 (no body) +``` + +The ETag is a hash of the serialized response, so it changes whenever +any event on the page, or the page's `total`, changes. The server still +runs the query to compute it: the saving is bandwidth and client-side +parsing, not database work. + diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 20a0c38..c1c6856 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -8,3 +8,32 @@ 6. Set `STELLAR_NETWORK=mainnet` and a production `SOROBAN_RPC_URL` 7. Rotate `JWT_SECRET` and `PLATFORM_SIGNER_SECRET` out of any shared `.env` file into a real secrets manager before going live +8. Set `TRUST_PROXY` to match the load balancer / reverse proxy in front + of the app (see below) + +## Running behind a proxy (`TRUST_PROXY`) + +Behind a load balancer or reverse proxy, every request reaches the app +from the proxy's address. Unless Express is told to trust that proxy, +`req.ip` is the proxy's IP, so the per-IP scan rate limit +([`docs/RATE_LIMITING.md`](RATE_LIMITING.md)) puts every client in one +bucket and logs show one address. + +`TRUST_PROXY` sets Express's +[`trust proxy`](https://expressjs.com/en/guide/behind-proxies.html) +setting, which decides how much of `X-Forwarded-For` to believe when +resolving `req.ip`: + +| Value | Meaning | +|---|---| +| unset, empty or `false` (default) | Ignore `X-Forwarded-For`. Correct when clients connect directly. | +| `1`, `2`, ... | Trust that many proxy hops in front of the app. `1` is right for a single load balancer (e.g. Render, Fly.io, an nginx in front of the container). | +| `10.0.0.0/8,loopback` | Trust only these proxy addresses: IPs, CIDR ranges (or `ip/netmask`) and the presets `loopback`, `linklocal`, `uniquelocal`, comma-separated. The strictest option when proxy IPs are known. | +| `true` | Trust every hop. Only safe if the app cannot be reached except through the proxy. Otherwise any client can pick its own IP by sending `X-Forwarded-For`. | + +An invalid value (e.g. `on`, `10.0.0.0/33`) fails env validation at boot. + +Set the hop count or address list to exactly what sits in front of the +app. Trusting more hops than exist lets clients spoof the IP the rate +limiter keys on. + diff --git a/docs/RATE_LIMITING.md b/docs/RATE_LIMITING.md index e1c27a3..c265e8f 100644 --- a/docs/RATE_LIMITING.md +++ b/docs/RATE_LIMITING.md @@ -15,6 +15,11 @@ limit keyed independently by: Either axis tripping the limit rejects the request with `429 Too Many Requests`; a request only has to fail one check to be treated as abuse. +The IP comes from `req.ip`. Behind a load balancer or reverse proxy, +set `TRUST_PROXY` (see [`docs/DEPLOYMENT.md`](DEPLOYMENT.md#running-behind-a-proxy-trust_proxy)). +Without it, every client shares the proxy's IP and one busy gate can +rate-limit all the others. + Configurable via environment variables, both optional with defaults: - `SCAN_RATE_LIMIT_MAX` (default `10`) — max attempts per window, per key. diff --git a/src/app.setup.spec.ts b/src/app.setup.spec.ts new file mode 100644 index 0000000..df57546 --- /dev/null +++ b/src/app.setup.spec.ts @@ -0,0 +1,186 @@ +import 'reflect-metadata'; +import { Controller, Get, Req } from '@nestjs/common'; +import type { ConfigService } from '@nestjs/config'; +import type { NestExpressApplication } from '@nestjs/platform-express'; +import { Test } from '@nestjs/testing'; +import type { Request } from 'express'; +import request from 'supertest'; + +// See tickets.service.spec.ts for why StellarService is mocked at the +// module level rather than imported for real. +jest.mock('./stellar/stellar.service', () => ({ StellarService: jest.fn() })); + +import { configureApp } from './app.setup'; +import { EventsController } from './events/events.controller'; +import { EventsService } from './events/events.service'; + +@Controller('test') +class IpEchoController { + @Get('ip') + ip(@Req() req: Request) { + return { ip: req.ip }; + } +} + +function ipOf(res: request.Response): string | undefined { + return (res.body as { ip?: string }).ip; +} + +function configWith(values: Record): ConfigService { + return { + get: (key: string) => values[key], + getOrThrow: (key: string) => { + if (values[key] === undefined) throw new Error(`missing ${key}`); + return values[key]; + }, + } as unknown as ConfigService; +} + +async function createApp( + env: Record = {}, +): Promise<{ app: NestExpressApplication; findPublished: jest.Mock }> { + const findPublished = jest.fn(); + const moduleRef = await Test.createTestingModule({ + controllers: [EventsController, IpEchoController], + providers: [{ provide: EventsService, useValue: { findPublished } }], + }).compile(); + + const app = moduleRef.createNestApplication(); + configureApp(app, configWith({ APP_URL: 'http://localhost:3001', ...env })); + await app.init(); + return { app, findPublished }; +} + +describe('configureApp', () => { + let app: NestExpressApplication; + + afterEach(async () => { + await app?.close(); + }); + + describe('GET /events pagination', () => { + it('returns a Paginated body built from the requested page', async () => { + const created = await createApp(); + app = created.app; + created.findPublished.mockResolvedValue([[{ id: 'event-3' }], 3]); + + const res = await request(app.getHttpServer()) + .get('/events?page=3&limit=1') + .expect(200); + + expect(created.findPublished).toHaveBeenCalledWith( + expect.objectContaining({ page: 3, limit: 1 }), + ); + expect(res.body).toEqual({ + items: [{ id: 'event-3' }], + total: 3, + page: 3, + limit: 1, + }); + }); + + it('defaults to page 1 of 20', async () => { + const created = await createApp(); + app = created.app; + created.findPublished.mockResolvedValue([[], 0]); + + const res = await request(app.getHttpServer()).get('/events').expect(200); + + expect(res.body).toEqual({ items: [], total: 0, page: 1, limit: 20 }); + }); + + it('rejects a limit above the maximum with 400', async () => { + const created = await createApp(); + app = created.app; + + await request(app.getHttpServer()).get('/events?limit=101').expect(400); + expect(created.findPublished).not.toHaveBeenCalled(); + }); + }); + + describe('ETags', () => { + it('sends a weak ETag and answers a matching If-None-Match with 304', async () => { + const created = await createApp(); + app = created.app; + created.findPublished.mockResolvedValue([[{ id: 'event-1' }], 1]); + + const first = await request(app.getHttpServer()) + .get('/events') + .expect(200); + const etag = first.headers['etag']; + expect(etag).toMatch(/^W\/".+"$/); + + const second = await request(app.getHttpServer()) + .get('/events') + .set('If-None-Match', etag) + .expect(304); + expect(second.text).toBe(''); + }); + + it('returns 200 with a new ETag once the listing changes', async () => { + const created = await createApp(); + app = created.app; + created.findPublished.mockResolvedValue([[{ id: 'event-1' }], 1]); + + const first = await request(app.getHttpServer()).get('/events'); + + created.findPublished.mockResolvedValue([ + [{ id: 'event-1' }, { id: 'event-2' }], + 2, + ]); + const second = await request(app.getHttpServer()) + .get('/events') + .set('If-None-Match', first.headers['etag']) + .expect(200); + + expect(second.headers['etag']).toMatch(/^W\//); + expect(second.headers['etag']).not.toBe(first.headers['etag']); + expect((second.body as { total: number }).total).toBe(2); + }); + }); + + describe('trust proxy', () => { + it('ignores X-Forwarded-For by default', async () => { + ({ app } = await createApp()); + + const res = await request(app.getHttpServer()) + .get('/test/ip') + .set('X-Forwarded-For', '203.0.113.7'); + + expect(ipOf(res)).not.toBe('203.0.113.7'); + }); + + it('uses the client IP from X-Forwarded-For when TRUST_PROXY trusts the hop', async () => { + ({ app } = await createApp({ TRUST_PROXY: '1' })); + + const res = await request(app.getHttpServer()) + .get('/test/ip') + .set('X-Forwarded-For', '203.0.113.7'); + + expect(ipOf(res)).toBe('203.0.113.7'); + }); + + it('only trusts the listed proxy addresses', async () => { + ({ app } = await createApp({ TRUST_PROXY: '10.0.0.0/8' })); + + const res = await request(app.getHttpServer()) + .get('/test/ip') + .set('X-Forwarded-For', '203.0.113.7'); + + // supertest connects over loopback, which is not in 10.0.0.0/8. + expect(ipOf(res)).not.toBe('203.0.113.7'); + }); + + it('applies the parsed setting to Express', async () => { + ({ app } = await createApp({ TRUST_PROXY: 'loopback' })); + expect(app.getHttpAdapter().getInstance().get('trust proxy')).toBe( + 'loopback', + ); + + const res = await request(app.getHttpServer()) + .get('/test/ip') + .set('X-Forwarded-For', '203.0.113.7'); + expect(ipOf(res)).toBe('203.0.113.7'); + }); + }); +}); diff --git a/src/app.setup.ts b/src/app.setup.ts new file mode 100644 index 0000000..ddccf3f --- /dev/null +++ b/src/app.setup.ts @@ -0,0 +1,34 @@ +import { ValidationPipe } from '@nestjs/common'; +import type { ConfigService } from '@nestjs/config'; +import type { NestExpressApplication } from '@nestjs/platform-express'; +import helmet from 'helmet'; +import { parseTrustProxy } from './config/trust-proxy'; + +/** + * HTTP-level app configuration, shared by `main.ts` and the HTTP specs so + * tests exercise the same middleware and Express settings as production. + */ +export function configureApp( + app: NestExpressApplication, + config: ConfigService, +): void { + // Resolve `req.ip` from X-Forwarded-For only for proxies we trust — + // see docs/DEPLOYMENT.md. + app.set('trust proxy', parseTrustProxy(config.get('TRUST_PROXY'))); + // Weak ETags on GET responses; Express answers a matching + // If-None-Match with 304 Not Modified. See docs/API.md. + app.set('etag', 'weak'); + + app.use(helmet()); + app.enableCors({ + origin: config.getOrThrow('APP_URL'), + credentials: true, + }); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); +} diff --git a/src/config/env.validation.spec.ts b/src/config/env.validation.spec.ts index 01ab646..d1fe1ac 100644 --- a/src/config/env.validation.spec.ts +++ b/src/config/env.validation.spec.ts @@ -40,6 +40,19 @@ describe('env.validate', () => { ).toThrow(); }); + it('accepts a valid TRUST_PROXY and treats it as optional', () => { + expect(() => validate(validConfig({ TRUST_PROXY: '1' }))).not.toThrow(); + expect(() => + validate(validConfig({ TRUST_PROXY: 'loopback,10.0.0.0/8' })), + ).not.toThrow(); + }); + + it('rejects a malformed TRUST_PROXY at boot', () => { + expect(() => validate(validConfig({ TRUST_PROXY: 'on' }))).toThrow( + /Invalid environment configuration: Invalid TRUST_PROXY entry "on"/, + ); + }); + it('rejects a missing required field', () => { const config = validConfig(); delete (config as Record).DATABASE_URL; diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index 60e5ba0..25af1b0 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -7,6 +7,7 @@ import { MinLength, validateSync, } from 'class-validator'; +import { parseTrustProxy } from './trust-proxy'; class EnvironmentVariables { @IsIn(['development', 'test', 'production']) @@ -29,6 +30,13 @@ class EnvironmentVariables { @IsString() APP_URL: string; + /// Express `trust proxy` setting: `true`, `false`, a hop count, or a + /// comma-separated list of proxy IPs / CIDR ranges. Unset means `false`. + /// See docs/DEPLOYMENT.md. + @IsString() + @IsOptional() + TRUST_PROXY?: string; + /// Soroban RPC endpoint the StellarService submits contract calls through. @IsString() SOROBAN_RPC_URL: string; @@ -72,5 +80,13 @@ export function validate(config: Record) { throw new Error(`Invalid environment configuration: ${errors.toString()}`); } + try { + parseTrustProxy(validated.TRUST_PROXY); + } catch (error) { + throw new Error( + `Invalid environment configuration: ${(error as Error).message}`, + ); + } + return validated; } diff --git a/src/config/trust-proxy.spec.ts b/src/config/trust-proxy.spec.ts new file mode 100644 index 0000000..c2c3b31 --- /dev/null +++ b/src/config/trust-proxy.spec.ts @@ -0,0 +1,42 @@ +import { parseTrustProxy } from './trust-proxy'; + +describe('parseTrustProxy', () => { + it.each([undefined, '', ' ', 'false', 'FALSE'])( + 'treats %p as not trusting any proxy', + (value) => { + expect(parseTrustProxy(value)).toBe(false); + }, + ); + + it('trusts every hop for "true"', () => { + expect(parseTrustProxy('true')).toBe(true); + }); + + it('parses a hop count', () => { + expect(parseTrustProxy('1')).toBe(1); + expect(parseTrustProxy('2')).toBe(2); + }); + + it('accepts IPs, CIDR ranges, netmasks and presets', () => { + expect( + parseTrustProxy( + 'loopback, 10.0.0.0/8, 192.168.1.10, fd00::/8, 172.16.0.0/255.240.0.0, uniquelocal', + ), + ).toBe( + 'loopback,10.0.0.0/8,192.168.1.10,fd00::/8,172.16.0.0/255.240.0.0,uniquelocal', + ); + }); + + it.each([ + 'yes', + '10.0.0.0/33', + 'fd00::/129', + '10.0.0.0/', + '10.0.0.0/8/1', + '300.1.1.1', + 'loopback,', + '10.0.0.0/ffff::', + ])('rejects %p', (value) => { + expect(() => parseTrustProxy(value)).toThrow(/Invalid TRUST_PROXY entry/); + }); +}); diff --git a/src/config/trust-proxy.ts b/src/config/trust-proxy.ts new file mode 100644 index 0000000..216716a --- /dev/null +++ b/src/config/trust-proxy.ts @@ -0,0 +1,49 @@ +import { isIP } from 'node:net'; + +/** Value accepted by Express's `app.set('trust proxy', ...)`. */ +export type TrustProxySetting = boolean | number | string; + +const PRESETS = ['loopback', 'linklocal', 'uniquelocal']; + +function isValidEntry(entry: string): boolean { + if (PRESETS.includes(entry)) return true; + if (isIP(entry)) return true; + + const [address, range, ...rest] = entry.split('/'); + if (rest.length > 0 || !address || !range) return false; + const family = isIP(address); + if (!family) return false; + // `ip/netmask` form, e.g. 10.0.0.0/255.0.0.0 + if (isIP(range) === family) return true; + if (!/^\d+$/.test(range)) return false; + return Number(range) <= (family === 4 ? 32 : 128); +} + +/** + * Parse `TRUST_PROXY` into Express's `trust proxy` setting: + * + * - unset / empty / `false` → `false` (default: ignore `X-Forwarded-*`) + * - `true` → trust every hop (only safe when the app is unreachable except + * through the proxy — any client can otherwise spoof its IP) + * - an integer `n` → trust the `n` nearest hops + * - a comma-separated list of IPs, CIDR ranges and the presets + * `loopback`, `linklocal`, `uniquelocal` + * + * Throws on anything else so a typo fails at boot instead of silently + * leaving every request keyed to the proxy's IP. + */ +export function parseTrustProxy(raw: string | undefined): TrustProxySetting { + const value = (raw ?? '').trim(); + if (value === '' || value.toLowerCase() === 'false') return false; + if (value.toLowerCase() === 'true') return true; + if (/^\d+$/.test(value)) return Number(value); + + const entries = value.split(',').map((entry) => entry.trim()); + const invalid = entries.find((entry) => !isValidEntry(entry)); + if (invalid !== undefined) { + throw new Error( + `Invalid TRUST_PROXY entry "${invalid}": expected true, false, a hop count, or a comma-separated list of IPs, CIDR ranges, loopback, linklocal, uniquelocal`, + ); + } + return entries.join(','); +} diff --git a/src/main.ts b/src/main.ts index 1f9a01c..10a016d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,25 +1,14 @@ import { NestFactory } from '@nestjs/core'; -import { ValidationPipe } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import helmet from 'helmet'; +import type { NestExpressApplication } from '@nestjs/platform-express'; import { AppModule } from './app.module'; +import { configureApp } from './app.setup'; async function bootstrap() { - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule); const config = app.get(ConfigService); - app.use(helmet()); - app.enableCors({ - origin: config.getOrThrow('APP_URL'), - credentials: true, - }); - app.useGlobalPipes( - new ValidationPipe({ - whitelist: true, - forbidNonWhitelisted: true, - transform: true, - }), - ); + configureApp(app, config); await app.listen(config.getOrThrow('PORT')); }