diff --git a/.env.example b/.env.example index e9495e2..8d04ed2 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,11 @@ OTEL_TRACING_ENABLED=false # OTEL_SERVICE_NAME=stellar-tickets-backend # OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://localhost:4318/v1/traces +# 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 758b385..4fc3dd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,17 @@ 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 +- Weak ETags on GET responses; a matching `If-None-Match` returns `304` +- `TRUST_PROXY` env var for Express `trust proxy`; see docs/DEPLOYMENT.md - `GET /organizations/:id/events` is paginated (`?page=`, `?limit=`) and returns `{ items, total, page, limit }`; shared `PaginationQueryDto` - Cache abstraction with memory and Redis drivers (`CACHE_DRIVER`) - Optional BullMQ queue for outbound webhooks (`WEBHOOK_QUEUE_ENABLED`, off by default) - Scheduler module with a sample cron job (`SCHEDULER_ENABLED`) + +### 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 fdce4b0..b44a620 100644 --- a/docs/API.md +++ b/docs/API.md @@ -17,6 +17,59 @@ API=http://localhost:3000 TOKEN= ``` +## 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`). `page` is also capped at 100000: + +```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 /v1/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 `PaginatedResponseInterceptor` to the handler's `@UseInterceptors(...)`. 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 /v1/events` does not re-download an identical list: + +```http +GET /v1/events +→ 200 ETag: W/"1a2-Lx0..." + +GET /v1/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. + ## Conventions **Versioning.** All routes live under `/v1` (URI versioning, default @@ -378,17 +431,19 @@ A ticket type row: ### `GET /v1/events` -Public marketplace listing: `PUBLISHED` events of live organizations, +Public marketplace listing: one page (`?page=&limit=`, see +[Pagination](#pagination)) of `PUBLISHED` events of live organizations, soonest first, each with its visible ticket types and the organizer's name and slug. Served with `Cache-Control: public, max-age=60, s-maxage=300` and an application cache (`CACHE_TTL_SECONDS`). ```bash -curl -s "$API/v1/events" +curl -s "$API/v1/events?page=1&limit=20" ``` ```json -[ +{ + "items": [ { "id": "7a1f3c5e-9b2d-4e6f-8a0c-1d2e3f4a5b6c", "name": "Launch Night", @@ -398,7 +453,11 @@ curl -s "$API/v1/events" "ticketTypes": [{ "id": "4b8d2f6a-0c1e-4a3b-9d5f-6e7a8b9c0d1e", "name": "General Admission", "price": "2500000", "quantityTotal": 500, "quantityIssued": 12, "isHidden": false }], "organization": { "name": "Fillmore Live", "slug": "fillmore-live" } } -] + ], + "total": 1, + "page": 1, + "limit": 20 +} ``` (Other event columns omitted here for brevity; the response carries the diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 77a2bda..d1e2e93 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -173,3 +173,31 @@ Content-Type: application/json; charset=utf-8 - [x] Soroban RPC endpoint is reachable by the service. - [x] Outbound webhooks (if enabled) connect to Redis cleanly. - [x] Logs output structured Nest logs without uncaught exception errors. + +--- + +## 6. 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 32e9725..69faec7 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#6-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, all 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..17a1231 --- /dev/null +++ b/src/app.setup.spec.ts @@ -0,0 +1,212 @@ +import 'reflect-metadata'; +import { Controller, Get, Req } from '@nestjs/common'; +import { 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 { CACHE_STORE } from './common/cache/cache-store'; +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 } }, + // The listing's response cache always misses, so every request reaches + // the mocked service. + { + provide: CACHE_STORE, + useValue: { + get: jest.fn().mockResolvedValue(undefined), + set: jest.fn().mockResolvedValue(undefined), + delete: jest.fn().mockResolvedValue(undefined), + }, + }, + { + provide: ConfigService, + useValue: { + get: jest.fn((_key: string, fallback: unknown) => fallback), + }, + }, + ], + }).compile(); + + const app = moduleRef.createNestApplication(); + configureApp( + app, + configWith({ CORS_ORIGINS: '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('/v1/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('/v1/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('/v1/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('/v1/events') + .expect(200); + const etag = first.headers['etag']; + expect(etag).toMatch(/^W\/".+"$/); + + const second = await request(app.getHttpServer()) + .get('/v1/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('/v1/events'); + + created.findPublished.mockResolvedValue([ + [{ id: 'event-1' }, { id: 'event-2' }], + 2, + ]); + const second = await request(app.getHttpServer()) + .get('/v1/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('/v1/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('/v1/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('/v1/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('/v1/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..c63b412 --- /dev/null +++ b/src/app.setup.ts @@ -0,0 +1,61 @@ +import { ValidationPipe, VersioningType } from '@nestjs/common'; +import type { ConfigService } from '@nestjs/config'; +import type { NestExpressApplication } from '@nestjs/platform-express'; +import { json } from 'express'; +import helmet from 'helmet'; +import { applyApiPrefix } from './config/api-prefix'; +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'); + + applyApiPrefix(app, config.get('API_PREFIX')); + + const cspDirectives = config.get('CSP_DIRECTIVES'); + const helmetOptions: Record = {}; + if (cspDirectives) { + try { + helmetOptions.contentSecurityPolicy = { + directives: JSON.parse(cspDirectives) as Record, + }; + } catch { + helmetOptions.contentSecurityPolicy = true; + } + } + app.use(helmet(helmetOptions)); + + app.use(json({ limit: config.get('JSON_BODY_LIMIT', '100kb') })); + + app.enableCors({ + origin: config + .getOrThrow('CORS_ORIGINS') + .split(',') + .map((o) => o.trim()), + credentials: true, + }); + + app.enableVersioning({ + type: VersioningType.URI, + defaultVersion: '1', + }); + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); +} 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..82d019d --- /dev/null +++ b/src/common/pagination/paginated.spec.ts @@ -0,0 +1,29 @@ +import 'reflect-metadata'; +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..40118a5 --- /dev/null +++ b/src/common/pagination/paginated.ts @@ -0,0 +1,36 @@ +import { + DEFAULT_PAGE_LIMIT, + 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 = 1, + limit = DEFAULT_PAGE_LIMIT, +}: PageParams): { + skip: number; + take: number; +} { + return { skip: (page - 1) * limit, take: limit }; +} + +export function paginate( + items: T[], + total: number, + { page = 1, limit = DEFAULT_PAGE_LIMIT }: PageParams, +): Paginated { + return { items, total, page, limit }; +} diff --git a/src/config/env.validation.spec.ts b/src/config/env.validation.spec.ts index f0b0ba0..818ab33 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('defaults to the in-memory rate-limit store without a REDIS_URL', () => { expect(() => validate(validConfig({ RATE_LIMIT_STORE: 'memory' })), diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index 53b6a56..d110584 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -13,6 +13,7 @@ import { API_PREFIX_PATTERN } from './api-prefix'; import { getJwtSecretProblems, JWT_SECRET_MIN_LENGTH } from './jwt-secret'; import { SECRET_PROVIDER_KINDS } from './secrets/secret-provider'; import { getRpcNetworkProblems, STELLAR_NETWORKS } from './stellar-networks'; +import { parseTrustProxy } from './trust-proxy'; class EnvironmentVariables { /// Which NestJS environment the app runs in. Drives logging verbosity and @@ -151,6 +152,13 @@ class EnvironmentVariables { @IsOptional() CSP_DIRECTIVES?: 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; @@ -243,5 +251,13 @@ export function validate(config: Record) { throw new Error('CORS_ORIGINS wildcard (*) is not allowed in production'); } + 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/events/events.controller.spec.ts b/src/events/events.controller.spec.ts index 754e56c..705a24b 100644 --- a/src/events/events.controller.spec.ts +++ b/src/events/events.controller.spec.ts @@ -59,9 +59,14 @@ describe('EventsController', () => { describe('public routes (no auth)', () => { it('GET /events lists published events', async () => { - service.findPublished.mockResolvedValue([{ id: 'e1' }]); + service.findPublished.mockResolvedValue([[{ id: 'e1' }], 1]); const res = await http().get('/events').expect(200); - expect(res.body).toEqual([{ id: 'e1' }]); + expect(res.body).toEqual({ + items: [{ id: 'e1' }], + total: 1, + page: 1, + limit: 20, + }); expect(res.headers['cache-control']).toBe( 'public, max-age=60, s-maxage=300', ); diff --git a/src/events/events.controller.ts b/src/events/events.controller.ts index 0166efd..abd79a5 100644 --- a/src/events/events.controller.ts +++ b/src/events/events.controller.ts @@ -17,6 +17,8 @@ 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'; import { ListOrganizationEventsQueryDto } from './dto/list-organization-events-query.dto'; import { ResponseCacheInterceptor } from '../common/interceptors/response-cache.interceptor'; @@ -26,9 +28,10 @@ export class EventsController { @Get('events') @Header('Cache-Control', 'public, max-age=60, s-maxage=300') - @UseInterceptors(ResponseCacheInterceptor) - findPublished() { - return this.eventsService.findPublished(); + // The cache wraps the paginator so it stores the finished page body. + @UseInterceptors(ResponseCacheInterceptor, 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 33c87b2..6d57306 100644 --- a/src/events/events.service.spec.ts +++ b/src/events/events.service.spec.ts @@ -13,6 +13,7 @@ import { createEvent, createOrganization } from '../../test/factories'; describe('EventsService', () => { let service: EventsService; let prisma: { + $transaction: jest.Mock; event: { create: jest.Mock; update: jest.Mock; @@ -33,6 +34,9 @@ describe('EventsService', () => { beforeEach(() => { prisma = { + $transaction: jest.fn((operations: Promise[]) => + Promise.all(operations), + ), event: { create: jest.fn(), update: jest.fn(), @@ -227,6 +231,47 @@ describe('EventsService', () => { }); }); + describe('findPublished', () => { + const publishedWhere = { + status: 'PUBLISHED', + deletedAt: null, + organization: { deletedAt: null }, + }; + + 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: publishedWhere, + orderBy: [{ startsAt: 'asc' }, { id: 'asc' }], + skip: 20, + take: 20, + }), + ); + expect(prisma.event.count).toHaveBeenCalledWith({ + where: publishedWhere, + }); + 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('unpublish', () => { it('reverts a published event to draft when no tickets exist', async () => { prisma.event.findUnique.mockResolvedValue({ diff --git a/src/events/events.service.ts b/src/events/events.service.ts index b6a212b..7109ba2 100644 --- a/src/events/events.service.ts +++ b/src/events/events.service.ts @@ -11,7 +11,11 @@ import { PrismaService } from '../prisma/prisma.service'; import { OrganizationsService } from '../organizations/organizations.service'; import { StellarService } from '../stellar/stellar.service'; import { AuditService } from '../audit/audit.service'; -import { DEFAULT_PAGE_LIMIT } from '../common/dto/pagination-query.dto'; +import { + DEFAULT_PAGE_LIMIT, + 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'; import { CACHE_STORE } from '../common/cache/cache-store'; @@ -180,20 +184,28 @@ export class EventsService { return event; } - findPublished() { - return this.prisma.event.findMany({ - // #207 — exclude soft-deleted events and events of soft-deleted orgs. - where: { - status: EventStatus.PUBLISHED, - deletedAt: null, - organization: { deletedAt: null }, - }, - 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) { + // #207 — exclude soft-deleted events and events of soft-deleted orgs. + const where = { + status: EventStatus.PUBLISHED, + deletedAt: null, + organization: { deletedAt: null }, + }; + 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 }), + ]); } /** #207 — soft-delete: sets `deletedAt` instead of hard-deleting. */ diff --git a/src/main.ts b/src/main.ts index 323bf35..8bc7794 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,5 @@ import 'dotenv/config'; +import type { NestExpressApplication } from '@nestjs/platform-express'; import { startTracing } from './tracing'; async function bootstrap() { @@ -7,65 +8,23 @@ async function bootstrap() { const [ { NestFactory }, { AppModule }, - { ValidationPipe, VersioningType }, { ConfigService }, - { default: helmet }, { DocumentBuilder, SwaggerModule }, { GlobalExceptionFilter }, - { applyApiPrefix }, + { configureApp }, ] = await Promise.all([ import('@nestjs/core'), import('./app.module.js'), - import('@nestjs/common'), import('@nestjs/config'), - import('helmet'), import('@nestjs/swagger'), import('./common/filters/global-exception.filter.js'), - import('./config/api-prefix.js'), + import('./app.setup.js'), ]); - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule); if (tracing) app.enableShutdownHooks(); const config = app.get(ConfigService); - applyApiPrefix(app, config.get('API_PREFIX')); - const cspDirectives = config.get('CSP_DIRECTIVES'); - const helmetOptions: Record = {}; - if (cspDirectives) { - try { - helmetOptions.contentSecurityPolicy = { - directives: JSON.parse(cspDirectives) as Record, - }; - } catch { - helmetOptions.contentSecurityPolicy = true; - } - } - app.use(helmet(helmetOptions)); - - const bodyLimit = config.get('JSON_BODY_LIMIT', '100kb'); - const express = await import('express'); - app.use(express.json({ limit: bodyLimit })); - - const corsOrigins = config - .getOrThrow('CORS_ORIGINS') - .split(',') - .map((o) => o.trim()); - app.enableCors({ - origin: corsOrigins, - credentials: true, - }); - - app.enableVersioning({ - type: VersioningType.URI, - defaultVersion: '1', - }); - - app.useGlobalPipes( - new ValidationPipe({ - whitelist: true, - forbidNonWhitelisted: true, - transform: true, - }), - ); + configureApp(app, config); app.useGlobalFilters(new GlobalExceptionFilter());