diff --git a/Dockerfile b/Dockerfile index cbc8e05..5b6ff13 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,10 +9,9 @@ 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 +COPY --from=build --chown=appuser:appgroup /app/node_modules ./node_modules +COPY --from=build --chown=appuser:appgroup /app/dist ./dist +COPY --from=build --chown=appuser:appgroup /app/prisma ./prisma USER appuser EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ diff --git a/README.md b/README.md index 232bbbb..ee0dcec 100644 --- a/README.md +++ b/README.md @@ -233,7 +233,10 @@ is safe. It creates: - **alice@example.com** (attendee) and **organiser@example.com** (organiser), both with password `Password123!` - A demo organisation **Stellar Events Demo** -- A draft event **StellarFest 2027** with General Admission and VIP ticket types +- A published event **StellarFest 2027** with General Admission and VIP ticket types + +The production Docker image runs as the unprivileged `appuser` account rather +than root. Seed data can be wiped and recreated with: diff --git a/docs/API.md b/docs/API.md index 290e10b..d553a12 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,5 +1,12 @@ # API reference +## Interactive OpenAPI documentation + +When `NODE_ENV` is not `production`, Swagger UI is available at `/docs` and +the generated OpenAPI document at `/docs-json`. The schema is generated from +controllers and DTOs at startup. Swagger is disabled in production so the +interactive schema is not exposed publicly. + Every route below is real: paths come from the controllers, request bodies from the `class-validator` DTOs, and response bodies from what the services return. When they drift, the generated Bruno collection under diff --git a/docs/ERROR_HANDLING.md b/docs/ERROR_HANDLING.md index 588e8f6..98a1684 100644 --- a/docs/ERROR_HANDLING.md +++ b/docs/ERROR_HANDLING.md @@ -1,14 +1,29 @@ # Error handling -Nest's built-in HTTP exception filter handles everything — services -throw `NotFoundException`, `ForbiddenException`, `ConflictException`, -`BadRequestException`, etc. from `@nestjs/common`, and Nest serializes -them to `{ statusCode, message, error }` automatically. +`GlobalExceptionFilter` is registered in `src/main.ts` and handles every +uncaught exception at the HTTP boundary. Known Nest HTTP exceptions retain +Nest's `{ statusCode, message, error }` response shape. Unexpected errors are +sanitised and returned as: + +```json +{ + "statusCode": 500, + "code": "INTERNAL_ERROR", + "message": "Internal server error", + "error": "Internal Server Error", + "requestId": "" +} +``` + +The request id is taken from `X-Request-Id` when supplied; otherwise the +filter generates one. Unexpected errors are logged server-side with the +request method and URL, but their internal message and stack are never +exposed to callers. `ValidationPipe` (registered globally in `main.ts`) rejects any request body that doesn't match its DTO's `class-validator` decorators before the request ever reaches a controller method. -There's no custom global exception filter yet — see -`docs/OBSERVABILITY.md` for what's still missing before a production -deploy (structured error logging in particular). +Prisma and domain errors are mapped to stable status codes and public error +messages by the same filter. See `docs/OBSERVABILITY.md` for production +monitoring guidance. diff --git a/prisma/seed.ts b/prisma/seed.ts index e202a14..4e7f134 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,7 +1,7 @@ /** * prisma/seed.ts — local development seed * - * Creates a demo user, organiser, event and two ticket types so new + * Creates a demo user, organiser, published event and two ticket types so new * contributors have a populated DB immediately after running * `npx prisma migrate dev`. * @@ -12,7 +12,13 @@ * running the script more than once is safe. */ -import { PrismaClient, Industry, UserRole, OrgMemberRole } from '@prisma/client'; +import { + PrismaClient, + EventStatus, + Industry, + UserRole, + OrgMemberRole, +} from '@prisma/client'; import * as bcrypt from 'bcrypt'; const prisma = new PrismaClient(); @@ -89,12 +95,18 @@ async function main() { description: 'A demo concert powered by StellarTickets.', imageUrl: null, category: Industry.CONCERTS, + status: EventStatus.PUBLISHED, venue: 'The Stellar Arena, Cape Town', timezone: 'Africa/Johannesburg', startsAt: new Date('2027-03-15T19:00:00Z'), endsAt: new Date('2027-03-15T23:00:00Z'), }, }); + } else if (event.status !== EventStatus.PUBLISHED) { + event = await prisma.event.update({ + where: { id: event.id }, + data: { status: EventStatus.PUBLISHED }, + }); } // ── 6. Ticket types ────────────────────────────────────────────────────── diff --git a/src/common/filters/global-exception.filter.spec.ts b/src/common/filters/global-exception.filter.spec.ts index bb7660a..4dc7d38 100644 --- a/src/common/filters/global-exception.filter.spec.ts +++ b/src/common/filters/global-exception.filter.spec.ts @@ -23,6 +23,7 @@ describe('GlobalExceptionFilter', () => { const request = { method: 'GET', url: '/test', + headers: { 'x-request-id': 'req-test-1' }, }; const argumentsHost = { switchToHttp: () => ({ @@ -216,6 +217,8 @@ describe('GlobalExceptionFilter', () => { statusCode: HttpStatus.INTERNAL_SERVER_ERROR, code: 'INTERNAL_ERROR', message: 'Internal server error', + error: 'Internal Server Error', + requestId: 'req-test-1', }); }); }); @@ -233,6 +236,8 @@ describe('GlobalExceptionFilter', () => { statusCode: HttpStatus.INTERNAL_SERVER_ERROR, code: 'INTERNAL_ERROR', message: 'Internal server error', + error: 'Internal Server Error', + requestId: 'req-test-1', }); }); @@ -248,6 +253,8 @@ describe('GlobalExceptionFilter', () => { statusCode: HttpStatus.INTERNAL_SERVER_ERROR, code: 'INTERNAL_ERROR', message: 'Internal server error', + error: 'Internal Server Error', + requestId: 'req-test-1', }); }); }); diff --git a/src/common/filters/global-exception.filter.ts b/src/common/filters/global-exception.filter.ts index ad45a18..a5b4a6f 100644 --- a/src/common/filters/global-exception.filter.ts +++ b/src/common/filters/global-exception.filter.ts @@ -7,6 +7,7 @@ import { Logger, } from '@nestjs/common'; import type { Request, Response } from 'express'; +import { randomUUID } from 'node:crypto'; import { Prisma } from '@prisma/client'; import { DomainError } from '../errors/domain.error'; @@ -94,6 +95,23 @@ export class GlobalExceptionFilter implements ExceptionFilter { ); } + fix/issues-395-396-397-398-runtime-hardening + const responseBody = { + statusCode, + code, + message, + ...(code === 'INTERNAL_ERROR' && { + error: 'Internal Server Error', + requestId: this.getRequestId(request), + }), + }; + response.status(statusCode).json(responseBody); + } + + private getRequestId(request: Request): string { + const header = request.headers['x-request-id']; + return (Array.isArray(header) ? header[0] : header) ?? randomUUID(); + const errorBody: Record = { statusCode, code, @@ -104,6 +122,7 @@ export class GlobalExceptionFilter implements ExceptionFilter { } response.status(statusCode).json(errorBody); + main } private getPrismaErrorMessage( diff --git a/src/main.ts b/src/main.ts index 129b69c..6911274 100644 --- a/src/main.ts +++ b/src/main.ts @@ -43,7 +43,9 @@ async function bootstrap() { .addBearerAuth() .build(); const document = SwaggerModule.createDocument(app, swaggerConfig); - SwaggerModule.setup('docs', app, document); + SwaggerModule.setup('docs', app, document, { + jsonDocumentUrl: 'docs-json', + }); } await app.listen(config.getOrThrow('PORT'));