diff --git a/.env.example b/.env.example index a59b04c..2424ecd 100644 --- a/.env.example +++ b/.env.example @@ -49,6 +49,7 @@ RATE_LIMIT_STORE=memory # (shared across instances). `redis` needs `npm install ioredis` and REDIS_URL. # See docs/CACHING.md. CACHE_DRIVER=memory +# CACHE_TTL_SECONDS=60 # Outbound webhook delivery queue (BullMQ). Disabled by default. Enabling it # needs `npm install bullmq` and REDIS_URL (Redis with maxmemory-policy @@ -75,3 +76,11 @@ IDEMPOTENCY_KEY_TTL_MINUTES=15 # Experimental features. Flags are disabled unless explicitly set to true. FEATURE_FEE_BUMP=false FEATURE_INDEXER=false + +# JSON body size limit (e.g. 100kb, 1mb). Rejects payloads exceeding this with 413. +JSON_BODY_LIMIT=100kb + +# CSP directives as JSON object string for helmet contentSecurityPolicy. +# Leave unset to use helmet defaults. Example: +# CSP_DIRECTIVES={"defaultSrc":["'self'"],"scriptSrc":["'self'"]} +# CSP_DIRECTIVES= diff --git a/Dockerfile b/Dockerfile index 0ec71e2..cbc8e05 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,10 +6,15 @@ COPY . . RUN npx prisma generate && npm run build FROM node:22-alpine +RUN addgroup -S appgroup && adduser -S appuser -G appgroup WORKDIR /app ENV NODE_ENV=production COPY --from=build /app/node_modules ./node_modules COPY --from=build /app/dist ./dist COPY --from=build /app/prisma ./prisma +RUN chown -R appuser:appgroup /app +USER appuser EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD node -e "require('http').get('http://localhost:3000/',(r)=>{process.exit(r.statusCode===200?0:1)}).on('error',()=>process.exit(1))" CMD ["node", "dist/main"] diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 39224d8..783c146 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -59,3 +59,24 @@ or malformed. A handful of variables are read directly by `ConfigService` without going through validation — they are flagged **no** above. A typo in one of those fails *silently* and the feature simply stays on its default, so check the spelling if a flag or limit does not take effect. + +## Security Headers (Helmet) + +The API uses [helmet](https://helmetjs.github.io/) with these defaults: +- `contentSecurityPolicy`: enabled (configurable via `CSP_DIRECTIVES`) +- `crossOriginEmbedderPolicy`: enabled +- `crossOriginOpenerPolicy`: enabled +- `crossOriginResourcePolicy`: enabled +- `dnsPrefetchControl`: enabled +- `frameguard`: enabled (deny) +- `hidePoweredBy`: enabled +- `hsts`: enabled (1 year, includeSubDomains) +- `ieNoOpen`: enabled +- `noSniff`: enabled +- `originAgentCluster`: enabled +- `referrerPolicy`: enabled (no-when-downgrade) +- `xssFilter`: enabled + +Disabled headers (not needed for API-only backend): +- `x-powered-by`: removed by `hidePoweredBy` +- `x-dns-prefetch-control`: controlled by `dnsPrefetchControl` diff --git a/src/common/interceptors/response-cache.interceptor.ts b/src/common/interceptors/response-cache.interceptor.ts new file mode 100644 index 0000000..b143a2e --- /dev/null +++ b/src/common/interceptors/response-cache.interceptor.ts @@ -0,0 +1,56 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Observable, of } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { CACHE_STORE } from '../cache/cache-store'; +import { Inject } from '@nestjs/common'; +import type { Request } from 'express'; + +interface CacheStore { + get(k: string): Promise; + set(k: string, v: unknown, ttl: number): Promise; + delete(k: string): Promise; +} + +@Injectable() +export class ResponseCacheInterceptor implements NestInterceptor { + private readonly defaultTtlMs: number; + + constructor( + @Inject(CACHE_STORE) private readonly cache: CacheStore, + private readonly config: ConfigService, + ) { + this.defaultTtlMs = config.get('CACHE_TTL_SECONDS', 60) * 1000; + } + + async intercept( + context: ExecutionContext, + next: CallHandler, + ): Promise> { + const req = context.switchToHttp().getRequest(); + if (req.method !== 'GET') { + return next.handle(); + } + const key = `cache:${req.url}`; + const cached = await this.cache.get(key); + if (cached !== undefined) { + return of(cached); + } + return next.handle().pipe( + tap({ + next: (data: unknown) => { + void this.cache.set(key, data, this.defaultTtlMs); + }, + }), + ); + } + + invalidate(pattern: string): Promise { + return this.cache.delete(pattern); + } +} diff --git a/src/events/events.controller.ts b/src/events/events.controller.ts index 81c11c5..0166efd 100644 --- a/src/events/events.controller.ts +++ b/src/events/events.controller.ts @@ -8,6 +8,7 @@ import { Post, Query, UseGuards, + UseInterceptors, } from '@nestjs/common'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; @@ -17,6 +18,7 @@ import { CreateEventDto } from './dto/create-event.dto'; import { CreateTicketTypeDto } from './dto/create-ticket-type.dto'; import { ConfirmPublishDto } from './dto/confirm-publish.dto'; import { ListOrganizationEventsQueryDto } from './dto/list-organization-events-query.dto'; +import { ResponseCacheInterceptor } from '../common/interceptors/response-cache.interceptor'; @Controller() export class EventsController { @@ -24,11 +26,13 @@ export class EventsController { @Get('events') @Header('Cache-Control', 'public, max-age=60, s-maxage=300') + @UseInterceptors(ResponseCacheInterceptor) findPublished() { return this.eventsService.findPublished(); } @Get('events/:eventId') + @UseInterceptors(ResponseCacheInterceptor) findOne(@Param('eventId') eventId: string) { return this.eventsService.getWithOrg(eventId); } diff --git a/src/events/events.module.ts b/src/events/events.module.ts index dcb0f23..5f2d94d 100644 --- a/src/events/events.module.ts +++ b/src/events/events.module.ts @@ -2,12 +2,13 @@ import { Module } from '@nestjs/common'; import { OrganizationsModule } from '../organizations/organizations.module'; import { StellarModule } from '../stellar/stellar.module'; import { AuditModule } from '../audit/audit.module'; +import { CacheModule } from '../common/cache/cache.module'; import { EventsController } from './events.controller'; import { EventsService } from './events.service'; import { EventReminderService } from './event-reminder.service'; @Module({ - imports: [OrganizationsModule, StellarModule, AuditModule], + imports: [OrganizationsModule, StellarModule, AuditModule, CacheModule], controllers: [EventsController], providers: [EventsService, EventReminderService], exports: [EventsService, EventReminderService], diff --git a/src/events/events.service.spec.ts b/src/events/events.service.spec.ts index e9f2c7f..33c87b2 100644 --- a/src/events/events.service.spec.ts +++ b/src/events/events.service.spec.ts @@ -29,6 +29,7 @@ describe('EventsService', () => { submitSignedTransaction: jest.Mock; getEvent: jest.Mock; }; + let cache: { delete: jest.Mock }; beforeEach(() => { prisma = { @@ -53,11 +54,14 @@ describe('EventsService', () => { organizer: 'GORG', }), }; + cache = { delete: jest.fn().mockResolvedValue(undefined) }; service = new EventsService( prisma as unknown as PrismaService, organizations as unknown as OrganizationsService, stellar as unknown as StellarService, + undefined, + cache, ); }); @@ -71,7 +75,7 @@ describe('EventsService', () => { chainEventId: null, }), organization: createOrganization({ stellarAccount: 'GORG' }), - } as never); + }); await expect( service.buildPublishTx('organizer-1', 'event-1'), @@ -88,7 +92,7 @@ describe('EventsService', () => { chainEventId: 99n, }), organization: createOrganization({ stellarAccount: 'GORG' }), - } as never); + }); await expect( service.buildPublishTx('organizer-1', 'event-1'), @@ -104,7 +108,7 @@ describe('EventsService', () => { chainEventId: null, }), organization: createOrganization({ stellarAccount: 'GORG' }), - } as never); + }); prisma.ticketType.count.mockResolvedValue(0); const attempt = service.buildPublishTx('organizer-1', 'event-1'); @@ -131,7 +135,7 @@ describe('EventsService', () => { royaltyBps: 500, }), organization: createOrganization({ stellarAccount: 'GORG' }), - } as never); + }); prisma.event.update.mockResolvedValue({}); const { unsignedXdr } = await service.buildPublishTx( @@ -165,7 +169,7 @@ describe('EventsService', () => { chainEventId: null, }), organization: createOrganization({ stellarAccount: 'GORG' }), - } as never); + }); await expect( service.confirmPublish('organizer-1', 'event-1', 'signed-xdr'), @@ -180,7 +184,7 @@ describe('EventsService', () => { chainEventId: 99n, }), organization: createOrganization({ stellarAccount: 'GORG' }), - } as never); + }); stellar.getEvent.mockResolvedValue({ eventId: 100n, organizer: 'GOTHER', @@ -200,7 +204,7 @@ describe('EventsService', () => { chainEventId: 99n, }), organization: createOrganization({ stellarAccount: 'GORG' }), - } as never); + }); prisma.event.update.mockResolvedValue( createEvent({ id: 'event-1', status: 'PUBLISHED' }), ); @@ -232,7 +236,7 @@ describe('EventsService', () => { status: 'PUBLISHED', }), organization: createOrganization({ stellarAccount: 'GORG' }), - } as never); + }); prisma.ticket.count.mockResolvedValue(0); prisma.event.update.mockResolvedValue( createEvent({ id: 'event-1', status: 'DRAFT' }), @@ -312,7 +316,11 @@ describe('EventsService', () => { expect(prisma.event.findMany).toHaveBeenCalledWith( expect.objectContaining({ - where: { organizationId: 'org-1', deletedAt: null, status: 'PUBLISHED' }, + where: { + organizationId: 'org-1', + deletedAt: null, + status: 'PUBLISHED', + }, }), ); }); @@ -367,12 +375,12 @@ describe('EventsService', () => { expect(prisma.event.findMany).toHaveBeenCalledWith( expect.objectContaining({ - where: expect.objectContaining({ deletedAt: null }), + where: expect.objectContaining({ deletedAt: null }) as never, }), ); expect(prisma.event.count).toHaveBeenCalledWith( expect.objectContaining({ - where: expect.objectContaining({ deletedAt: null }), + where: expect.objectContaining({ deletedAt: null }) as never, }), ); }); @@ -384,7 +392,7 @@ describe('EventsService', () => { ...createEvent({ id: 'event-1', organizationId: 'org-1' }), deletedAt: new Date('2026-09-26T00:00:00.000Z'), organization: createOrganization({ stellarAccount: 'GORG' }), - } as never); + }); await expect(service.getWithOrg('event-1')).rejects.toMatchObject({ status: 404, @@ -396,14 +404,14 @@ describe('EventsService', () => { ...createEvent({ id: 'event-1', organizationId: 'org-1' }), deletedAt: null, organization: createOrganization({ stellarAccount: 'GORG' }), - } as never); + }); prisma.event.update.mockResolvedValue({ id: 'event-1' }); await service.softDelete('organizer-1', 'event-1'); expect(prisma.event.update).toHaveBeenCalledWith({ where: { id: 'event-1' }, - data: { deletedAt: expect.any(Date) }, + data: { deletedAt: expect.any(Date) as never }, }); }); @@ -412,7 +420,7 @@ describe('EventsService', () => { ...createEvent({ id: 'event-1', organizationId: 'org-1' }), deletedAt: new Date('2026-09-26T00:00:00.000Z'), organization: createOrganization({ stellarAccount: 'GORG' }), - } as never); + }); prisma.event.update.mockResolvedValue({ id: 'event-1' }); await service.restore('organizer-1', 'event-1'); @@ -423,4 +431,41 @@ describe('EventsService', () => { }); }); }); + + describe('cache invalidation', () => { + it('invalidates cache on create', async () => { + prisma.event.create.mockResolvedValue({ id: 'event-1' }); + await service.create('user-1', 'org-1', { + name: 'Test', + category: 'CONCERTS', + startsAt: new Date(), + } as never); + expect(cache.delete).toHaveBeenCalledWith('cache:/v1/events'); + }); + + it('invalidates cache on confirmPublish', async () => { + prisma.event.findUnique.mockResolvedValue({ + ...createEvent({ + id: 'event-1', + organizationId: 'org-1', + chainEventId: 99n, + }), + organization: createOrganization({ stellarAccount: 'GORG' }), + }); + prisma.event.update.mockResolvedValue({ id: 'event-1' }); + await service.confirmPublish('organizer-1', 'event-1', 'signed-xdr'); + expect(cache.delete).toHaveBeenCalledWith('cache:/v1/events'); + }); + + it('invalidates cache on softDelete', async () => { + prisma.event.findUnique.mockResolvedValue({ + ...createEvent({ id: 'event-1', organizationId: 'org-1' }), + deletedAt: null, + organization: createOrganization({ stellarAccount: 'GORG' }), + }); + prisma.event.update.mockResolvedValue({ id: 'event-1' }); + await service.softDelete('organizer-1', 'event-1'); + expect(cache.delete).toHaveBeenCalledWith('cache:/v1/events'); + }); + }); }); diff --git a/src/events/events.service.ts b/src/events/events.service.ts index d163f6c..b6a212b 100644 --- a/src/events/events.service.ts +++ b/src/events/events.service.ts @@ -14,6 +14,8 @@ import { AuditService } from '../audit/audit.service'; import { DEFAULT_PAGE_LIMIT } from '../common/dto/pagination-query.dto'; import { CreateEventDto } from './dto/create-event.dto'; import { CreateTicketTypeDto } from './dto/create-ticket-type.dto'; +import { CACHE_STORE } from '../common/cache/cache-store'; +import { Inject } from '@nestjs/common'; @Injectable() export class EventsService { @@ -22,6 +24,9 @@ export class EventsService { private readonly organizations: OrganizationsService, private readonly stellar: StellarService, @Optional() private readonly audit?: AuditService, + @Optional() + @Inject(CACHE_STORE) + private readonly cache?: { delete(k: string): Promise }, ) {} async create(userId: string, organizationId: string, dto: CreateEventDto) { @@ -42,6 +47,7 @@ export class EventsService { await this.audit?.record(userId, 'event.create', 'Event', event.id, { organizationId, }); + await this.invalidateCache(); return event; } @@ -137,6 +143,7 @@ export class EventsService { await this.audit?.record(userId, 'event.publish', 'Event', eventId, { txHash, }); + await this.invalidateCache(); return published; } @@ -157,6 +164,7 @@ export class EventsService { data: { status: EventStatus.DRAFT }, }); await this.audit?.record(userId, 'event.unpublish', 'Event', eventId); + await this.invalidateCache(); return unpublished; } @@ -197,6 +205,7 @@ export class EventsService { data: { deletedAt: new Date() }, }); await this.audit?.record(userId, 'event.delete', 'Event', eventId); + await this.invalidateCache(); return deleted; } @@ -215,6 +224,7 @@ export class EventsService { data: { deletedAt: null }, }); await this.audit?.record(userId, 'event.restore', 'Event', eventId); + await this.invalidateCache(); return restored; } @@ -274,4 +284,13 @@ export class EventsService { 'Could not allocate an on-chain event id, please retry', ); } + + private async invalidateCache(): Promise { + if (!this.cache) return; + try { + await this.cache.delete('cache:/v1/events'); + } catch { + // Cache invalidation failure is non-critical + } + } } diff --git a/src/main.ts b/src/main.ts index 41dcd73..29e7c99 100644 --- a/src/main.ts +++ b/src/main.ts @@ -21,7 +21,23 @@ async function bootstrap() { if (tracing) app.enableShutdownHooks(); const config = app.get(ConfigService); - app.use(helmet()); + 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 })); + app.enableCors({ origin: config.getOrThrow('APP_URL'), credentials: true,