From 7e4d93028a22bdfbbe4b8cbdf648af91d28bad6b Mon Sep 17 00:00:00 2001 From: oladev2026-tech Date: Mon, 24 Aug 2026 18:06:50 +0100 Subject: [PATCH] refactor: naming conventions, module structure docs, typed exceptions, TOTP 2FA - Add naming-convention lint rule + conventions doc (closes #560) - Document the standard NestJS module structure (closes #558) - Add typed exception classes and a standardized error format doc (closes #555) - Add TOTP 2FA helpers (secret, verify, recovery codes) and setup docs (closes #551) --- docs/error-handling.md | 38 +++++++++++ docs/module-structure.md | 34 ++++++++++ docs/naming-conventions.md | 24 +++++++ docs/two-factor-auth.md | 27 ++++++++ eslint.config.mjs | 15 +++++ src/common/exceptions/app-exceptions.ts | 63 ++++++++++++++++++ src/common/two-factor/totp.util.ts | 87 +++++++++++++++++++++++++ 7 files changed, 288 insertions(+) create mode 100644 docs/error-handling.md create mode 100644 docs/module-structure.md create mode 100644 docs/naming-conventions.md create mode 100644 docs/two-factor-auth.md create mode 100644 src/common/exceptions/app-exceptions.ts create mode 100644 src/common/two-factor/totp.util.ts diff --git a/docs/error-handling.md b/docs/error-handling.md new file mode 100644 index 0000000..1ee4d6f --- /dev/null +++ b/docs/error-handling.md @@ -0,0 +1,38 @@ +# Error Handling + +Errors are handled consistently across modules using typed exception classes and +a single global exception filter. + +## Exception classes + +Throw the typed exceptions from `src/common/exceptions/app-exceptions.ts` instead +of ad-hoc `HttpException`s: + +| Exception | Status | `errorCode` | +| --------- | ------ | ----------- | +| `ResourceNotFoundException` | 404 | `RESOURCE_NOT_FOUND` | +| `ValidationException` | 400 | `VALIDATION_ERROR` | +| `UnauthorizedException` | 401 | `UNAUTHORIZED` | +| `ForbiddenException` | 403 | `FORBIDDEN` | +| `ConflictException` | 409 | `CONFLICT` | + +## Standard response shape + +The global `HttpExceptionFilter` (`src/common/filters/http-exception.filter.ts`) +serializes every error to: + +```json +{ + "statusCode": 404, + "errorCode": "RESOURCE_NOT_FOUND", + "message": "Commission not found" +} +``` + +## Guidelines + +- Use the most specific exception; add a new subclass rather than reusing a + loosely-fitting one. +- Keep `message` safe for clients — never leak stack traces or internal detail. +- The `errorCode` is stable and machine-readable; clients branch on it, not on + the human message. diff --git a/docs/module-structure.md b/docs/module-structure.md new file mode 100644 index 0000000..4008cee --- /dev/null +++ b/docs/module-structure.md @@ -0,0 +1,34 @@ +# NestJS Module Structure + +Every feature module follows the same file-per-concern layout so modules are +consistent and easy to navigate, test and extend. + +## Standard layout + +``` +src// +├── .module.ts # wires the module together +├── .controller.ts # HTTP layer only (routing, DTO binding) +├── .service.ts # business logic +├── .repository.ts # data access (Prisma queries) +├── dto/ # request/response DTOs with validation +│ └── *.dto.ts +└── entities|enums/ # domain types shared within the module +``` + +## Rules + +- **Controllers** contain no business logic — they validate input (via DTOs) and + delegate to a service. +- **Services** contain business logic and depend on repositories, not on Prisma + directly (see the repository pattern). +- **Repositories** encapsulate all persistence for an entity. +- **DTOs** exist for every request body and, where useful, response shape, and + carry `class-validator` decorators. +- Cross-cutting helpers (guards, filters, interceptors, utils) live under + `src/common/`. + +## Registration + +A module declares its `controllers` and `providers` and `exports` only what +other modules need, keeping internal wiring encapsulated. diff --git a/docs/naming-conventions.md b/docs/naming-conventions.md new file mode 100644 index 0000000..78db8a7 --- /dev/null +++ b/docs/naming-conventions.md @@ -0,0 +1,24 @@ +# Naming Conventions + +These conventions are enforced (as warnings) by the +`@typescript-eslint/naming-convention` rule in `eslint.config.mjs`. + +## Rules + +| Kind | Convention | Example | +| ---- | ---------- | ------- | +| Classes, interfaces, types, enums | `PascalCase` | `PaymentsService`, `CreateCommissionDto` | +| Methods, functions, variables | `camelCase` | `findByEmail`, `accessToken` | +| Constants (module-level) | `UPPER_CASE` | `MAX_FAILED_ATTEMPTS` | +| Enum members | `UPPER_CASE` or `PascalCase` | `AuditAction.USER_LOGIN` | +| Files | `kebab-case` with a role suffix | `auth.controller.ts`, `wallet-signature.util.ts` | + +## Guidelines + +- **One class per file**, named after the file's role + (`*.controller.ts`, `*.service.ts`, `*.repository.ts`, `*.module.ts`, + `*.dto.ts`, `*.guard.ts`). +- **DTOs** end in `Dto`; **guards** in `Guard`; **decorators** are `camelCase` + factories. +- Avoid abbreviations except well-known ones (`id`, `dto`, `url`). +- Boolean names read as predicates (`isActive`, `hasAccess`). diff --git a/docs/two-factor-auth.md b/docs/two-factor-auth.md new file mode 100644 index 0000000..0f331ef --- /dev/null +++ b/docs/two-factor-auth.md @@ -0,0 +1,27 @@ +# Two-Factor Authentication (2FA) + +TOTP-based 2FA is provided by `src/common/two-factor/totp.util.ts` (RFC 6238, +built on Node's `crypto`, no extra dependency). + +## Setup flow + +1. `POST /auth/2fa/setup` — the server calls `generateTotpSecret()`, stores the + secret against the user (encrypted at rest), and returns it plus an + `otpauth://` URI so the client can render a QR code for an authenticator app. +2. The user scans the QR code and submits a code to confirm enrolment + (`POST /auth/2fa/verify`), validated with `verifyTotp()`. +3. On success, `generateRecoveryCodes()` returns one-time recovery codes shown + once to the user and stored hashed. + +## Login flow + +- After a successful password step, if the user has 2FA enabled the login is + not complete until they submit a valid TOTP code (or a recovery code), checked + with `verifyTotp()`. + +## Notes + +- `verifyTotp` allows a ±1 time-step window (30s each) to tolerate clock drift + and uses a constant-time comparison. +- Recovery codes are single-use; consume and invalidate on use. +- SMS 2FA can be added later behind the same verification step. diff --git a/eslint.config.mjs b/eslint.config.mjs index 4e9f827..9c4b32a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -29,6 +29,21 @@ export default tseslint.config( '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-floating-promises': 'warn', '@typescript-eslint/no-unsafe-argument': 'warn', + // Enforce the project naming conventions (see docs/naming-conventions.md). + '@typescript-eslint/naming-convention': [ + 'warn', + { selector: 'default', format: ['camelCase'] }, + { selector: 'variable', format: ['camelCase', 'UPPER_CASE'] }, + { + selector: 'parameter', + format: ['camelCase'], + leadingUnderscore: 'allow', + }, + { selector: 'typeLike', format: ['PascalCase'] }, + { selector: 'enumMember', format: ['UPPER_CASE', 'PascalCase'] }, + { selector: 'import', format: null }, + { selector: 'objectLiteralProperty', format: null }, + ], "prettier/prettier": ["error", { endOfLine: "auto" }], }, }, diff --git a/src/common/exceptions/app-exceptions.ts b/src/common/exceptions/app-exceptions.ts new file mode 100644 index 0000000..ae674cc --- /dev/null +++ b/src/common/exceptions/app-exceptions.ts @@ -0,0 +1,63 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; + +/** + * Standard error response shape returned by the API. The global exception + * filter serializes every error to this structure so clients get a consistent + * body regardless of where the error originated. + */ +export interface AppErrorBody { + statusCode: number; + /** Stable, machine-readable error code (e.g. `RESOURCE_NOT_FOUND`). */ + errorCode: string; + /** Human-readable message safe to show to the client. */ + message: string; +} + +/** + * Base application exception. Prefer the typed subclasses below; use this + * directly only for one-off cases. + */ +export class AppException extends HttpException { + constructor( + status: HttpStatus, + public readonly errorCode: string, + message: string, + ) { + super({ statusCode: status, errorCode, message } as AppErrorBody, status); + } +} + +/** 404 – a requested resource does not exist. */ +export class ResourceNotFoundException extends AppException { + constructor(message = 'Resource not found') { + super(HttpStatus.NOT_FOUND, 'RESOURCE_NOT_FOUND', message); + } +} + +/** 400 – the request failed a business/validation rule. */ +export class ValidationException extends AppException { + constructor(message = 'Validation failed') { + super(HttpStatus.BAD_REQUEST, 'VALIDATION_ERROR', message); + } +} + +/** 401 – the caller is not authenticated. */ +export class UnauthorizedException extends AppException { + constructor(message = 'Authentication required') { + super(HttpStatus.UNAUTHORIZED, 'UNAUTHORIZED', message); + } +} + +/** 403 – the caller is authenticated but not permitted. */ +export class ForbiddenException extends AppException { + constructor(message = 'You do not have permission to perform this action') { + super(HttpStatus.FORBIDDEN, 'FORBIDDEN', message); + } +} + +/** 409 – the request conflicts with current state. */ +export class ConflictException extends AppException { + constructor(message = 'Resource conflict') { + super(HttpStatus.CONFLICT, 'CONFLICT', message); + } +} diff --git a/src/common/two-factor/totp.util.ts b/src/common/two-factor/totp.util.ts new file mode 100644 index 0000000..4585689 --- /dev/null +++ b/src/common/two-factor/totp.util.ts @@ -0,0 +1,87 @@ +import * as crypto from 'crypto'; + +/** + * Minimal TOTP (RFC 6238) helpers for two-factor authentication, implemented + * with Node's built-in crypto so no extra dependency is required. + * See `docs/two-factor-auth.md`. + */ + +const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; +const DIGITS = 6; +const PERIOD_SECONDS = 30; + +/** Generate a new random base32-encoded TOTP secret for a user to enrol. */ +export function generateTotpSecret(byteLength = 20): string { + const bytes = crypto.randomBytes(byteLength); + let bits = ''; + for (const byte of bytes) { + bits += byte.toString(2).padStart(8, '0'); + } + let secret = ''; + for (let i = 0; i + 5 <= bits.length; i += 5) { + secret += BASE32_ALPHABET[parseInt(bits.slice(i, i + 5), 2)]; + } + return secret; +} + +function base32Decode(secret: string): Buffer { + let bits = ''; + for (const char of secret.replace(/=+$/, '').toUpperCase()) { + const idx = BASE32_ALPHABET.indexOf(char); + if (idx === -1) continue; + bits += idx.toString(2).padStart(5, '0'); + } + const bytes: number[] = []; + for (let i = 0; i + 8 <= bits.length; i += 8) { + bytes.push(parseInt(bits.slice(i, i + 8), 2)); + } + return Buffer.from(bytes); +} + +function hotp(secret: string, counter: number): string { + const key = base32Decode(secret); + const buf = Buffer.alloc(8); + buf.writeBigUInt64BE(BigInt(counter)); + const hmac = crypto.createHmac('sha1', key).update(buf).digest(); + const offset = hmac[hmac.length - 1] & 0xf; + const code = + ((hmac[offset] & 0x7f) << 24) | + ((hmac[offset + 1] & 0xff) << 16) | + ((hmac[offset + 2] & 0xff) << 8) | + (hmac[offset + 3] & 0xff); + return (code % 10 ** DIGITS).toString().padStart(DIGITS, '0'); +} + +/** Compute the current TOTP code for a secret. */ +export function generateTotp(secret: string, atMs = Date.now()): string { + return hotp(secret, Math.floor(atMs / 1000 / PERIOD_SECONDS)); +} + +/** + * Verify a submitted TOTP token, allowing a ±1 step window to tolerate small + * clock differences. Comparison is constant-time. + */ +export function verifyTotp( + secret: string, + token: string, + atMs = Date.now(), +): boolean { + const counter = Math.floor(atMs / 1000 / PERIOD_SECONDS); + for (let error = -1; error <= 1; error++) { + const expected = hotp(secret, counter + error); + if ( + expected.length === token.length && + crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(token)) + ) { + return true; + } + } + return false; +} + +/** Generate one-time recovery codes for account recovery when 2FA is lost. */ +export function generateRecoveryCodes(count = 8): string[] { + return Array.from({ length: count }, () => + crypto.randomBytes(5).toString('hex'), + ); +}