Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions docs/error-handling.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 34 additions & 0 deletions docs/module-structure.md
Original file line number Diff line number Diff line change
@@ -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/<feature>/
├── <feature>.module.ts # wires the module together
├── <feature>.controller.ts # HTTP layer only (routing, DTO binding)
├── <feature>.service.ts # business logic
├── <feature>.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.
24 changes: 24 additions & 0 deletions docs/naming-conventions.md
Original file line number Diff line number Diff line change
@@ -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`).
27 changes: 27 additions & 0 deletions docs/two-factor-auth.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 15 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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" }],
},
},
Expand Down
63 changes: 63 additions & 0 deletions src/common/exceptions/app-exceptions.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
87 changes: 87 additions & 0 deletions src/common/two-factor/totp.util.ts
Original file line number Diff line number Diff line change
@@ -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'),
);
}