Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=

Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` 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
67 changes: 63 additions & 4 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,59 @@ API=http://localhost:3000
TOKEN=<accessToken from /v1/auth/login>
```

## 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<T>` (`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<T>` 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
Expand Down Expand Up @@ -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",
Expand All @@ -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
Expand Down
28 changes: 28 additions & 0 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
5 changes: 5 additions & 0 deletions docs/RATE_LIMITING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
212 changes: 212 additions & 0 deletions src/app.setup.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>): 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<string, string> = {},
): 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<NestExpressApplication>();
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');
});
});
});
Loading