diff --git a/src/common/errors/api-exception.filter.ts b/src/common/errors/api-exception.filter.ts new file mode 100644 index 0000000..ee55cf6 --- /dev/null +++ b/src/common/errors/api-exception.filter.ts @@ -0,0 +1,51 @@ +import { ArgumentsHost, Catch, ExceptionFilter, Logger } from '@nestjs/common'; +import { randomUUID, createHash } from 'node:crypto'; +import type { Request, Response } from 'express'; +import { classifyError } from './classify-error'; +import { ERRORS } from './error-catalog'; + +@Catch() +export class ApiExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger('ApiException'); + catch(error: unknown, host: ArgumentsHost): void { + const http = host.switchToHttp(); + const request = http.getRequest(); + const response = http.getResponse(); + const failure = classifyError(error); + const requestId = + (response.locals.requestId as string | undefined) ?? randomUUID(); + const record = { + event: failure.code, + message: ERRORS[failure.code][2], + diagnostic: failure.diagnostic, + requestId, + method: request.method, + route: request.route?.path ?? 'unmatched', + // Fingerprints distinguish unexpected faults without logging raw errors, queries or secrets. + faultId: + failure.code === 'INTERNAL_FAILURE' && error instanceof Error + ? createHash('sha256') + .update(error.stack ?? error.name) + .digest('hex') + .slice(0, 16) + : undefined, + }; + if (failure.getStatus() >= 500) this.logger.error(JSON.stringify(record)); + else this.logger.warn(JSON.stringify(record)); + if (response.headersSent) return; + response.setHeader('X-Request-Id', requestId); + response.setHeader('Cache-Control', 'no-store'); + if (failure.getStatus() === 429) + response.setHeader( + 'Retry-After', + String(failure.retryAfterSeconds ?? 60), + ); + response.status(failure.getStatus()).json({ + statusCode: failure.getStatus(), + code: failure.code, + message: failure.message, + requestId, + ...(failure.fields ? { fields: failure.fields } : {}), + }); + } +} diff --git a/src/common/errors/app-error.ts b/src/common/errors/app-error.ts new file mode 100644 index 0000000..02df76c --- /dev/null +++ b/src/common/errors/app-error.ts @@ -0,0 +1,16 @@ +import { HttpException } from '@nestjs/common'; +import { ERRORS, type ErrorCode } from './error-catalog'; + +export class AppError extends HttpException { + constructor( + readonly code: ErrorCode, + readonly diagnostic?: string, + readonly fields?: string[], + readonly retryAfterSeconds?: number, + ) { + super( + { code, message: ERRORS[code][1], ...(fields ? { fields } : {}) }, + ERRORS[code][0], + ); + } +} diff --git a/src/common/errors/classify-error.ts b/src/common/errors/classify-error.ts new file mode 100644 index 0000000..4ef3d84 --- /dev/null +++ b/src/common/errors/classify-error.ts @@ -0,0 +1,42 @@ +import { HttpException } from '@nestjs/common'; +import { Prisma } from '../../generated/prisma/client'; +import { AppError } from './app-error'; +import type { ErrorCode } from './error-catalog'; + +export function classifyError(error: unknown): AppError { + if (error instanceof AppError) return error; + if (error instanceof Prisma.PrismaClientKnownRequestError) { + const codes: Record = { + P2002: 'RECORD_CONFLICT', + P2003: 'REFERENCE_INVALID', + P2004: 'DATA_CONSTRAINT', + P2025: 'RECORD_NOT_FOUND', + P2024: 'DATABASE_BUSY', + P2028: 'DATABASE_BUSY', + P2034: 'TRANSACTION_CONFLICT', + }; + return new AppError(codes[error.code] ?? 'INTERNAL_FAILURE', error.code); + } + if (error instanceof Prisma.PrismaClientInitializationError) + return new AppError('DATABASE_BUSY'); + if (error instanceof HttpException) { + const codes: Record = { + 400: 'REQUEST_MALFORMED', + 401: 'AUTH_REQUIRED', + 403: 'ACCESS_DENIED', + 404: 'ROUTE_NOT_FOUND', + 413: 'REQUEST_TOO_LARGE', + 429: 'RATE_LIMITED', + 503: 'DATABASE_BUSY', + }; + return new AppError(codes[error.getStatus()] ?? 'INTERNAL_FAILURE'); + } + // Express parser errors are not Nest HttpExceptions. + if (typeof error === 'object' && error !== null && 'type' in error) { + if (error.type === 'entity.too.large') + return new AppError('REQUEST_TOO_LARGE'); + if (error.type === 'entity.parse.failed') + return new AppError('REQUEST_MALFORMED'); + } + return new AppError('INTERNAL_FAILURE'); +} diff --git a/src/common/errors/commerce-errors.ts b/src/common/errors/commerce-errors.ts new file mode 100644 index 0000000..0143f92 --- /dev/null +++ b/src/common/errors/commerce-errors.ts @@ -0,0 +1,84 @@ +export const COMMERCE_ERRORS = { + PRODUCT_NOT_FOUND: [404, 'Product not found', 'Scoped product lookup failed'], + PRODUCT_NOT_PUBLISHABLE: [ + 409, + 'Add an active priced variant before publishing', + 'Product publish eligibility failed', + ], + PRODUCT_ARCHIVED: [ + 409, + 'Archived products cannot be edited', + 'Archived product mutation rejected', + ], + VARIANT_NOT_FOUND: [ + 404, + 'Product variant not found', + 'Scoped product variant lookup failed', + ], + VARIANT_LIMIT: [ + 409, + 'Product variant limit reached', + 'Variant resource quota exceeded', + ], + VARIANT_REQUIRED: [ + 409, + 'A published product needs an active variant', + 'Last sellable variant deactivation rejected', + ], + GROUP_NOT_FOUND: [ + 404, + 'Catalogue group not found', + 'Scoped catalogue group lookup failed', + ], + ADDRESS_NOT_FOUND: [ + 404, + 'Address not found', + 'Private address lookup failed', + ], + ADDRESS_LIMIT: [ + 409, + 'Address limit reached', + 'Account address quota exceeded', + ], + WAREHOUSE_NOT_FOUND: [ + 404, + 'Warehouse not found', + 'Scoped warehouse lookup failed', + ], + STOCK_NOT_FOUND: [404, 'Stock item not found', 'Scoped stock lookup failed'], + STOCK_INSUFFICIENT: [ + 409, + 'Insufficient available stock', + 'Inventory availability check rejected operation', + ], + STOCK_CAPACITY: [ + 409, + 'Stock balance limit would be exceeded', + 'Inventory integer bound rejected operation', + ], + IDEMPOTENCY_CONFLICT: [ + 409, + 'Idempotency key was used for a different request', + 'Idempotency payload mismatch', + ], + RESERVATION_NOT_FOUND: [ + 404, + 'Stock reservation not found', + 'Scoped reservation lookup failed', + ], + RESERVATION_EXPIRED: [ + 409, + 'Stock reservation has expired', + 'Expired reservation commit rejected', + ], + RESERVATION_CLOSED: [ + 409, + 'Stock reservation is already closed', + 'Invalid reservation state transition', + ], + PRODUCT_UNAVAILABLE: [ + 409, + 'Product variant is not available for reservation', + 'Non-sellable variant reservation rejected', + ], +} as const; diff --git a/src/common/errors/error-catalog.ts b/src/common/errors/error-catalog.ts new file mode 100644 index 0000000..d8d1774 --- /dev/null +++ b/src/common/errors/error-catalog.ts @@ -0,0 +1,4 @@ +import { PLATFORM_ERRORS } from './platform-errors'; +import { COMMERCE_ERRORS } from './commerce-errors'; +export const ERRORS = { ...PLATFORM_ERRORS, ...COMMERCE_ERRORS } as const; +export type ErrorCode = keyof typeof ERRORS; diff --git a/src/common/errors/platform-errors.ts b/src/common/errors/platform-errors.ts new file mode 100644 index 0000000..21da11a --- /dev/null +++ b/src/common/errors/platform-errors.ts @@ -0,0 +1,122 @@ +export const PLATFORM_ERRORS = { + REQUEST_INVALID: [400, 'Invalid request', 'Request schema validation failed'], + REQUEST_MALFORMED: [ + 400, + 'Malformed request syntax', + 'HTTP request parser rejected input', + ], + REQUEST_TOO_LARGE: [ + 413, + 'Request body exceeds the allowed size', + 'HTTP payload limit exceeded', + ], + ROUTE_NOT_FOUND: [404, 'API route not found', 'Unmatched HTTP route'], + AUTH_REQUIRED: [ + 401, + 'Authentication is required', + 'Bearer credential missing or malformed', + ], + AUTH_INVALID_CREDENTIALS: [ + 401, + 'Invalid credentials', + 'Login credential verification failed', + ], + SESSION_INVALID: [ + 401, + 'Session is invalid or expired', + 'Session authentication rejected', + ], + ACCESS_DENIED: [ + 403, + 'You do not have permission for this action', + 'Permission check rejected operation', + ], + SCOPE_DENIED: [ + 403, + 'Account scope is not authorized', + 'Transaction principal or organization mismatch', + ], + RECORD_CONFLICT: [ + 409, + 'A record with these details already exists', + 'Database uniqueness conflict', + ], + REFERENCE_INVALID: [ + 409, + 'A related record is unavailable', + 'Database foreign-key constraint rejected operation', + ], + DATA_CONSTRAINT: [ + 409, + 'Operation violates a data integrity rule', + 'Database check constraint rejected operation', + ], + RECORD_NOT_FOUND: [ + 404, + 'Requested record is unavailable', + 'Database record lookup failed', + ], + DATABASE_BUSY: [ + 503, + 'Database is temporarily busy; retry later', + 'Database timeout or connection unavailable', + ], + TRANSACTION_CONFLICT: [ + 409, + 'Concurrent update detected; retry this operation', + 'Database transaction conflict', + ], + INTERNAL_FAILURE: [ + 500, + 'An unexpected error occurred', + 'Unhandled application failure', + ], + USER_NOT_FOUND: [404, 'User not found', 'Scoped user lookup failed'], + ROLE_NOT_FOUND: [404, 'Role not found', 'Scoped role lookup failed'], + ROLE_GRANT_DENIED: [ + 403, + 'Cannot grant permissions you do not hold', + 'Role grant would exceed actor permissions', + ], + ROLE_IMMUTABLE: [ + 403, + 'System role is immutable', + 'Attempt to modify protected system role', + ], + ROLE_ASSIGNMENT_DENIED: [ + 403, + 'Cannot change these role assignments', + 'Protected or self role assignment rejected', + ], + ROLE_SYSTEM_DENIED: [ + 403, + 'System role cannot be assigned', + 'Attempt to assign owner role', + ], + USER_STATUS_DENIED: [ + 403, + 'Cannot change this account status', + 'Protected or self approval change rejected', + ], + OWNER_EXISTS: [ + 409, + 'Owner already exists', + 'Installation bootstrap repeated', + ], + RECOVERY_INVALID: [ + 400, + 'Invalid or expired recovery token', + 'Recovery token consumption rejected', + ], + RECOVERY_UNAVAILABLE: [ + 503, + 'Password recovery is not configured', + 'SMTP recovery configuration missing', + ], + RATE_LIMITED: [429, 'Too many requests', 'Durable request quota exceeded'], + DATABASE_NOT_READY: [ + 503, + 'Service is not ready', + 'Database readiness probe failed', + ], +} as const; diff --git a/src/common/input.ts b/src/common/input.ts new file mode 100644 index 0000000..b5b8868 --- /dev/null +++ b/src/common/input.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; +export const text = (max: number, min = 1) => + z + .string() + .trim() + .min(min) + .max(max) + .regex(/^[^<>\u0000-\u001F\u007F]*$/, 'Plain text only'); +export const ids = z + .array(z.uuid()) + .max(20) + .refine((values) => new Set(values).size === values.length); diff --git a/src/common/validation.pipe.ts b/src/common/validation.pipe.ts index 17f19fa..5c0a2b2 100644 --- a/src/common/validation.pipe.ts +++ b/src/common/validation.pipe.ts @@ -1,17 +1,15 @@ -import { BadRequestException, PipeTransform } from '@nestjs/common'; +import type { PipeTransform } from '@nestjs/common'; import { z } from 'zod'; +import { AppError } from './errors/app-error'; export class SchemaPipe implements PipeTransform { constructor(private readonly schema: z.ZodType) {} transform(value: unknown): T { const result = this.schema.safeParse(value); if (!result.success) { - throw new BadRequestException({ - message: 'Invalid request', - fields: [ - ...new Set(result.error.issues.map((issue) => issue.path.join('.'))), - ], - }); + throw new AppError('REQUEST_INVALID', 'SCHEMA_REJECTED', [ + ...new Set(result.error.issues.map((issue) => issue.path.join('.'))), + ]); } return result.data; } diff --git a/src/config/environment.ts b/src/config/environment.ts index ac46ee8..f127e39 100644 --- a/src/config/environment.ts +++ b/src/config/environment.ts @@ -8,6 +8,7 @@ const schema = z .enum(['development', 'test', 'production']) .default('development'), PORT: z.coerce.number().int().min(1).max(65535).default(3000), + DATABASE_POOL_SIZE: z.coerce.number().int().min(1).max(50).default(10), DATABASE_URL: z.url().refine((value) => /^postgres(ql)?:/.test(value)), CORS_ORIGINS: z .string() diff --git a/src/configure-app.ts b/src/configure-app.ts index 11c5b33..df83db1 100644 --- a/src/configure-app.ts +++ b/src/configure-app.ts @@ -1,6 +1,10 @@ import type { INestApplication } from '@nestjs/common'; +import type { NestExpressApplication } from '@nestjs/platform-express'; import helmet from 'helmet'; +import { randomUUID } from 'node:crypto'; +import type { Request, Response, NextFunction } from 'express'; import type { Environment } from './config/environment'; +import { ApiExceptionFilter } from './common/errors/api-exception.filter'; export function configureApp( app: INestApplication, @@ -8,6 +12,19 @@ export function configureApp( ): void { app.setGlobalPrefix('api/v1'); app.use(helmet()); + app.use((_request: Request, response: Response, next: NextFunction) => { + response.locals.requestId = randomUUID(); + response.setHeader('X-Request-Id', response.locals.requestId); + response.setHeader('Cache-Control', 'no-store'); + next(); + }); + (app as NestExpressApplication).useBodyParser('json', { limit: '32kb' }); + (app as NestExpressApplication).useBodyParser('urlencoded', { + limit: '32kb', + extended: false, + parameterLimit: 100, + }); + app.useGlobalFilters(new ApiExceptionFilter()); app.enableCors({ origin: environment.CORS_ORIGINS, credentials: true }); app.enableShutdownHooks(); } diff --git a/src/database/database.service.ts b/src/database/database.service.ts index 00fb00f..0cf9fad 100644 --- a/src/database/database.service.ts +++ b/src/database/database.service.ts @@ -20,7 +20,7 @@ export class DatabaseService connectionString: environment.DATABASE_URL, connectionTimeoutMillis: 3000, query_timeout: 3000, - max: 10, + max: environment.DATABASE_POOL_SIZE, }), }); } diff --git a/src/health/health.service.ts b/src/health/health.service.ts index 372dfb8..897094f 100644 --- a/src/health/health.service.ts +++ b/src/health/health.service.ts @@ -1,4 +1,5 @@ -import { Injectable, ServiceUnavailableException } from '@nestjs/common'; +import { AppError } from '../common/errors/app-error'; +import { Injectable } from '@nestjs/common'; import { DatabaseService } from '../database/database.service'; @Injectable() @@ -13,7 +14,7 @@ export class HealthService { try { await this.database.ping(); } catch { - throw new ServiceUnavailableException('Service is not ready'); + throw new AppError('DATABASE_NOT_READY'); } return { status: 'ok' }; } diff --git a/src/identity/access.guard.ts b/src/identity/access.guard.ts index 922bb06..bd562a4 100644 --- a/src/identity/access.guard.ts +++ b/src/identity/access.guard.ts @@ -1,10 +1,6 @@ -import { - CanActivate, - ExecutionContext, - ForbiddenException, - Injectable, - UnauthorizedException, -} from '@nestjs/common'; +import { RateLimitService } from './rate-limit.service'; +import { AppError } from '../common/errors/app-error'; +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { SessionStore } from './session.store'; import { PUBLIC_ROUTE, REQUIRED_PERMISSION } from './access.decorator'; @@ -15,6 +11,7 @@ export class AccessGuard implements CanActivate { constructor( private readonly reflector: Reflector, private readonly sessions: SessionStore, + private readonly limits: RateLimitService, ) {} async canActivate(context: ExecutionContext): Promise { const targets = [context.getHandler(), context.getClass()]; @@ -24,14 +21,20 @@ export class AccessGuard implements CanActivate { const match = /^Bearer ([A-Za-z0-9_-]{43})$/.exec( request.headers.authorization ?? '', ); - if (!match) throw new UnauthorizedException(); + if (!match) throw new AppError('AUTH_REQUIRED'); + await this.limits.consume(`protected-ip:${request.ip}`, 300, 60); request.principal = await this.sessions.authenticate(match[1]); + await this.limits.consume( + `protected-user:${request.principal.userId}`, + 180, + 60, + ); const permission = this.reflector.getAllAndOverride( REQUIRED_PERMISSION, targets, ); if (permission && !request.principal.permissions.includes(permission)) - throw new ForbiddenException(); + throw new AppError('ACCESS_DENIED'); return true; } } diff --git a/src/identity/access.store.ts b/src/identity/access.store.ts index a221e0b..4f63011 100644 --- a/src/identity/access.store.ts +++ b/src/identity/access.store.ts @@ -1,11 +1,8 @@ -import { - ConflictException, - ForbiddenException, - Injectable, -} from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { DatabaseService } from '../database/database.service'; import { Prisma } from '../generated/prisma/client'; import { readPrincipal } from './session.store'; +import { AppError } from '../common/errors/app-error'; import type { Principal } from './identity.types'; import type { Permission } from './permissions'; @@ -14,20 +11,24 @@ export class AccessStore { constructor(private readonly db: DatabaseService) {} async mutate( actor: Principal, - permission: Permission, + permission: Permission | null, work: (tx: Prisma.TransactionClient, current: Principal) => Promise, + lockOrganization = true, ): Promise { try { return await this.db.$transaction(async (tx) => { - // Serialize administration within an organization and recheck permissions after locking. - await tx.$queryRaw`SELECT id FROM organizations WHERE id = ${actor.organizationId}::uuid FOR UPDATE`; + if (lockOrganization) { + await tx.$queryRaw`SELECT id FROM organizations WHERE id = ${actor.organizationId}::uuid FOR UPDATE`; + } const current = await readPrincipal(tx, actor.sessionId); if ( current.organizationId !== actor.organizationId || - !current.permissions.includes(permission) + current.userId !== actor.userId ) { - throw new ForbiddenException(); + throw new AppError('SCOPE_DENIED'); } + if (permission && !current.permissions.includes(permission)) + throw new AppError('ACCESS_DENIED'); return work(tx, current); }); } catch (error) { @@ -35,9 +36,7 @@ export class AccessStore { error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002' ) { - throw new ConflictException( - 'A record with these details already exists', - ); + throw new AppError('RECORD_CONFLICT'); } throw error; } diff --git a/src/identity/auth.service.ts b/src/identity/auth.service.ts index f48eedb..fff59c7 100644 --- a/src/identity/auth.service.ts +++ b/src/identity/auth.service.ts @@ -1,6 +1,7 @@ -import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { ENVIRONMENT } from '../config/environment.module'; import type { Environment } from '../config/environment'; +import { AppError } from '../common/errors/app-error'; import { AuthStore } from './auth.store'; import { PasswordService } from './password.service'; import { RateLimitService } from './rate-limit.service'; @@ -22,12 +23,18 @@ export class AuthService { 900, ); const user = await this.store.findUser(input.organizationId, input.email); - const valid = user - ? await this.passwords.verify(input.password, user.passwordHash) - : (await this.passwords.dummyVerify(input.password), false); + let valid = false; + if (user) + valid = await this.passwords.verify(input.password, user.passwordHash); + else await this.passwords.dummyVerify(input.password); if (!valid || !user || user.status !== 'ACTIVE') { if (user) await this.store.failedLogin(user.organizationId, user.id); - throw new UnauthorizedException('Invalid credentials'); + const reason = !user + ? 'ACCOUNT_UNKNOWN' + : !valid + ? 'PASSWORD_MISMATCH' + : 'ACCOUNT_INACTIVE'; + throw new AppError('AUTH_INVALID_CREDENTIALS', reason); } const { token, tokenHash } = issueToken(); const expiresAt = new Date( diff --git a/src/identity/auth.store.ts b/src/identity/auth.store.ts index 4683605..f39b18c 100644 --- a/src/identity/auth.store.ts +++ b/src/identity/auth.store.ts @@ -1,4 +1,5 @@ -import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { AppError } from '../common/errors/app-error'; +import { Injectable } from '@nestjs/common'; import { DatabaseService } from '../database/database.service'; import { recordAudit } from './audit'; import type { Principal } from './identity.types'; @@ -21,7 +22,7 @@ export class AuthStore { await tx.$queryRaw`SELECT id FROM users WHERE id = ${userId}::uuid FOR UPDATE`; const user = await tx.user.findUniqueOrThrow({ where: { id: userId } }); if (user.status !== 'ACTIVE' || user.passwordHash !== expectedHash) - throw new UnauthorizedException(); + throw new AppError('SESSION_INVALID', 'LOGIN_SNAPSHOT_CHANGED'); await tx.session.create({ data: { userId, tokenHash, expiresAt } }); await recordAudit( tx, diff --git a/src/identity/bootstrap.service.ts b/src/identity/bootstrap.service.ts index 63d9172..d8592a1 100644 --- a/src/identity/bootstrap.service.ts +++ b/src/identity/bootstrap.service.ts @@ -1,4 +1,5 @@ -import { ConflictException, Injectable } from '@nestjs/common'; +import { AppError } from '../common/errors/app-error'; +import { Injectable } from '@nestjs/common'; import { DatabaseService } from '../database/database.service'; import { PasswordService } from './password.service'; import { PERMISSIONS } from './permissions'; @@ -17,7 +18,7 @@ export class BootstrapService { // One-time installation bootstrap, serialized across processes. await tx.$queryRaw`SELECT pg_advisory_xact_lock(74192001)::text`; if (await tx.user.count({ where: { isOwner: true } })) - throw new ConflictException('Owner already exists'); + throw new AppError('OWNER_EXISTS'); const organization = await tx.organization.create({ data: { name: organizationName }, }); diff --git a/src/identity/identity.module.ts b/src/identity/identity.module.ts index 8ee3afe..904e7de 100644 --- a/src/identity/identity.module.ts +++ b/src/identity/identity.module.ts @@ -46,6 +46,6 @@ import { UsersController } from './users.controller'; UserStore, { provide: APP_GUARD, useClass: AccessGuard }, ], - exports: [BootstrapService], + exports: [BootstrapService, AccessStore, RateLimitService], }) export class IdentityModule {} diff --git a/src/identity/permissions.ts b/src/identity/permissions.ts index b6392ae..e52b281 100644 --- a/src/identity/permissions.ts +++ b/src/identity/permissions.ts @@ -6,5 +6,13 @@ export const PERMISSIONS = [ 'roles.read', 'roles.manage', 'audit.read', + 'catalog.read', + 'catalog.manage', + 'catalog.publish', + 'inventory.read', + 'inventory.manage', + 'inventory.adjust', + 'inventory.reserve', + 'inventory.commit', ] as const; export type Permission = (typeof PERMISSIONS)[number]; diff --git a/src/identity/rate-limit.service.ts b/src/identity/rate-limit.service.ts index 3bed16c..0c090a4 100644 --- a/src/identity/rate-limit.service.ts +++ b/src/identity/rate-limit.service.ts @@ -1,4 +1,5 @@ -import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { AppError } from '../common/errors/app-error'; +import { Injectable } from '@nestjs/common'; import { DatabaseService } from '../database/database.service'; import { hashToken } from './tokens'; @@ -7,19 +8,23 @@ export class RateLimitService { constructor(private readonly db: DatabaseService) {} async consume(key: string, limit: number, seconds: number): Promise { const digest = hashToken(key); - const [row] = await this.db.$queryRaw>` + const [row] = await this.db.$queryRaw< + Array<{ hits: number; expiresAt: Date }> + >` INSERT INTO rate_limits (key, hits, expires_at) VALUES (${digest}, 1, NOW() + ${seconds} * INTERVAL '1 second') ON CONFLICT (key) DO UPDATE SET hits = CASE WHEN rate_limits.expires_at <= NOW() THEN 1 ELSE rate_limits.hits + 1 END, expires_at = CASE WHEN rate_limits.expires_at <= NOW() THEN NOW() + ${seconds} * INTERVAL '1 second' ELSE rate_limits.expires_at END - RETURNING hits + RETURNING hits, expires_at AS "expiresAt" `; if (row.hits > limit) - throw new HttpException( - 'Too many requests', - HttpStatus.TOO_MANY_REQUESTS, + throw new AppError( + 'RATE_LIMITED', + key.split(':')[0], + undefined, + Math.max(1, Math.ceil((row.expiresAt.getTime() - Date.now()) / 1000)), ); } } diff --git a/src/identity/recovery-mailer.ts b/src/identity/recovery-mailer.ts index 7cbead6..916b6a3 100644 --- a/src/identity/recovery-mailer.ts +++ b/src/identity/recovery-mailer.ts @@ -1,8 +1,5 @@ -import { - Inject, - Injectable, - ServiceUnavailableException, -} from '@nestjs/common'; +import { AppError } from '../common/errors/app-error'; +import { Inject, Injectable } from '@nestjs/common'; import { createTransport } from 'nodemailer'; import { ENVIRONMENT } from '../config/environment.module'; import type { Environment } from '../config/environment'; @@ -11,10 +8,7 @@ import type { Environment } from '../config/environment'; export class RecoveryMailer { constructor(@Inject(ENVIRONMENT) private readonly env: Environment) {} assertConfigured(): void { - if (!this.env.SMTP_HOST) - throw new ServiceUnavailableException( - 'Password recovery is not configured', - ); + if (!this.env.SMTP_HOST) throw new AppError('RECOVERY_UNAVAILABLE'); } async send(email: string, token: string): Promise { this.assertConfigured(); diff --git a/src/identity/recovery.service.ts b/src/identity/recovery.service.ts index ac29f74..7c26f64 100644 --- a/src/identity/recovery.service.ts +++ b/src/identity/recovery.service.ts @@ -41,7 +41,12 @@ export class RecoveryService { } catch { await this.store.discard(tokenHash); // SMTP errors may contain credentials and addresses; do not log the raw error. - this.logger.error('Password recovery delivery failed'); + this.logger.error( + JSON.stringify({ + event: 'RECOVERY_DELIVERY_FAILED', + message: 'Password recovery delivery failed', + }), + ); } } async reset(token: string, password: string): Promise { diff --git a/src/identity/recovery.store.ts b/src/identity/recovery.store.ts index a20bb97..e0f01a6 100644 --- a/src/identity/recovery.store.ts +++ b/src/identity/recovery.store.ts @@ -1,4 +1,5 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { AppError } from '../common/errors/app-error'; +import { Injectable } from '@nestjs/common'; import { DatabaseService } from '../database/database.service'; import { recordAudit } from './audit'; @@ -32,8 +33,7 @@ export class RecoveryStore { async reset(tokenHash: string, passwordHash: string) { await this.db.$transaction(async (tx) => { const token = await tx.recoveryToken.findUnique({ where: { tokenHash } }); - if (!token) - throw new BadRequestException('Invalid or expired recovery token'); + if (!token) throw new AppError('RECOVERY_INVALID'); await tx.$queryRaw`SELECT id FROM users WHERE id = ${token.userId}::uuid FOR UPDATE`; const user = await tx.user.findUniqueOrThrow({ where: { id: token.userId }, @@ -42,7 +42,7 @@ export class RecoveryStore { where: { id: token.id, expiresAt: { gt: new Date() } }, }); if (consumed.count !== 1 || user.status !== 'ACTIVE') { - throw new BadRequestException('Invalid or expired recovery token'); + throw new AppError('RECOVERY_INVALID'); } await tx.user.update({ where: { id: user.id }, data: { passwordHash } }); await tx.recoveryToken.deleteMany({ where: { userId: user.id } }); diff --git a/src/identity/role.store.ts b/src/identity/role.store.ts index 5191123..fcaf08b 100644 --- a/src/identity/role.store.ts +++ b/src/identity/role.store.ts @@ -1,8 +1,5 @@ -import { - ForbiddenException, - Injectable, - NotFoundException, -} from '@nestjs/common'; +import { AppError } from '../common/errors/app-error'; +import { Injectable } from '@nestjs/common'; import { DatabaseService } from '../database/database.service'; import { AccessStore } from './access.store'; import { recordAudit } from './audit'; @@ -13,7 +10,7 @@ function ensureGrantable(actor: Principal, permissions: string[]) { if ( permissions.some((permission) => !actor.permissions.includes(permission)) ) { - throw new ForbiddenException('Cannot grant permissions you do not hold'); + throw new AppError('ROLE_GRANT_DENIED'); } } @Injectable() @@ -37,9 +34,8 @@ export class RoleStore { const role = await tx.role.findFirst({ where: { id, organizationId: actor.organizationId }, }); - if (!role) throw new NotFoundException(); - if (role.isSystem) - throw new ForbiddenException('System role is immutable'); + if (!role) throw new AppError('ROLE_NOT_FOUND'); + if (role.isSystem) throw new AppError('ROLE_IMMUTABLE'); ensureGrantable(current, role.permissions); } const role = id @@ -66,9 +62,9 @@ export class RoleStore { where: { id: userId, organizationId: actor.organizationId }, include: { roles: { include: { role: true } } }, }); - if (!user) throw new NotFoundException(); + if (!user) throw new AppError('USER_NOT_FOUND'); if (user.isOwner || user.id === current.userId) - throw new ForbiddenException('Cannot change these role assignments'); + throw new AppError('ROLE_ASSIGNMENT_DENIED'); ensureGrantable( current, user.roles.flatMap((assignment) => assignment.role.permissions), @@ -76,9 +72,10 @@ export class RoleStore { const roles = await tx.role.findMany({ where: { id: { in: roleIds }, organizationId: actor.organizationId }, }); - if (roles.length !== roleIds.length) throw new NotFoundException(); + if (roles.length !== roleIds.length) + throw new AppError('ROLE_NOT_FOUND'); if (roles.some((role) => role.isSystem)) - throw new ForbiddenException('System role cannot be assigned'); + throw new AppError('ROLE_SYSTEM_DENIED'); ensureGrantable( current, roles.flatMap((role) => role.permissions), diff --git a/src/identity/session.store.ts b/src/identity/session.store.ts index c6ef6bc..68d2206 100644 --- a/src/identity/session.store.ts +++ b/src/identity/session.store.ts @@ -1,4 +1,5 @@ -import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { AppError } from '../common/errors/app-error'; +import { Injectable } from '@nestjs/common'; import { DatabaseService } from '../database/database.service'; import type { Prisma } from '../generated/prisma/client'; import { hashToken } from './tokens'; @@ -18,7 +19,7 @@ export async function readPrincipal( session.expiresAt <= new Date() || session.user.status !== 'ACTIVE' ) { - throw new UnauthorizedException(); + throw new AppError('SESSION_INVALID'); } return { userId: session.userId, @@ -36,7 +37,7 @@ export class SessionStore { const session = await this.db.session.findUnique({ where: { tokenHash: hashToken(token) }, }); - if (!session) throw new UnauthorizedException(); + if (!session) throw new AppError('SESSION_INVALID'); return readPrincipal(this.db, session.id); } } diff --git a/src/identity/user.store.ts b/src/identity/user.store.ts index 9f1de3c..f656264 100644 --- a/src/identity/user.store.ts +++ b/src/identity/user.store.ts @@ -1,8 +1,5 @@ -import { - ForbiddenException, - Injectable, - NotFoundException, -} from '@nestjs/common'; +import { AppError } from '../common/errors/app-error'; +import { Injectable } from '@nestjs/common'; import { DatabaseService } from '../database/database.service'; import { AccessStore } from './access.store'; import { PasswordService } from './password.service'; @@ -66,9 +63,9 @@ export class UserStore { const user = await tx.user.findFirst({ where: { id, organizationId: actor.organizationId }, }); - if (!user) throw new NotFoundException(); + if (!user) throw new AppError('USER_NOT_FOUND'); if (user.isOwner || user.id === actor.userId) - throw new ForbiddenException('Cannot change this account status'); + throw new AppError('USER_STATUS_DENIED'); const updated = await tx.user.update({ where: { id }, data: { status }, diff --git a/test/admin-policy.spec.ts b/test/admin-policy.spec.ts index 5f49657..621bc1c 100644 --- a/test/admin-policy.spec.ts +++ b/test/admin-policy.spec.ts @@ -77,7 +77,7 @@ describe('administration use-case policy', () => { it('hides users outside the organization scope', async () => { tx.user.findFirst.mockResolvedValue(null); await expect(users.setStatus(actor, 'foreign', 'ACTIVE')).rejects.toThrow( - 'Not Found', + /not found/i, ); expect(tx.user.update).not.toHaveBeenCalled(); }); @@ -105,7 +105,7 @@ describe('administration use-case policy', () => { tx.role.findFirst.mockResolvedValue(null); await expect( roles.save(actor, { name: 'Changed', permissions: [] }, 'foreign'), - ).rejects.toThrow('Not Found'); + ).rejects.toThrow(/not found/i); }); it('does not remove a target user permissions the actor cannot grant', async () => { tx.user.findFirst.mockResolvedValue({ @@ -121,7 +121,7 @@ describe('administration use-case policy', () => { it('hides missing assignment targets', async () => { tx.user.findFirst.mockResolvedValue(null); await expect(roles.assign(actor, 'foreign', [])).rejects.toThrow( - 'Not Found', + /not found/i, ); }); }); diff --git a/test/error-handling.spec.ts b/test/error-handling.spec.ts new file mode 100644 index 0000000..2bd0d7f --- /dev/null +++ b/test/error-handling.spec.ts @@ -0,0 +1,109 @@ +import { ArgumentsHost, HttpException, Logger } from '@nestjs/common'; +import { ApiExceptionFilter } from '../src/common/errors/api-exception.filter'; +import { AppError } from '../src/common/errors/app-error'; +import { ERRORS } from '../src/common/errors/error-catalog'; +import { classifyError } from '../src/common/errors/classify-error'; +import { Prisma } from '../src/generated/prisma/client'; + +describe('safe error responses and logs', () => { + beforeEach(() => { + jest.spyOn(Logger.prototype, 'warn').mockImplementation(); + jest.spyOn(Logger.prototype, 'error').mockImplementation(); + }); + afterEach(() => jest.restoreAllMocks()); + function dispatch(error: unknown, headersSent = false) { + const response = { + locals: { requestId: 'server-generated-id' }, + headersSent, + setHeader: jest.fn(), + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + const host = { + switchToHttp: () => ({ + getRequest: () => ({ + method: 'POST', + route: { path: '/products/:id' }, + body: { password: 'SECRET' }, + headers: { authorization: 'Bearer SECRET' }, + originalUrl: '/products?token=SECRET', + }), + getResponse: () => response, + }), + } as unknown as ArgumentsHost; + new ApiExceptionFilter().catch(error, host); + return response; + } + it('returns stable codes and correlation IDs without leaking raw faults', () => { + const response = dispatch(new Error('SQL password=SECRET token=SECRET')); + expect(response.json).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'INTERNAL_FAILURE', + requestId: 'server-generated-id', + statusCode: 500, + }), + ); + const output = JSON.stringify([ + response.json.mock.calls, + jest.mocked(Logger.prototype.error).mock.calls, + ]); + expect(output).not.toContain('SECRET'); + expect(output).not.toContain('password='); + expect(output).toContain('faultId'); + }); + it('keeps validation field names and unique business error messages', () => { + const response = dispatch( + new AppError('REQUEST_INVALID', 'SCHEMA_REJECTED', ['price']), + ); + expect(response.json).toHaveBeenCalledWith( + expect.objectContaining({ fields: ['price'], code: 'REQUEST_INVALID' }), + ); + const messages = Object.values(ERRORS).map((value) => value[1]); + expect(new Set(messages).size).toBe(messages.length); + }); + it('does not try to write a second response', () => { + expect( + dispatch(new AppError('STOCK_INSUFFICIENT'), true).json, + ).not.toHaveBeenCalled(); + }); + it('adds retry guidance for throttling', () => { + expect( + dispatch(new AppError('RATE_LIMITED')).setHeader, + ).toHaveBeenCalledWith('Retry-After', '60'); + }); + it.each([ + ['P2002', 'RECORD_CONFLICT'], + ['P2003', 'REFERENCE_INVALID'], + ['P2004', 'DATA_CONSTRAINT'], + ['P2025', 'RECORD_NOT_FOUND'], + ['P2028', 'DATABASE_BUSY'], + ['P2034', 'TRANSACTION_CONFLICT'], + ['P9999', 'INTERNAL_FAILURE'], + ])('classifies database error %s as %s', (databaseCode, code) => { + const error = new Prisma.PrismaClientKnownRequestError('SECRET', { + code: databaseCode, + clientVersion: 'test', + }); + expect(classifyError(error).code).toBe(code); + }); + it('classifies parser, transport and connection failures', () => { + expect(classifyError({ type: 'entity.too.large' }).code).toBe( + 'REQUEST_TOO_LARGE', + ); + expect(classifyError({ type: 'entity.parse.failed' }).code).toBe( + 'REQUEST_MALFORMED', + ); + expect(classifyError(new HttpException('SECRET', 404)).code).toBe( + 'ROUTE_NOT_FOUND', + ); + expect(classifyError(new HttpException('SECRET', 502)).code).toBe( + 'INTERNAL_FAILURE', + ); + expect( + classifyError( + new Prisma.PrismaClientInitializationError('SECRET', 'test'), + ).code, + ).toBe('DATABASE_BUSY'); + expect(classifyError(null).code).toBe('INTERNAL_FAILURE'); + }); +}); diff --git a/test/helpers/identity-app.ts b/test/helpers/identity-app.ts index 2383cc4..de1b80b 100644 --- a/test/helpers/identity-app.ts +++ b/test/helpers/identity-app.ts @@ -16,6 +16,7 @@ export async function identityApp() { const env = parseEnvironment({ DATABASE_URL: database.connectionUrl, NODE_ENV: 'test', + DATABASE_POOL_SIZE: process.env.TEST_DATABASE_URL ? '10' : '1', }); const mailer = { assertConfigured: jest.fn(), @@ -28,6 +29,7 @@ export async function identityApp() { .useValue(mailer) .compile(); const app = module.createNestApplication(); + app.useLogger(false); configureApp(app, env); await app.init(); const db = app.get(DatabaseService); @@ -51,6 +53,7 @@ export async function identityApp() { app, db, + executeSql: database.execute, owner, token, api, diff --git a/test/identity-auth.spec.ts b/test/identity-auth.spec.ts index 1522cf8..82fcce2 100644 --- a/test/identity-auth.spec.ts +++ b/test/identity-auth.spec.ts @@ -58,7 +58,8 @@ describe('authentication with migrated PostgreSQL engine', () => { const missing = await ctx.login('missing@example.com'); const wrong = await ctx.login('owner@example.com', 'wrong password'); expect(missing.status).toBe(401); - expect(wrong.body).toEqual(missing.body); + expect(wrong.body.code).toBe(missing.body.code); + expect(wrong.body.message).toBe(missing.body.message); }); it('validates payloads without echoing secrets and rejects mass assignment', async () => { const response = await ctx diff --git a/test/security-http.spec.ts b/test/security-http.spec.ts new file mode 100644 index 0000000..31cfe8d --- /dev/null +++ b/test/security-http.spec.ts @@ -0,0 +1,71 @@ +import { Logger } from '@nestjs/common'; +import { identityApp, type IdentityApp } from './helpers/identity-app'; +import { randomUUID } from 'node:crypto'; + +describe('HTTP security boundaries', () => { + let ctx: IdentityApp; + beforeAll(async () => { + ctx = await identityApp(); + }, 60000); + afterAll(async () => { + await ctx?.close(); + }); + afterEach(() => jest.restoreAllMocks()); + it('bounds bodies and rejects malformed JSON with distinct codes', async () => { + const large = await ctx + .api() + .post('/api/v1/products') + .auth(ctx.token, { type: 'bearer' }) + .send({ name: 'A', slug: 'a', description: 'x'.repeat(40000) }) + .expect(413); + expect(large.body.code).toBe('REQUEST_TOO_LARGE'); + const invalid = await ctx + .api() + .post('/api/v1/products') + .set('Content-Type', 'application/json') + .send('{"invalid":') + .expect(400); + expect(invalid.body.code).toBe('REQUEST_MALFORMED'); + }); + it('generates its own request IDs and ignores cookie credentials', async () => { + const response = await ctx + .api() + .get('/api/v1/addresses') + .set('X-Request-Id', 'attacker-controlled') + .set('Cookie', 'accessToken=' + ctx.token) + .expect(401); + expect(response.body.requestId).toMatch(/^[a-f0-9-]{36}$/); + expect(response.headers['x-request-id']).toBe(response.body.requestId); + expect(response.headers['cache-control']).toBe('no-store'); + }); + it('treats SQL-looking input as data and prevents field injection', async () => { + const name = "Robert'); DROP TABLE users;--"; + await ctx + .api() + .post('/api/v1/products') + .auth(ctx.token, { type: 'bearer' }) + .send({ name, slug: randomUUID() }) + .expect(201); + expect(await ctx.db.user.count()).toBeGreaterThan(0); + await ctx + .api() + .get('/api/v1/products?limit=999999') + .auth(ctx.token, { type: 'bearer' }) + .expect(400); + }); + it('logs an unexpected database failure without exposing sensitive text', async () => { + const logger = jest.spyOn(Logger.prototype, 'error').mockImplementation(); + jest + .spyOn(ctx.db.product, 'findMany') + .mockRejectedValueOnce(new Error('SELECT secret password=SECRET')); + const response = await ctx + .api() + .get('/api/v1/products') + .auth(ctx.token, { type: 'bearer' }) + .expect(500); + expect(response.body.code).toBe('INTERNAL_FAILURE'); + expect(JSON.stringify([response.body, logger.mock.calls])).not.toContain( + 'SECRET', + ); + }); +});