feat/catalog-inventory #2

Merged
mihir merged 7 commits from feat/catalog-inventory into main 2026-09-10 23:43:44 +05:30
31 changed files with 639 additions and 89 deletions
Showing only changes of commit cad3f4ec29 - Show all commits

View File

@ -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<Request>();
const response = http.getResponse<Response>();
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 } : {}),
});
}
}

View File

@ -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],
);
}
}

View File

@ -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<string, ErrorCode> = {
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<number, ErrorCode> = {
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');
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

12
src/common/input.ts Normal file
View File

@ -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);

View File

@ -1,17 +1,15 @@
import { BadRequestException, PipeTransform } from '@nestjs/common'; import type { PipeTransform } from '@nestjs/common';
import { z } from 'zod'; import { z } from 'zod';
import { AppError } from './errors/app-error';
export class SchemaPipe<T> implements PipeTransform<unknown, T> { export class SchemaPipe<T> implements PipeTransform<unknown, T> {
constructor(private readonly schema: z.ZodType<T>) {} constructor(private readonly schema: z.ZodType<T>) {}
transform(value: unknown): T { transform(value: unknown): T {
const result = this.schema.safeParse(value); const result = this.schema.safeParse(value);
if (!result.success) { if (!result.success) {
throw new BadRequestException({ throw new AppError('REQUEST_INVALID', 'SCHEMA_REJECTED', [
message: 'Invalid request', ...new Set(result.error.issues.map((issue) => issue.path.join('.'))),
fields: [ ]);
...new Set(result.error.issues.map((issue) => issue.path.join('.'))),
],
});
} }
return result.data; return result.data;
} }

View File

@ -8,6 +8,7 @@ const schema = z
.enum(['development', 'test', 'production']) .enum(['development', 'test', 'production'])
.default('development'), .default('development'),
PORT: z.coerce.number().int().min(1).max(65535).default(3000), 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)), DATABASE_URL: z.url().refine((value) => /^postgres(ql)?:/.test(value)),
CORS_ORIGINS: z CORS_ORIGINS: z
.string() .string()

View File

@ -1,6 +1,10 @@
import type { INestApplication } from '@nestjs/common'; import type { INestApplication } from '@nestjs/common';
import type { NestExpressApplication } from '@nestjs/platform-express';
import helmet from 'helmet'; import helmet from 'helmet';
import { randomUUID } from 'node:crypto';
import type { Request, Response, NextFunction } from 'express';
import type { Environment } from './config/environment'; import type { Environment } from './config/environment';
import { ApiExceptionFilter } from './common/errors/api-exception.filter';
export function configureApp( export function configureApp(
app: INestApplication, app: INestApplication,
@ -8,6 +12,19 @@ export function configureApp(
): void { ): void {
app.setGlobalPrefix('api/v1'); app.setGlobalPrefix('api/v1');
app.use(helmet()); 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.enableCors({ origin: environment.CORS_ORIGINS, credentials: true });
app.enableShutdownHooks(); app.enableShutdownHooks();
} }

View File

@ -20,7 +20,7 @@ export class DatabaseService
connectionString: environment.DATABASE_URL, connectionString: environment.DATABASE_URL,
connectionTimeoutMillis: 3000, connectionTimeoutMillis: 3000,
query_timeout: 3000, query_timeout: 3000,
max: 10, max: environment.DATABASE_POOL_SIZE,
}), }),
}); });
} }

View File

@ -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'; import { DatabaseService } from '../database/database.service';
@Injectable() @Injectable()
@ -13,7 +14,7 @@ export class HealthService {
try { try {
await this.database.ping(); await this.database.ping();
} catch { } catch {
throw new ServiceUnavailableException('Service is not ready'); throw new AppError('DATABASE_NOT_READY');
} }
return { status: 'ok' }; return { status: 'ok' };
} }

View File

@ -1,10 +1,6 @@
import { import { RateLimitService } from './rate-limit.service';
CanActivate, import { AppError } from '../common/errors/app-error';
ExecutionContext, import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
ForbiddenException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import { SessionStore } from './session.store'; import { SessionStore } from './session.store';
import { PUBLIC_ROUTE, REQUIRED_PERMISSION } from './access.decorator'; import { PUBLIC_ROUTE, REQUIRED_PERMISSION } from './access.decorator';
@ -15,6 +11,7 @@ export class AccessGuard implements CanActivate {
constructor( constructor(
private readonly reflector: Reflector, private readonly reflector: Reflector,
private readonly sessions: SessionStore, private readonly sessions: SessionStore,
private readonly limits: RateLimitService,
) {} ) {}
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
const targets = [context.getHandler(), context.getClass()]; const targets = [context.getHandler(), context.getClass()];
@ -24,14 +21,20 @@ export class AccessGuard implements CanActivate {
const match = /^Bearer ([A-Za-z0-9_-]{43})$/.exec( const match = /^Bearer ([A-Za-z0-9_-]{43})$/.exec(
request.headers.authorization ?? '', 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]); request.principal = await this.sessions.authenticate(match[1]);
await this.limits.consume(
`protected-user:${request.principal.userId}`,
180,
60,
);
const permission = this.reflector.getAllAndOverride<string>( const permission = this.reflector.getAllAndOverride<string>(
REQUIRED_PERMISSION, REQUIRED_PERMISSION,
targets, targets,
); );
if (permission && !request.principal.permissions.includes(permission)) if (permission && !request.principal.permissions.includes(permission))
throw new ForbiddenException(); throw new AppError('ACCESS_DENIED');
return true; return true;
} }
} }

View File

@ -1,11 +1,8 @@
import { import { Injectable } from '@nestjs/common';
ConflictException,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { DatabaseService } from '../database/database.service'; import { DatabaseService } from '../database/database.service';
import { Prisma } from '../generated/prisma/client'; import { Prisma } from '../generated/prisma/client';
import { readPrincipal } from './session.store'; import { readPrincipal } from './session.store';
import { AppError } from '../common/errors/app-error';
import type { Principal } from './identity.types'; import type { Principal } from './identity.types';
import type { Permission } from './permissions'; import type { Permission } from './permissions';
@ -14,20 +11,24 @@ export class AccessStore {
constructor(private readonly db: DatabaseService) {} constructor(private readonly db: DatabaseService) {}
async mutate<T>( async mutate<T>(
actor: Principal, actor: Principal,
permission: Permission, permission: Permission | null,
work: (tx: Prisma.TransactionClient, current: Principal) => Promise<T>, work: (tx: Prisma.TransactionClient, current: Principal) => Promise<T>,
lockOrganization = true,
): Promise<T> { ): Promise<T> {
try { try {
return await this.db.$transaction(async (tx) => { return await this.db.$transaction(async (tx) => {
// Serialize administration within an organization and recheck permissions after locking. if (lockOrganization) {
await tx.$queryRaw`SELECT id FROM organizations WHERE id = ${actor.organizationId}::uuid FOR UPDATE`; await tx.$queryRaw`SELECT id FROM organizations WHERE id = ${actor.organizationId}::uuid FOR UPDATE`;
}
const current = await readPrincipal(tx, actor.sessionId); const current = await readPrincipal(tx, actor.sessionId);
if ( if (
current.organizationId !== actor.organizationId || 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); return work(tx, current);
}); });
} catch (error) { } catch (error) {
@ -35,9 +36,7 @@ export class AccessStore {
error instanceof Prisma.PrismaClientKnownRequestError && error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002' error.code === 'P2002'
) { ) {
throw new ConflictException( throw new AppError('RECORD_CONFLICT');
'A record with these details already exists',
);
} }
throw error; throw error;
} }

View File

@ -1,6 +1,7 @@
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { ENVIRONMENT } from '../config/environment.module'; import { ENVIRONMENT } from '../config/environment.module';
import type { Environment } from '../config/environment'; import type { Environment } from '../config/environment';
import { AppError } from '../common/errors/app-error';
import { AuthStore } from './auth.store'; import { AuthStore } from './auth.store';
import { PasswordService } from './password.service'; import { PasswordService } from './password.service';
import { RateLimitService } from './rate-limit.service'; import { RateLimitService } from './rate-limit.service';
@ -22,12 +23,18 @@ export class AuthService {
900, 900,
); );
const user = await this.store.findUser(input.organizationId, input.email); const user = await this.store.findUser(input.organizationId, input.email);
const valid = user let valid = false;
? await this.passwords.verify(input.password, user.passwordHash) if (user)
: (await this.passwords.dummyVerify(input.password), false); valid = await this.passwords.verify(input.password, user.passwordHash);
else await this.passwords.dummyVerify(input.password);
if (!valid || !user || user.status !== 'ACTIVE') { if (!valid || !user || user.status !== 'ACTIVE') {
if (user) await this.store.failedLogin(user.organizationId, user.id); 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 { token, tokenHash } = issueToken();
const expiresAt = new Date( const expiresAt = new Date(

View File

@ -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 { DatabaseService } from '../database/database.service';
import { recordAudit } from './audit'; import { recordAudit } from './audit';
import type { Principal } from './identity.types'; 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`; await tx.$queryRaw`SELECT id FROM users WHERE id = ${userId}::uuid FOR UPDATE`;
const user = await tx.user.findUniqueOrThrow({ where: { id: userId } }); const user = await tx.user.findUniqueOrThrow({ where: { id: userId } });
if (user.status !== 'ACTIVE' || user.passwordHash !== expectedHash) 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 tx.session.create({ data: { userId, tokenHash, expiresAt } });
await recordAudit( await recordAudit(
tx, tx,

View File

@ -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 { DatabaseService } from '../database/database.service';
import { PasswordService } from './password.service'; import { PasswordService } from './password.service';
import { PERMISSIONS } from './permissions'; import { PERMISSIONS } from './permissions';
@ -17,7 +18,7 @@ export class BootstrapService {
// One-time installation bootstrap, serialized across processes. // One-time installation bootstrap, serialized across processes.
await tx.$queryRaw`SELECT pg_advisory_xact_lock(74192001)::text`; await tx.$queryRaw`SELECT pg_advisory_xact_lock(74192001)::text`;
if (await tx.user.count({ where: { isOwner: true } })) 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({ const organization = await tx.organization.create({
data: { name: organizationName }, data: { name: organizationName },
}); });

View File

@ -46,6 +46,6 @@ import { UsersController } from './users.controller';
UserStore, UserStore,
{ provide: APP_GUARD, useClass: AccessGuard }, { provide: APP_GUARD, useClass: AccessGuard },
], ],
exports: [BootstrapService], exports: [BootstrapService, AccessStore, RateLimitService],
}) })
export class IdentityModule {} export class IdentityModule {}

View File

@ -6,5 +6,13 @@ export const PERMISSIONS = [
'roles.read', 'roles.read',
'roles.manage', 'roles.manage',
'audit.read', 'audit.read',
'catalog.read',
'catalog.manage',
'catalog.publish',
'inventory.read',
'inventory.manage',
'inventory.adjust',
'inventory.reserve',
'inventory.commit',
] as const; ] as const;
export type Permission = (typeof PERMISSIONS)[number]; export type Permission = (typeof PERMISSIONS)[number];

View File

@ -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 { DatabaseService } from '../database/database.service';
import { hashToken } from './tokens'; import { hashToken } from './tokens';
@ -7,19 +8,23 @@ export class RateLimitService {
constructor(private readonly db: DatabaseService) {} constructor(private readonly db: DatabaseService) {}
async consume(key: string, limit: number, seconds: number): Promise<void> { async consume(key: string, limit: number, seconds: number): Promise<void> {
const digest = hashToken(key); const digest = hashToken(key);
const [row] = await this.db.$queryRaw<Array<{ hits: number }>>` const [row] = await this.db.$queryRaw<
Array<{ hits: number; expiresAt: Date }>
>`
INSERT INTO rate_limits (key, hits, expires_at) INSERT INTO rate_limits (key, hits, expires_at)
VALUES (${digest}, 1, NOW() + ${seconds} * INTERVAL '1 second') VALUES (${digest}, 1, NOW() + ${seconds} * INTERVAL '1 second')
ON CONFLICT (key) DO UPDATE SET ON CONFLICT (key) DO UPDATE SET
hits = CASE WHEN rate_limits.expires_at <= NOW() THEN 1 ELSE rate_limits.hits + 1 END, 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() expires_at = CASE WHEN rate_limits.expires_at <= NOW()
THEN NOW() + ${seconds} * INTERVAL '1 second' ELSE rate_limits.expires_at END THEN NOW() + ${seconds} * INTERVAL '1 second' ELSE rate_limits.expires_at END
RETURNING hits RETURNING hits, expires_at AS "expiresAt"
`; `;
if (row.hits > limit) if (row.hits > limit)
throw new HttpException( throw new AppError(
'Too many requests', 'RATE_LIMITED',
HttpStatus.TOO_MANY_REQUESTS, key.split(':')[0],
undefined,
Math.max(1, Math.ceil((row.expiresAt.getTime() - Date.now()) / 1000)),
); );
} }
} }

View File

@ -1,8 +1,5 @@
import { import { AppError } from '../common/errors/app-error';
Inject, import { Inject, Injectable } from '@nestjs/common';
Injectable,
ServiceUnavailableException,
} from '@nestjs/common';
import { createTransport } from 'nodemailer'; import { createTransport } from 'nodemailer';
import { ENVIRONMENT } from '../config/environment.module'; import { ENVIRONMENT } from '../config/environment.module';
import type { Environment } from '../config/environment'; import type { Environment } from '../config/environment';
@ -11,10 +8,7 @@ import type { Environment } from '../config/environment';
export class RecoveryMailer { export class RecoveryMailer {
constructor(@Inject(ENVIRONMENT) private readonly env: Environment) {} constructor(@Inject(ENVIRONMENT) private readonly env: Environment) {}
assertConfigured(): void { assertConfigured(): void {
if (!this.env.SMTP_HOST) if (!this.env.SMTP_HOST) throw new AppError('RECOVERY_UNAVAILABLE');
throw new ServiceUnavailableException(
'Password recovery is not configured',
);
} }
async send(email: string, token: string): Promise<void> { async send(email: string, token: string): Promise<void> {
this.assertConfigured(); this.assertConfigured();

View File

@ -41,7 +41,12 @@ export class RecoveryService {
} catch { } catch {
await this.store.discard(tokenHash); await this.store.discard(tokenHash);
// SMTP errors may contain credentials and addresses; do not log the raw error. // 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<void> { async reset(token: string, password: string): Promise<void> {

View File

@ -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 { DatabaseService } from '../database/database.service';
import { recordAudit } from './audit'; import { recordAudit } from './audit';
@ -32,8 +33,7 @@ export class RecoveryStore {
async reset(tokenHash: string, passwordHash: string) { async reset(tokenHash: string, passwordHash: string) {
await this.db.$transaction(async (tx) => { await this.db.$transaction(async (tx) => {
const token = await tx.recoveryToken.findUnique({ where: { tokenHash } }); const token = await tx.recoveryToken.findUnique({ where: { tokenHash } });
if (!token) if (!token) throw new AppError('RECOVERY_INVALID');
throw new BadRequestException('Invalid or expired recovery token');
await tx.$queryRaw`SELECT id FROM users WHERE id = ${token.userId}::uuid FOR UPDATE`; await tx.$queryRaw`SELECT id FROM users WHERE id = ${token.userId}::uuid FOR UPDATE`;
const user = await tx.user.findUniqueOrThrow({ const user = await tx.user.findUniqueOrThrow({
where: { id: token.userId }, where: { id: token.userId },
@ -42,7 +42,7 @@ export class RecoveryStore {
where: { id: token.id, expiresAt: { gt: new Date() } }, where: { id: token.id, expiresAt: { gt: new Date() } },
}); });
if (consumed.count !== 1 || user.status !== 'ACTIVE') { 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.user.update({ where: { id: user.id }, data: { passwordHash } });
await tx.recoveryToken.deleteMany({ where: { userId: user.id } }); await tx.recoveryToken.deleteMany({ where: { userId: user.id } });

View File

@ -1,8 +1,5 @@
import { import { AppError } from '../common/errors/app-error';
ForbiddenException, import { Injectable } from '@nestjs/common';
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DatabaseService } from '../database/database.service'; import { DatabaseService } from '../database/database.service';
import { AccessStore } from './access.store'; import { AccessStore } from './access.store';
import { recordAudit } from './audit'; import { recordAudit } from './audit';
@ -13,7 +10,7 @@ function ensureGrantable(actor: Principal, permissions: string[]) {
if ( if (
permissions.some((permission) => !actor.permissions.includes(permission)) permissions.some((permission) => !actor.permissions.includes(permission))
) { ) {
throw new ForbiddenException('Cannot grant permissions you do not hold'); throw new AppError('ROLE_GRANT_DENIED');
} }
} }
@Injectable() @Injectable()
@ -37,9 +34,8 @@ export class RoleStore {
const role = await tx.role.findFirst({ const role = await tx.role.findFirst({
where: { id, organizationId: actor.organizationId }, where: { id, organizationId: actor.organizationId },
}); });
if (!role) throw new NotFoundException(); if (!role) throw new AppError('ROLE_NOT_FOUND');
if (role.isSystem) if (role.isSystem) throw new AppError('ROLE_IMMUTABLE');
throw new ForbiddenException('System role is immutable');
ensureGrantable(current, role.permissions); ensureGrantable(current, role.permissions);
} }
const role = id const role = id
@ -66,9 +62,9 @@ export class RoleStore {
where: { id: userId, organizationId: actor.organizationId }, where: { id: userId, organizationId: actor.organizationId },
include: { roles: { include: { role: true } } }, 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) if (user.isOwner || user.id === current.userId)
throw new ForbiddenException('Cannot change these role assignments'); throw new AppError('ROLE_ASSIGNMENT_DENIED');
ensureGrantable( ensureGrantable(
current, current,
user.roles.flatMap((assignment) => assignment.role.permissions), user.roles.flatMap((assignment) => assignment.role.permissions),
@ -76,9 +72,10 @@ export class RoleStore {
const roles = await tx.role.findMany({ const roles = await tx.role.findMany({
where: { id: { in: roleIds }, organizationId: actor.organizationId }, 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)) if (roles.some((role) => role.isSystem))
throw new ForbiddenException('System role cannot be assigned'); throw new AppError('ROLE_SYSTEM_DENIED');
ensureGrantable( ensureGrantable(
current, current,
roles.flatMap((role) => role.permissions), roles.flatMap((role) => role.permissions),

View File

@ -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 { DatabaseService } from '../database/database.service';
import type { Prisma } from '../generated/prisma/client'; import type { Prisma } from '../generated/prisma/client';
import { hashToken } from './tokens'; import { hashToken } from './tokens';
@ -18,7 +19,7 @@ export async function readPrincipal(
session.expiresAt <= new Date() || session.expiresAt <= new Date() ||
session.user.status !== 'ACTIVE' session.user.status !== 'ACTIVE'
) { ) {
throw new UnauthorizedException(); throw new AppError('SESSION_INVALID');
} }
return { return {
userId: session.userId, userId: session.userId,
@ -36,7 +37,7 @@ export class SessionStore {
const session = await this.db.session.findUnique({ const session = await this.db.session.findUnique({
where: { tokenHash: hashToken(token) }, where: { tokenHash: hashToken(token) },
}); });
if (!session) throw new UnauthorizedException(); if (!session) throw new AppError('SESSION_INVALID');
return readPrincipal(this.db, session.id); return readPrincipal(this.db, session.id);
} }
} }

View File

@ -1,8 +1,5 @@
import { import { AppError } from '../common/errors/app-error';
ForbiddenException, import { Injectable } from '@nestjs/common';
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DatabaseService } from '../database/database.service'; import { DatabaseService } from '../database/database.service';
import { AccessStore } from './access.store'; import { AccessStore } from './access.store';
import { PasswordService } from './password.service'; import { PasswordService } from './password.service';
@ -66,9 +63,9 @@ export class UserStore {
const user = await tx.user.findFirst({ const user = await tx.user.findFirst({
where: { id, organizationId: actor.organizationId }, 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) 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({ const updated = await tx.user.update({
where: { id }, where: { id },
data: { status }, data: { status },

View File

@ -77,7 +77,7 @@ describe('administration use-case policy', () => {
it('hides users outside the organization scope', async () => { it('hides users outside the organization scope', async () => {
tx.user.findFirst.mockResolvedValue(null); tx.user.findFirst.mockResolvedValue(null);
await expect(users.setStatus(actor, 'foreign', 'ACTIVE')).rejects.toThrow( await expect(users.setStatus(actor, 'foreign', 'ACTIVE')).rejects.toThrow(
'Not Found', /not found/i,
); );
expect(tx.user.update).not.toHaveBeenCalled(); expect(tx.user.update).not.toHaveBeenCalled();
}); });
@ -105,7 +105,7 @@ describe('administration use-case policy', () => {
tx.role.findFirst.mockResolvedValue(null); tx.role.findFirst.mockResolvedValue(null);
await expect( await expect(
roles.save(actor, { name: 'Changed', permissions: [] }, 'foreign'), 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 () => { it('does not remove a target user permissions the actor cannot grant', async () => {
tx.user.findFirst.mockResolvedValue({ tx.user.findFirst.mockResolvedValue({
@ -121,7 +121,7 @@ describe('administration use-case policy', () => {
it('hides missing assignment targets', async () => { it('hides missing assignment targets', async () => {
tx.user.findFirst.mockResolvedValue(null); tx.user.findFirst.mockResolvedValue(null);
await expect(roles.assign(actor, 'foreign', [])).rejects.toThrow( await expect(roles.assign(actor, 'foreign', [])).rejects.toThrow(
'Not Found', /not found/i,
); );
}); });
}); });

109
test/error-handling.spec.ts Normal file
View File

@ -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');
});
});

View File

@ -16,6 +16,7 @@ export async function identityApp() {
const env = parseEnvironment({ const env = parseEnvironment({
DATABASE_URL: database.connectionUrl, DATABASE_URL: database.connectionUrl,
NODE_ENV: 'test', NODE_ENV: 'test',
DATABASE_POOL_SIZE: process.env.TEST_DATABASE_URL ? '10' : '1',
}); });
const mailer = { const mailer = {
assertConfigured: jest.fn(), assertConfigured: jest.fn(),
@ -28,6 +29,7 @@ export async function identityApp() {
.useValue(mailer) .useValue(mailer)
.compile(); .compile();
const app = module.createNestApplication(); const app = module.createNestApplication();
app.useLogger(false);
configureApp(app, env); configureApp(app, env);
await app.init(); await app.init();
const db = app.get(DatabaseService); const db = app.get(DatabaseService);
@ -51,6 +53,7 @@ export async function identityApp() {
app, app,
db, db,
executeSql: database.execute,
owner, owner,
token, token,
api, api,

View File

@ -58,7 +58,8 @@ describe('authentication with migrated PostgreSQL engine', () => {
const missing = await ctx.login('missing@example.com'); const missing = await ctx.login('missing@example.com');
const wrong = await ctx.login('owner@example.com', 'wrong password'); const wrong = await ctx.login('owner@example.com', 'wrong password');
expect(missing.status).toBe(401); 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 () => { it('validates payloads without echoing secrets and rejects mass assignment', async () => {
const response = await ctx const response = await ctx

View File

@ -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',
);
});
});