diff --git a/CLAUDE.md b/CLAUDE.md index e70def7..6719dc3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,6 +36,10 @@ The client is generated into `prisma/generated/` (generator `prisma-client`, not Read from `.env` (`@nestjs/config`, global): `DATABASE_URL` (used by Prisma and `PrismaService`), `HIBOUTIK_BASE_URL`, `HIBOUTIK_USERNAME`, `HIBOUTIK_PASSWORD` (HTTP Basic auth). HTTP calls to Hiboutik time out after 30 s (`HttpModule.register` in `AppModule`). +The environment is validated at startup (`src/config/env.validation.ts`, wired in `ConfigModule.forRoot`): a missing or malformed required variable stops the app, and `HIBOUTIK_BASE_URL` must be HTTPS (Basic auth). Optional variables: `PORT`, `CORS_ORIGINS` (comma-separated; CORS stays disabled when unset) and `TRUST_PROXY` (number of reverse proxies, so rate limiting sees the real client IP). Any new variable goes in `EnvironmentVariables`; the e2e test gets dummy values from `test/setup-env.ts`. + +Security defaults in `main.ts`/`AppModule`: Helmet, global `ValidationPipe`, `ThrottlerGuard` on every route (60 req/min/IP; `@Throttle` for stricter limits, e.g. `POST /sync` at 5/min), `AllExceptionsFilter` (unexpected errors are logged with message + stack and answered with a generic 500; deliberate `HttpException`s and 4xx HTTP-layer errors pass through), HTTP header/request timeouts (15 s / 30 s), shutdown hooks. Long work must run in the background (like `POST /sync`), never inside a request. + ## Architecture Everything is registered as providers in a single `AppModule` (`src/app.module.ts`); new repositories/converters/services must be added there. `PrismaModule` (in `prisma/`, outside `src/`) is global. @@ -50,7 +54,7 @@ Sync pipeline, one entity per layer: Domain notes: - Synced models carry a unique `hiboutikId` (join key with Hiboutik), except `Order` (nullable `hiboutikSaleId`), `ProductVersion` (unique `[productId, hiboutikSizeId]`) and `OrderLine`; the internal `id` is a UUID. Empty Hiboutik barcodes are stored as `null`. `ProductVersion.barcode` is indexed but **not unique** (Hiboutik may reuse a barcode). -- Hiboutik is the source of truth: brands, categories, suppliers, products and customers missing from it are deleted (skipped if Hiboutik returns an empty list). Products are deleted before the upserts (so a recreated product can reuse its barcode); their versions go by cascade, and versions missing from a product are deleted before its versions are upserted. Customer addresses go by cascade with their customer; other relations (product brand/category/supplier, sub-categories, order customer) are set to null and orders are kept. +- Hiboutik is the source of truth: brands, categories, suppliers, products and customers missing from it are deleted. Hiboutik is trusted: a 2xx answer is the truth, even an empty list (everything is then deleted). Deletion only requires that the call did not fail: `HiboutikClient` throws on HTTP errors (4xx/5xx) and `getList`/`getAllPages` also throw on a 2xx body that is not a list (a 2xx without body is an empty list), so a failed step deletes nothing. List endpoints must go through `getList`/`getAllPages`, never a bare `get`. Products are deleted before the upserts (so a recreated product can reuse its barcode); their versions go by cascade, and versions missing from a product are deleted before its versions are upserted. Customer addresses go by cascade with their customer; other relations (product brand/category/supplier, sub-categories, order customer) are set to null and orders are kept. - Brands, categories and suppliers are upserted through `forEachSafely` (`src/utils/sync.ts`): a failing row is logged and skipped. Each sync step and each product/customer is in a try/catch: failures are logged, the sync goes on, and a summary is logged at the end. - Categories form a tree: parents are linked in a second pass (`setParent`). `enabled` mirrors `category_enabled` (POS), `webEnabled` mirrors `category_enabled_www`. - Products: `ProductVersion` rows come from `product_size_details` (barcode, price/discount overrides from `product_specific_rules`, stock from the stocks endpoint); no sizes means one version with `hiboutikSizeId: 0`. `globalStock` is the sum of available stock. `archived` mirrors `product_arch`. @@ -59,6 +63,20 @@ Domain notes: - Orders come from the customer's `sales`: sales already stored as `COMPLETED` are skipped, the others are fetched and upserted on `hiboutikSaleId` (`source: STORE` on creation only). `status` is `COMPLETED` or `PENDING`, `orderedAt` is the completion date else the creation date. Lines are replaced on each upsert and snapshot name, version, barcode, price and Hiboutik ids; the `ProductVersion` link is optional (`SetNull`). `unitPrice` is the paid price, taxes and discount included. Quotations are skipped, anonymous sales are never synced (only customers' sales are read), and orders are never deleted. - Shared Prisma payload types (e.g. `ProductWithVersions`) live in `prisma/customObjects/`. +## Input validation + +All data coming from API clients (`@Body()`, `@Query()`, `@Param()`) must be validated with NestJS `ValidationPipe` and `class-validator` DTO classes. + +- Register `ValidationPipe` globally in `main.ts` with `whitelist: true`, `forbidNonWhitelisted: true` and `transform: true` (no `if` checks by hand in controllers/services). +- Every endpoint that reads a body, query string or route param takes a dedicated input DTO class decorated with `class-validator` rules (type, length, range, format). Cap pagination (`limit`/`take`) with `@Max`. For nested payloads use `@ValidateNested()` + `@Type()`. +- Never use Prisma models or Hiboutik DTOs as input types: the `src/models/hiboutikDTO/` types describe data coming from Hiboutik, not from API clients. + +## Security rules + +- **Logs**: never log a raw error object, a request/response or an axios config: they carry the Hiboutik `Authorization` header (credentials). Log `(error as Error).message` and `.stack` only, as the sync services do. No personal data (emails, names, addresses, phone numbers, tax numbers) in logs: ids only. +- **SQL**: use the Prisma query API. `$queryRaw`/`$executeRaw` are allowed only as tagged templates with parameters (`` $queryRaw`... ${value}` ``); never `$queryRawUnsafe`/`$executeRawUnsafe` nor string concatenation or interpolation into SQL text. +- **Sync lock**: only one synchronization runs at a time across all instances. `HiboutikService.startSync` takes a Postgres advisory lock (`SyncLockDatabaseRepository`, dedicated `pg` connection held for the whole sync, released by Postgres itself if the process dies). Start syncs only through `startSync`, never call `syncAll` directly from a controller or a scheduler. + ## API If the Postman plugin is installed, load its `postman:api-engineer` skill before any non-trivial API engineering task (designing, implementing, mocking, testing, monitoring, documenting, deploying). Pure questions and trivial one-line edits do not need it. The local `postman/` and `.postman/` folders are generated by `postman init` and are git-ignored. diff --git a/package-lock.json b/package-lock.json index ca89bb4..99b44f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,9 +15,13 @@ "@nestjs/config": "^12.0.0", "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.0.1", + "@nestjs/throttler": "^6.7.1", "@prisma/adapter-pg": "^7.10.0", "@prisma/client": "^7.10.0", "axios": "1.20.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.15.1", + "helmet": "^8.3.0", "pg": "^8.23.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" @@ -50,49 +54,10 @@ "typescript-eslint": "^8.20.0" } }, - "node_modules/@angular-devkit/schematics": { - "version": "22.2.3", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-22.2.3.tgz", - "integrity": "sha512-+XfBViIGqiFQK8KJXA6PgANX9tLuMO/x8GFjlzEmA4LIe9ikAtpGZw+Ezo547BIJQoJBIKCJYR0knE4BmbFeSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@angular-devkit/core": "22.2.3", - "jsonc-parser": "3.3.1", - "magic-string": "1.4.1", - "ora": "9.4.1", - "rxjs": "7.8.2" - }, - "engines": { - "node": "^22.22.3 || ^24.15.0 || >=26.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - } - }, - "node_modules/@angular-devkit/schematics-cli": { - "version": "22.2.3", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics-cli/-/schematics-cli-22.2.3.tgz", - "integrity": "sha512-GebRkurUplPBwJOz/LT40ZZ0Hxv0EzpllJD1Oj7Bjkk/9A8PKGPDSRgEGfWcVk2h35V4JNNC0g6pSMj9s0dWxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@angular-devkit/core": "22.2.3", - "@angular-devkit/schematics": "22.2.3", - "@inquirer/prompts": "8.7.2" - }, - "bin": { - "schematics": "bin/schematics.js" - }, - "engines": { - "node": "^22.22.3 || ^24.15.0 || >=26.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - } - }, - "node_modules/@angular-devkit/schematics-cli/node_modules/@angular-devkit/core": { - "version": "22.2.3", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-22.2.3.tgz", - "integrity": "sha512-bi1oBjJeg1XCgRrBYSuJikaJV4sO/mcnyFE/0a1H646yHhQpVYoU/M2Vh69Qv8h+YoMfC7RuSxT4LZHaubhhLw==", + "node_modules/@angular-devkit/core": { + "version": "22.2.6", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-22.2.6.tgz", + "integrity": "sha512-OtFUe9fLXpHsqO6gCSbTWIaTO64MCrESRVigs9SAW4EzIagCAhgB0Y8GflTjmKGLvJjilBFJFd0JSIzNxLyifA==", "dev": true, "license": "MIT", "dependencies": { @@ -117,6 +82,92 @@ } } }, + "node_modules/@angular-devkit/core/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@angular-devkit/core/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@angular-devkit/core/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@angular-devkit/core/node_modules/source-map": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0.tgz", + "integrity": "sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/@angular-devkit/schematics": { + "version": "22.2.6", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-22.2.6.tgz", + "integrity": "sha512-XACkbAmAhBGDfxr8Uny2k0ErDwLpauseccNWdIFs4fiisGTqiCByzkH60/4xTmbV3FTj8bQ6cXYIhhaqe0MK1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "22.2.6", + "jsonc-parser": "3.3.1", + "magic-string": "1.4.1", + "ora": "9.4.1", + "rxjs": "7.8.2" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/schematics-cli": { + "version": "22.2.6", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics-cli/-/schematics-cli-22.2.6.tgz", + "integrity": "sha512-+/zAu8jAdB5Hd6a4hYwurdwalC9wv2ElyDeDPyWZf4b8vZRdIaZwoYvrK91ArQGWn9Y3DrV3i0fpxHY38L91Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "22.2.6", + "@angular-devkit/schematics": "22.2.6", + "@inquirer/prompts": "8.7.2" + }, + "bin": { + "schematics": "bin/schematics.js" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, "node_modules/@angular-devkit/schematics-cli/node_modules/@inquirer/prompts": { "version": "8.7.2", "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.7.2.tgz", @@ -147,98 +198,6 @@ } } }, - "node_modules/@angular-devkit/schematics-cli/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@angular-devkit/schematics-cli/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, - "node_modules/@angular-devkit/schematics-cli/node_modules/picomatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", - "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/@angular-devkit/schematics-cli/node_modules/source-map": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0.tgz", - "integrity": "sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">= 12" - } - }, - "node_modules/@angular-devkit/schematics/node_modules/@angular-devkit/core": { - "version": "22.2.3", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-22.2.3.tgz", - "integrity": "sha512-bi1oBjJeg1XCgRrBYSuJikaJV4sO/mcnyFE/0a1H646yHhQpVYoU/M2Vh69Qv8h+YoMfC7RuSxT4LZHaubhhLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "8.20.0", - "ajv-formats": "3.0.1", - "jsonc-parser": "3.3.1", - "picomatch": "4.0.7", - "rxjs": "7.8.2", - "source-map": "0.8.0" - }, - "engines": { - "node": "^22.22.3 || ^24.15.0 || >=26.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - }, - "peerDependencies": { - "chokidar": "^5.0.0" - }, - "peerDependenciesMeta": { - "chokidar": { - "optional": true - } - } - }, - "node_modules/@angular-devkit/schematics/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, "node_modules/@angular-devkit/schematics/node_modules/ansi-regex": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.5.0.tgz", @@ -320,13 +279,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@angular-devkit/schematics/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, "node_modules/@angular-devkit/schematics/node_modules/log-symbols": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", @@ -393,19 +345,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@angular-devkit/schematics/node_modules/picomatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", - "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/@angular-devkit/schematics/node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -423,16 +362,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@angular-devkit/schematics/node_modules/source-map": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0.tgz", - "integrity": "sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">= 12" - } - }, "node_modules/@angular-devkit/schematics/node_modules/string-width": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz", @@ -1508,22 +1437,22 @@ } }, "node_modules/@inquirer/prompts": { - "version": "8.7.3", - "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.7.3.tgz", - "integrity": "sha512-DHK20kO+H5k/XcJQpHQUpVQbMYHHhZchlGWhZVMUV4WIuRuBW5744hipOtriE1vMqPLgnIyXFMHWKkIbTepa4Q==", + "version": "8.7.5", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.7.5.tgz", + "integrity": "sha512-l/IWwC+QhOyXgxkZloOh+n5quGzc1m5ZawyzBmoizCZaxbJeLRdS3pStoqg/JVInJRXaG5aHMhjK6H6oYiBC9A==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/checkbox": "^5.2.6", - "@inquirer/confirm": "^6.3.3", - "@inquirer/editor": "^5.3.4", - "@inquirer/expand": "^5.1.6", - "@inquirer/input": "^5.1.7", - "@inquirer/number": "^4.2.4", - "@inquirer/password": "^5.2.3", - "@inquirer/rawlist": "^5.3.6", - "@inquirer/search": "^4.3.4", - "@inquirer/select": "^5.2.6" + "@inquirer/checkbox": "^5.2.8", + "@inquirer/confirm": "^6.3.5", + "@inquirer/editor": "^5.3.6", + "@inquirer/expand": "^5.1.8", + "@inquirer/input": "^5.1.9", + "@inquirer/number": "^4.2.6", + "@inquirer/password": "^5.2.5", + "@inquirer/rawlist": "^5.3.8", + "@inquirer/search": "^4.3.6", + "@inquirer/select": "^5.2.8" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" @@ -1727,6 +1656,13 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/@isaacs/nonrecursive-glob-match": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@isaacs/nonrecursive-glob-match/-/nonrecursive-glob-match-1.1.0.tgz", + "integrity": "sha512-GPm/Dbvk5RLmhRnxXIa7nTpK0xtl4E0wj0MslsuNiJ1FIROU+2+9lgUC/dgF7Ey4QCWhfEsgxilMRMCdHPfDfw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -2285,23 +2221,23 @@ } }, "node_modules/@nestjs/cli": { - "version": "12.0.11", - "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-12.0.11.tgz", - "integrity": "sha512-ExNgbKfhwMNXCV6FbQQnQOcxuubkDPcpYXUh+jkFn5Xm+WkdP+wjYS0KqyZgFSfpY24mST6y50UOEHVsvQME1A==", + "version": "12.0.12", + "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-12.0.12.tgz", + "integrity": "sha512-6Ab0zxLqYzZD8aEYWvleCIkvxJhcUtjR1VfQ/Hqg1/xhinKmSaFXEbFxc3QgqsJdoM//hRoMl0iQ4sudam1pjg==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "22.2.3", - "@angular-devkit/schematics": "22.2.3", - "@angular-devkit/schematics-cli": "22.2.3", - "@inquirer/prompts": "8.7.3", - "@nestjs/schematics": "^12.0.0", - "ansis": "4.4.0", + "@angular-devkit/core": "22.2.6", + "@angular-devkit/schematics": "22.2.6", + "@angular-devkit/schematics-cli": "22.2.6", + "@inquirer/prompts": "8.7.5", + "@nestjs/schematics": "^12.0.13", + "ansis": "4.5.0", "chokidar": "5.0.0", "cli-table3": "0.6.5", "commander": "15.0.0", "jsonc-parser": "3.3.1", - "minimatch": "10.2.6", + "minimatch": "11.0.5", "node-emoji": "2.2.0", "ora": "9.4.1", "tsconfig-paths": "4.2.0", @@ -2350,43 +2286,15 @@ } } }, - "node_modules/@nestjs/cli/node_modules/@angular-devkit/core": { - "version": "22.2.3", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-22.2.3.tgz", - "integrity": "sha512-bi1oBjJeg1XCgRrBYSuJikaJV4sO/mcnyFE/0a1H646yHhQpVYoU/M2Vh69Qv8h+YoMfC7RuSxT4LZHaubhhLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "8.20.0", - "ajv-formats": "3.0.1", - "jsonc-parser": "3.3.1", - "picomatch": "4.0.7", - "rxjs": "7.8.2", - "source-map": "0.8.0" - }, - "engines": { - "node": "^22.22.3 || ^24.15.0 || >=26.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - }, - "peerDependencies": { - "chokidar": "^5.0.0" - }, - "peerDependenciesMeta": { - "chokidar": { - "optional": true - } - } - }, "node_modules/@nestjs/cli/node_modules/@nestjs/schematics": { - "version": "12.0.12", - "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-12.0.12.tgz", - "integrity": "sha512-mZmpt1GuzRe4uwBKh/Men8SR6NeDNwCJ6cXHYiGOvFzl25X5u+7iWb3q/fS7xTbUxPUtu4kp1TgPcu+JJpqSPA==", + "version": "12.0.13", + "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-12.0.13.tgz", + "integrity": "sha512-Z9Xtp67fO5NuU7FgB/u44peK3XQnrL4qLxkf1dp6fqUk9l3ATBQneRNrWaC8e+HaimX8olxq2mH2ZXhhfalDhQ==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "22.2.4", - "@angular-devkit/schematics": "22.2.4", + "@angular-devkit/core": "22.2.6", + "@angular-devkit/schematics": "22.2.6", "comment-json": "5.0.0", "jsonc-parser": "3.3.1", "pluralize": "8.0.0" @@ -2404,70 +2312,6 @@ } } }, - "node_modules/@nestjs/cli/node_modules/@nestjs/schematics/node_modules/@angular-devkit/core": { - "version": "22.2.4", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-22.2.4.tgz", - "integrity": "sha512-HDlsVK/qN7p5ttKgPvVLo87yrN0UcHR7OyQo1n/lvvMQcYrA0n01E+ix4t+q4C04ifpMbNQtrK0I1222OKi99w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "8.20.0", - "ajv-formats": "3.0.1", - "jsonc-parser": "3.3.1", - "picomatch": "4.0.7", - "rxjs": "7.8.2", - "source-map": "0.8.0" - }, - "engines": { - "node": "^22.22.3 || ^24.15.0 || >=26.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - }, - "peerDependencies": { - "chokidar": "^5.0.0" - }, - "peerDependenciesMeta": { - "chokidar": { - "optional": true - } - } - }, - "node_modules/@nestjs/cli/node_modules/@nestjs/schematics/node_modules/@angular-devkit/schematics": { - "version": "22.2.4", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-22.2.4.tgz", - "integrity": "sha512-9pRNtcZN87yM7KQX+HTxGatrm86y30JxVwkF2WNG1pR0e9yeiYWc1KQ5hRS1Nq1LULoP95HApc+TBXqUXzF4Ag==", - "dev": true, - "license": "MIT", - "dependencies": { - "@angular-devkit/core": "22.2.4", - "jsonc-parser": "3.3.1", - "magic-string": "1.4.1", - "ora": "9.4.1", - "rxjs": "7.8.2" - }, - "engines": { - "node": "^22.22.3 || ^24.15.0 || >=26.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - } - }, - "node_modules/@nestjs/cli/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, "node_modules/@nestjs/cli/node_modules/ansi-regex": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.5.0.tgz", @@ -2517,22 +2361,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@nestjs/cli/node_modules/chokidar": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^5.0.0" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@nestjs/cli/node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -2588,13 +2416,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@nestjs/cli/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, "node_modules/@nestjs/cli/node_modules/log-symbols": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", @@ -2612,27 +2433,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@nestjs/cli/node_modules/magic-string": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz", - "integrity": "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.6.0" - } - }, "node_modules/@nestjs/cli/node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-11.0.5.tgz", + "integrity": "sha512-snPlVppFcYWfLLFQGhDxUyBcHNtMlWmn3jtj7pEaO+DP8pqL0IvAzXhMYmn1z9ASNtgmwLBdFxSf4tMUB0OTKA==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.8" + "@isaacs/nonrecursive-glob-match": "^1.1.0", + "brace-expansion": "^5.0.12" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -2677,33 +2489,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@nestjs/cli/node_modules/picomatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", - "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/@nestjs/cli/node_modules/readdirp": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", - "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@nestjs/cli/node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -2721,16 +2506,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@nestjs/cli/node_modules/source-map": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0.tgz", - "integrity": "sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">= 12" - } - }, "node_modules/@nestjs/cli/node_modules/string-width": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz", @@ -2865,15 +2640,15 @@ } }, "node_modules/@nestjs/platform-express": { - "version": "11.2.8", - "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.2.8.tgz", - "integrity": "sha512-aSAAz8E31qv+sDsL0s7Cw/HoXmeM0TVf35ny17BjLL10c2PgFDp2vunycL7muVyp3/IP5TStMb3Ia4B/RTNHZA==", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.1.tgz", + "integrity": "sha512-IUxk380qnUtz0PCRQ5i+o9UHlGMrFzGPIJxDwyt3JZZwx2AngOlcEcm5e+7YeJQEr2QYX2QyC4tUQg0zde+D7A==", "license": "MIT", "dependencies": { - "cors": "2.8.6", - "express": "5.2.1", - "multer": "2.4.0", - "path-to-regexp": "8.4.2", + "cors": "2.8.5", + "express": "5.1.0", + "multer": "1.4.5-lts.2", + "path-to-regexp": "8.2.0", "tslib": "2.8.1" }, "funding": { @@ -2885,6 +2660,15 @@ "@nestjs/core": "^11.0.0" } }, + "node_modules/@nestjs/platform-express/node_modules/path-to-regexp": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", + "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/@nestjs/schematics": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz", @@ -3017,6 +2801,20 @@ } } }, + "node_modules/@nestjs/throttler": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.7.1.tgz", + "integrity": "sha512-gAyY1AkU+cSK5DCjFOB43xax9oakameH4xZa9kAgqBqSBQZgmv3DZylLV8KyZNzOGhceL3kDVtby+CF8VAdmEA==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0", + "@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0", + "reflect-metadata": "^0.1.13 || ^0.2.0" + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -4240,6 +4038,12 @@ "@types/superagent": "^8.1.0" } }, + "node_modules/@types/validator": { + "version": "13.15.10", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", + "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", + "license": "MIT" + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -5390,9 +5194,9 @@ } }, "node_modules/ansis": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.4.0.tgz", - "integrity": "sha512-9k3v7xcHwgdO/DruxGIg4HtjvlAZlcnsX/mzqUb1t3NkYnl9kK2UJ+Gq0io+vQf7iT//BD/HB/NBkUR1LWxoeA==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.5.0.tgz", + "integrity": "sha512-KwIVkfFfJSSzBmZhDGuTmkhWqTdyB5oNVzzznBJbBZ5bZtHiqSrcugRF0RKJKT4Ss3d7ZrcjnlFVDlQNmtWfWw==", "dev": true, "license": "ISC", "engines": { @@ -5794,7 +5598,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, "license": "MIT" }, "node_modules/busboy": { @@ -5846,36 +5649,6 @@ } } }, - "node_modules/c12/node_modules/chokidar": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "readdirp": "^5.0.0" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/c12/node_modules/readdirp": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", - "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -5980,6 +5753,22 @@ "dev": true, "license": "MIT" }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/chrome-trace-event": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", @@ -6014,6 +5803,23 @@ "dev": true, "license": "MIT" }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", + "license": "MIT" + }, + "node_modules/class-validator": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.15.1.tgz", + "integrity": "sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==", + "license": "MIT", + "dependencies": { + "@types/validator": "^13.15.3", + "libphonenumber-js": "^1.11.1", + "validator": "^13.15.22" + } + }, "node_modules/classnames": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", @@ -6189,6 +5995,51 @@ "dev": true, "license": "MIT" }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concat-stream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/concat-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/concat-stream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/confbox": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", @@ -6250,10 +6101,16 @@ "dev": true, "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", "license": "MIT", "dependencies": { "object-assign": "^4", @@ -6261,10 +6118,6 @@ }, "engines": { "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" } }, "node_modules/create-require": { @@ -7153,19 +7006,18 @@ } }, "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "license": "MIT", "dependencies": { "accepts": "^2.0.0", - "body-parser": "^2.2.1", + "body-parser": "^2.2.0", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", - "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", @@ -7723,9 +7575,9 @@ } }, "node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "version": "13.0.7", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.7.tgz", + "integrity": "sha512-wfsFgXQ8A3SEI9XGf7Nl3Y9l7ffSrGh7SUCT7vU/2CzYPep9kjxAn1S40F9OPKxAMGCVl7HtNlJhrCJUeUq83g==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -7927,6 +7779,18 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz", + "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -8204,6 +8068,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -9004,6 +8874,12 @@ "node": ">= 0.8.0" } }, + "node_modules/libphonenumber-js": { + "version": "1.13.15", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.15.tgz", + "integrity": "sha512-AHdhEQeDFEJ4GKDAQ9AwxChy+v2kUILUB3TGQRpkXMkWePjXTU/WQEzWVQoIx/Gz8fE73Wr5SEuZCy2vk8jrMQ==", + "license": "MIT" + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -9321,7 +9197,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -9337,6 +9212,18 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -9344,21 +9231,22 @@ "license": "MIT" }, "node_modules/multer": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz", - "integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==", + "version": "1.4.5-lts.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", + "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", + "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.", "license": "MIT", "dependencies": { "append-field": "^1.0.0", - "busboy": "^1.6.0", - "type-is": "^1.6.18" + "busboy": "^1.0.0", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "type-is": "^1.6.4", + "xtend": "^4.0.0" }, "engines": { - "node": ">= 10.16.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">= 6.0.0" } }, "node_modules/multer/node_modules/media-typer": { @@ -10216,6 +10104,12 @@ } } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/proper-lockfile": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", @@ -10383,6 +10277,20 @@ "node": ">= 6" } }, + "node_modules/readdirp": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", @@ -11752,6 +11660,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -11934,7 +11848,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, "node_modules/v8-compile-cache-lib": { @@ -11974,6 +11887,15 @@ } } }, + "node_modules/validator": { + "version": "13.15.35", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", + "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/package.json b/package.json index 1ef6718..1f18bad 100644 --- a/package.json +++ b/package.json @@ -26,9 +26,13 @@ "@nestjs/config": "^12.0.0", "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.0.1", + "@nestjs/throttler": "^6.7.1", "@prisma/adapter-pg": "^7.10.0", "@prisma/client": "^7.10.0", "axios": "1.20.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.15.1", + "helmet": "^8.3.0", "pg": "^8.23.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" @@ -90,7 +94,7 @@ "/node_modules/(?!@nestjs/config/)" ], "moduleNameMapper": { - "^(.{1,2}/.*).js$": "$1" + "^(\\.{1,2}/.*)\\.js$": "$1" } }, "allowScripts": { diff --git a/src/app.controller.spec.ts b/src/app.controller.spec.ts index c46c13f..99499bf 100644 --- a/src/app.controller.spec.ts +++ b/src/app.controller.spec.ts @@ -16,14 +16,14 @@ describe('AppController', () => { controller = module.get(AppController); }); - it('starts a synchronization', () => { - hiboutikService.startSync.mockReturnValue(true); - expect(controller.hiboutikSync()).toEqual({ message: 'Hiboutik synchronization started' }); + it('starts a synchronization', async () => { + hiboutikService.startSync.mockResolvedValue(true); + await expect(controller.hiboutikSync()).resolves.toEqual({ message: 'Hiboutik synchronization started' }); expect(hiboutikService.startSync).toHaveBeenCalledTimes(1); }); - it('rejects a synchronization when one is already running', () => { - hiboutikService.startSync.mockReturnValue(false); - expect(() => controller.hiboutikSync()).toThrow(ConflictException); + it('rejects a synchronization when one is already running', async () => { + hiboutikService.startSync.mockResolvedValue(false); + await expect(controller.hiboutikSync()).rejects.toThrow(ConflictException); }); }); diff --git a/src/app.controller.ts b/src/app.controller.ts index 345b0ed..3dac09e 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -1,15 +1,20 @@ import { ConflictException, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common'; +import { Throttle } from '@nestjs/throttler'; import { HiboutikService } from './services/hiboutik/hiboutik.service'; @Controller() export class AppController { constructor(private readonly hiboutikService: HiboutikService) {} - /** Starts the synchronization in the background and answers immediately: 202, or 409 if one is already running. */ + /** + * Starts the synchronization in the background and answers immediately: 202, or 409 if one is already running. + * Rate limited to 5 calls per minute: each one can trigger thousands of Hiboutik API calls. + */ @Post('sync') + @Throttle({ default: { limit: 5, ttl: 60_000 } }) @HttpCode(HttpStatus.ACCEPTED) - hiboutikSync() { - if (!this.hiboutikService.startSync()) { + async hiboutikSync() { + if (!(await this.hiboutikService.startSync())) { throw new ConflictException('A Hiboutik synchronization is already in progress'); } return { message: 'Hiboutik synchronization started' }; diff --git a/src/app.module.ts b/src/app.module.ts index db86dc2..289d076 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,6 +1,10 @@ import { Module } from '@nestjs/common'; +import { APP_FILTER, APP_GUARD } from '@nestjs/core'; +import { AllExceptionsFilter } from './filters/all-exceptions.filter'; +import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; import { AppController } from './app.controller'; import { ConfigModule } from '@nestjs/config'; +import { validateEnv } from './config/env.validation'; import { HiboutikService } from './services/hiboutik/hiboutik.service'; import { CategoryHiboutikRepository } from './hiboutik/category.hiboutik.repository'; import { CategoryConverter } from './models/converter/category.converter'; @@ -32,17 +36,23 @@ import { CategoryHiboutikService } from './services/hiboutik/category.hiboutik.s import { CustomerHiboutikService } from './services/hiboutik/customer.hiboutik.service'; import { SupplierHiboutikService } from './services/hiboutik/supplier.hiboutik.service'; import { ProductHiboutikService } from './services/hiboutik/product.hiboutik.service'; +import { SyncLockDatabaseRepository } from './database/syncLock.database.repository'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, + validate: validateEnv, }), + // Default rate limit for every route: 60 requests per minute per IP (stricter limits via @Throttle) + ThrottlerModule.forRoot([{ ttl: 60_000, limit: 60 }]), HttpModule.register({ timeout: 30_000 }), PrismaModule, ], controllers: [AppController], providers: [ + { provide: APP_GUARD, useClass: ThrottlerGuard }, + { provide: APP_FILTER, useClass: AllExceptionsFilter }, BrandConverter, BrandDatabaseRepository, BrandHiboutikRepository, @@ -70,6 +80,7 @@ import { ProductHiboutikService } from './services/hiboutik/product.hiboutik.ser StockHiboutikRepository, SupplierConverter, SupplierDatabaseRepository, + SyncLockDatabaseRepository, SupplierHiboutikRepository, SupplierHiboutikService, ], diff --git a/src/config/env.validation.spec.ts b/src/config/env.validation.spec.ts new file mode 100644 index 0000000..d1a21cc --- /dev/null +++ b/src/config/env.validation.spec.ts @@ -0,0 +1,39 @@ +import 'reflect-metadata'; +import { validateEnv } from './env.validation'; + +describe('validateEnv', () => { + const valid = { + DATABASE_URL: 'postgresql://user:secret@localhost:5432/db', + HIBOUTIK_BASE_URL: 'https://shop.hiboutik.com/api', + HIBOUTIK_USERNAME: 'user', + HIBOUTIK_PASSWORD: 'secret-password', + }; + + it('accepts a valid configuration and keeps every variable', () => { + const env = { ...valid, PORT: '3000', CORS_ORIGINS: 'https://shop.example.com', TRUST_PROXY: '1', OTHER: 'x' }; + expect(validateEnv(env)).toBe(env); + }); + + it('rejects a missing required variable, naming it', () => { + expect(() => validateEnv({ ...valid, HIBOUTIK_PASSWORD: undefined })).toThrow(/HIBOUTIK_PASSWORD/); + }); + + it('rejects a non-HTTPS Hiboutik URL', () => { + expect(() => validateEnv({ ...valid, HIBOUTIK_BASE_URL: 'http://shop.hiboutik.com/api' })).toThrow(/HIBOUTIK_BASE_URL/); + }); + + it('rejects an invalid port', () => { + expect(() => validateEnv({ ...valid, PORT: 'abc' })).toThrow(/PORT/); + expect(() => validateEnv({ ...valid, PORT: '70000' })).toThrow(/PORT/); + }); + + it('never puts secret values in the error message', () => { + expect.assertions(2); + try { + validateEnv({ ...valid, HIBOUTIK_BASE_URL: 'http://insecure', DATABASE_URL: '' }); + } catch (error) { + expect((error as Error).message).not.toContain('secret-password'); + expect((error as Error).message).not.toContain('secret'); + } + }); +}); diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts new file mode 100644 index 0000000..afc176e --- /dev/null +++ b/src/config/env.validation.ts @@ -0,0 +1,51 @@ +import { plainToInstance } from 'class-transformer'; +import { IsInt, IsNotEmpty, IsOptional, IsString, IsUrl, Max, Min, validateSync } from 'class-validator'; + +/** Environment variables read by the app. A missing or malformed required one stops the startup. */ +export class EnvironmentVariables { + @IsString() + @IsNotEmpty() + DATABASE_URL!: string; + + /** HTTPS only: the Hiboutik credentials are sent with HTTP Basic auth. */ + @IsUrl({ protocols: ['https'], require_protocol: true }) + HIBOUTIK_BASE_URL!: string; + + @IsString() + @IsNotEmpty() + HIBOUTIK_USERNAME!: string; + + @IsString() + @IsNotEmpty() + HIBOUTIK_PASSWORD!: string; + + @IsOptional() + @IsInt() + @Min(1) + @Max(65535) + PORT?: number; + + /** Comma-separated list of origins allowed by CORS. Empty or unset: CORS stays disabled. */ + @IsOptional() + @IsString() + CORS_ORIGINS?: string; + + /** Number of reverse proxies in front of the app (so the real client IP is used for rate limiting). Unset: no proxy. */ + @IsOptional() + @IsInt() + @Min(1) + @Max(10) + TRUST_PROXY?: number; +} + +/** `ConfigModule` validation hook: fails fast with the names of the faulty variables (never their values, they may be secrets). */ +export function validateEnv(config: Record): Record { + const validated = plainToInstance(EnvironmentVariables, config, { enableImplicitConversion: true }); + const errors = validateSync(validated, { whitelist: false }); + + if (errors.length > 0) { + const details = errors.map((error) => `${error.property}: ${Object.values(error.constraints ?? {}).join(', ')}`).join('\n '); + throw new Error(`Invalid environment configuration:\n ${details}`); + } + return config; +} diff --git a/src/database/syncLock.database.repository.spec.ts b/src/database/syncLock.database.repository.spec.ts new file mode 100644 index 0000000..622163a --- /dev/null +++ b/src/database/syncLock.database.repository.spec.ts @@ -0,0 +1,74 @@ +import { Logger } from '@nestjs/common'; +import { Client } from 'pg'; +import { SyncLockDatabaseRepository } from './syncLock.database.repository'; + +jest.mock('pg', () => ({ Client: jest.fn() })); + +describe('SyncLockDatabaseRepository', () => { + const client = { on: jest.fn(), connect: jest.fn(), query: jest.fn(), end: jest.fn() }; + const configService = { get: jest.fn().mockReturnValue('postgresql://user:pass@localhost:5432/db') }; + let repository: SyncLockDatabaseRepository; + + beforeAll(() => { + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + }); + + beforeEach(() => { + jest.clearAllMocks(); + (Client as unknown as jest.Mock).mockImplementation(() => client); + client.connect.mockResolvedValue(undefined); + client.end.mockResolvedValue(undefined); + client.query.mockResolvedValue({ rows: [{ locked: true }] }); + repository = new SyncLockDatabaseRepository(configService as never); + }); + + it('takes the advisory lock on a dedicated connection and keeps it open', async () => { + const release = await repository.tryAcquire(); + + expect(release).toEqual(expect.any(Function)); + expect(Client).toHaveBeenCalledWith({ connectionString: 'postgresql://user:pass@localhost:5432/db' }); + expect(client.query).toHaveBeenCalledWith('SELECT pg_try_advisory_lock($1) AS locked', [expect.any(Number)]); + expect(client.end).not.toHaveBeenCalled(); + }); + + it('returns null and closes the connection when the lock is already held', async () => { + client.query.mockResolvedValue({ rows: [{ locked: false }] }); + + await expect(repository.tryAcquire()).resolves.toBeNull(); + + expect(client.end).toHaveBeenCalledTimes(1); + }); + + it('unlocks then closes the connection on release', async () => { + const release = await repository.tryAcquire(); + + await release!(); + + expect(client.query).toHaveBeenLastCalledWith('SELECT pg_advisory_unlock($1)', [expect.any(Number)]); + expect(client.end).toHaveBeenCalledTimes(1); + }); + + it('still closes the connection, and does not throw, when the explicit unlock fails', async () => { + const release = await repository.tryAcquire(); + client.query.mockRejectedValueOnce(new Error('connection lost')); + + await expect(release!()).resolves.toBeUndefined(); + + expect(client.end).toHaveBeenCalledTimes(1); + }); + + it('closes the connection and rethrows when the lock query fails', async () => { + client.query.mockRejectedValue(new Error('boom')); + + await expect(repository.tryAcquire()).rejects.toThrow('boom'); + + expect(client.end).toHaveBeenCalledTimes(1); + }); + + it('logs connection errors instead of leaving them unhandled', async () => { + await repository.tryAcquire(); + + expect(client.on).toHaveBeenCalledWith('error', expect.any(Function)); + }); +}); diff --git a/src/database/syncLock.database.repository.ts b/src/database/syncLock.database.repository.ts new file mode 100644 index 0000000..61de25f --- /dev/null +++ b/src/database/syncLock.database.repository.ts @@ -0,0 +1,52 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Client } from 'pg'; + +/** Arbitrary constant identifying the Hiboutik synchronization among the Postgres advisory locks of the database. */ +const SYNC_LOCK_KEY = 727_001; + +/** Function releasing a lock acquired with `tryAcquire`. Never throws. */ +export type ReleaseLock = () => Promise; + +/** + * Cross-instance lock for the synchronization, based on a Postgres session-level advisory lock. + * It lives on its own dedicated connection (not the Prisma pool, where lock and unlock could land on different + * connections) kept open for as long as the lock is held. If the process dies, Postgres closes the connection and + * releases the lock by itself: there is never a stale lock to clean up. + */ +@Injectable() +export class SyncLockDatabaseRepository { + private readonly logger = new Logger('SyncLockDatabaseRepository'); + + constructor(private readonly configService: ConfigService) {} + + /** @returns the function releasing the lock, or `null` if another instance (or process) already holds it. */ + async tryAcquire(): Promise { + const client = new Client({ connectionString: this.configService.get('DATABASE_URL') }); + // A lost connection means Postgres released the lock: log it instead of crashing the process on an unhandled 'error' + client.on('error', (error) => this.logger.error(`Synchronization lock connection error: ${error.message}`)); + await client.connect(); + + try { + const { rows } = await client.query<{ locked: boolean }>('SELECT pg_try_advisory_lock($1) AS locked', [SYNC_LOCK_KEY]); + if (!rows[0]?.locked) { + await client.end(); + return null; + } + } catch (error) { + await client.end().catch(() => undefined); + throw error; + } + + return async () => { + try { + await client.query('SELECT pg_advisory_unlock($1)', [SYNC_LOCK_KEY]); + } catch (error) { + // Ending the connection below releases the lock anyway + this.logger.warn(`Failed to unlock the synchronization lock explicitly: ${(error as Error)?.message}`); + } finally { + await client.end().catch(() => undefined); + } + }; + } +} diff --git a/src/filters/all-exceptions.filter.spec.ts b/src/filters/all-exceptions.filter.spec.ts new file mode 100644 index 0000000..54f5fc8 --- /dev/null +++ b/src/filters/all-exceptions.filter.spec.ts @@ -0,0 +1,58 @@ +import { BadRequestException, Logger } from '@nestjs/common'; +import { AllExceptionsFilter } from './all-exceptions.filter'; + +describe('AllExceptionsFilter', () => { + const applicationRef = { reply: jest.fn(), isHeadersSent: jest.fn().mockReturnValue(false), end: jest.fn() }; + const response = {}; + const host = { getArgByIndex: jest.fn().mockReturnValue(response) }; + let filter: AllExceptionsFilter; + let logError: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + logError = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + filter = new AllExceptionsFilter(applicationRef as never); + }); + + it('answers a generic 500 for an unexpected error, without leaking its message', () => { + filter.catch(new Error('relation "customer" does not exist'), host as never); + + expect(applicationRef.reply).toHaveBeenCalledTimes(1); + const [, body, status] = applicationRef.reply.mock.calls[0] as [unknown, { message: string }, number]; + expect(status).toBe(500); + expect(JSON.stringify(body)).not.toContain('customer'); + }); + + it('logs the message and stack of an unexpected error, never the raw object', () => { + const error = Object.assign(new Error('boom'), { config: { headers: { Authorization: 'Basic c2VjcmV0' } } }); + + filter.catch(error, host as never); + + expect(logError).toHaveBeenCalledWith('Unhandled exception: boom', error.stack); + expect(JSON.stringify(logError.mock.calls)).not.toContain('c2VjcmV0'); + }); + + it('keeps the status and body of deliberate HTTP exceptions', () => { + filter.catch(new BadRequestException('limit must be an integer'), host as never); + + const [, body, status] = applicationRef.reply.mock.calls[0] as [unknown, { message: string }, number]; + expect(status).toBe(400); + expect(body.message).toBe('limit must be an integer'); + expect(logError).not.toHaveBeenCalled(); + }); + + it('keeps the 4xx of HTTP-layer errors such as a payload too large', () => { + filter.catch(Object.assign(new Error('request entity too large'), { statusCode: 413 }), host as never); + + const [, , status] = applicationRef.reply.mock.calls[0] as [unknown, unknown, number]; + expect(status).toBe(413); + }); + + it('treats a 5xx HTTP-layer error as an unexpected one', () => { + filter.catch(Object.assign(new Error('upstream detail'), { statusCode: 502 }), host as never); + + const [, body, status] = applicationRef.reply.mock.calls[0] as [unknown, unknown, number]; + expect(status).toBe(500); + expect(JSON.stringify(body)).not.toContain('upstream'); + }); +}); diff --git a/src/filters/all-exceptions.filter.ts b/src/filters/all-exceptions.filter.ts new file mode 100644 index 0000000..322da10 --- /dev/null +++ b/src/filters/all-exceptions.filter.ts @@ -0,0 +1,29 @@ +import { ArgumentsHost, Catch, HttpException, InternalServerErrorException, Logger } from '@nestjs/common'; +import { BaseExceptionFilter } from '@nestjs/core'; + +/** Errors raised by the HTTP layer itself (malformed JSON, payload too large...) carry a 4xx `statusCode` but are not `HttpException`s. */ +function isClientHttpError(exception: unknown): boolean { + const statusCode = (exception as { statusCode?: unknown } | null)?.statusCode; + return typeof statusCode === 'number' && statusCode >= 400 && statusCode < 500; +} + +/** + * Last line of defence: anything that is not a deliberate HTTP error (Prisma, axios, bug...) is logged with its stack + * (message and stack only, never the raw error object: it may carry credentials) and answered with a generic 500, + * so no internal detail (SQL, table names, URLs, stack) ever reaches the client. + */ +@Catch() +export class AllExceptionsFilter extends BaseExceptionFilter { + private readonly logger = new Logger('ExceptionsFilter'); + + catch(exception: unknown, host: ArgumentsHost): void { + if (exception instanceof HttpException || isClientHttpError(exception)) { + super.catch(exception, host); + return; + } + + const error = exception as Error | undefined; + this.logger.error(`Unhandled exception: ${error?.message}`, error?.stack); + super.catch(new InternalServerErrorException(), host); + } +} diff --git a/src/main.ts b/src/main.ts index ff7d2ee..4f0fcb0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,8 +1,36 @@ +import { ValidationPipe } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { NestFactory } from '@nestjs/core'; +import { NestExpressApplication } from '@nestjs/platform-express'; +import helmet from 'helmet'; import { AppModule } from './app.module'; async function bootstrap() { - const app = await NestFactory.create(AppModule); - await app.listen(process.env.PORT ?? 3000); + const app = await NestFactory.create(AppModule); + const config = app.get(ConfigService); + + // Security headers (also removes X-Powered-By) + app.use(helmet()); + + // Behind a reverse proxy, trust its X-Forwarded-For so rate limiting sees the real client IP + const trustProxy = config.get('TRUST_PROXY'); + if (trustProxy) app.set('trust proxy', trustProxy); + + // CORS is disabled unless origins are explicitly allowed + const origins = (config.get('CORS_ORIGINS') ?? '') + .split(',') + .map((origin) => origin.trim()) + .filter(Boolean); + if (origins.length > 0) app.enableCors({ origin: origins }); + + app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true })); + + // Disconnect Prisma cleanly on SIGTERM/SIGINT + app.enableShutdownHooks(); + + const server = await app.listen(config.get('PORT') ?? 3000); + // Drop slow clients holding sockets open (Slowloris): every endpoint answers fast, POST /sync runs in the background + server.headersTimeout = 15_000; + server.requestTimeout = 30_000; } void bootstrap(); diff --git a/src/services/hiboutik/hiboutik.service.spec.ts b/src/services/hiboutik/hiboutik.service.spec.ts index 55eed97..6da351d 100644 --- a/src/services/hiboutik/hiboutik.service.spec.ts +++ b/src/services/hiboutik/hiboutik.service.spec.ts @@ -7,7 +7,10 @@ describe('HiboutikService', () => { const supplierService = { syncSuppliers: jest.fn() }; const productService = { syncProducts: jest.fn() }; const customerService = { syncCustomers: jest.fn() }; + const syncLock = { tryAcquire: jest.fn() }; + const release = jest.fn(); let service: HiboutikService; + const flush = () => new Promise((resolve) => setImmediate(resolve)); beforeAll(() => { jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); @@ -22,12 +25,15 @@ describe('HiboutikService', () => { supplierService.syncSuppliers.mockResolvedValue(undefined); productService.syncProducts.mockResolvedValue(undefined); customerService.syncCustomers.mockResolvedValue(undefined); + release.mockResolvedValue(undefined); + syncLock.tryAcquire.mockResolvedValue(release); service = new HiboutikService( brandService as never, categoryService as never, customerService as never, productService as never, supplierService as never, + syncLock as never, ); }); @@ -57,12 +63,58 @@ describe('HiboutikService', () => { let finishBrands: () => void = () => undefined; brandService.syncBrands.mockReturnValue(new Promise((resolve) => (finishBrands = resolve))); - expect(service.startSync()).toBe(true); + await expect(service.startSync()).resolves.toBe(true); expect(service.isSyncInProgress).toBe(true); - expect(service.startSync()).toBe(false); + await expect(service.startSync()).resolves.toBe(false); + expect(syncLock.tryAcquire).toHaveBeenCalledTimes(1); + expect(release).not.toHaveBeenCalled(); finishBrands(); - await new Promise((resolve) => setImmediate(resolve)); + await flush(); + expect(release).toHaveBeenCalledTimes(1); + expect(service.isSyncInProgress).toBe(false); + }); + + it('refuses concurrent calls on the same instance while the lock is being requested', async () => { + let grantLock: (value: typeof release) => void = () => undefined; + syncLock.tryAcquire.mockReturnValue(new Promise((resolve) => (grantLock = resolve))); + + const first = service.startSync(); + await expect(service.startSync()).resolves.toBe(false); + + grantLock(release); + await expect(first).resolves.toBe(true); + await flush(); + expect(syncLock.tryAcquire).toHaveBeenCalledTimes(1); + }); + + it('starts nothing when another instance holds the lock', async () => { + syncLock.tryAcquire.mockResolvedValue(null); + + await expect(service.startSync()).resolves.toBe(false); + + expect(service.isSyncInProgress).toBe(false); + expect(brandService.syncBrands).not.toHaveBeenCalled(); + expect(release).not.toHaveBeenCalled(); + }); + + it('starts nothing and can be retried when the lock cannot be requested', async () => { + syncLock.tryAcquire.mockRejectedValueOnce(new Error('database unreachable')); + + await expect(service.startSync()).rejects.toThrow('database unreachable'); + expect(service.isSyncInProgress).toBe(false); + + await expect(service.startSync()).resolves.toBe(true); + await flush(); + }); + + it('releases the lock even when the synchronization crashes', async () => { + jest.spyOn(service, 'syncAll').mockRejectedValue(new Error('crash')); + + await expect(service.startSync()).resolves.toBe(true); + await flush(); + + expect(release).toHaveBeenCalledTimes(1); expect(service.isSyncInProgress).toBe(false); }); }); diff --git a/src/services/hiboutik/hiboutik.service.ts b/src/services/hiboutik/hiboutik.service.ts index 5578c48..89205df 100644 --- a/src/services/hiboutik/hiboutik.service.ts +++ b/src/services/hiboutik/hiboutik.service.ts @@ -4,12 +4,13 @@ import { CategoryHiboutikService } from './category.hiboutik.service'; import { CustomerHiboutikService } from './customer.hiboutik.service'; import { SupplierHiboutikService } from './supplier.hiboutik.service'; import { ProductHiboutikService } from './product.hiboutik.service'; +import { ReleaseLock, SyncLockDatabaseRepository } from '../../database/syncLock.database.repository'; /** Entry point of the Hiboutik → database synchronization: runs each entity sync in dependency order. */ @Injectable() export class HiboutikService { private readonly logger = new Logger('HiboutikService'); - /** In-memory lock: only valid while the app runs as a single instance. */ + /** Cheap in-process guard, checked before the database lock (which is the one that holds across instances). */ private syncInProgress = false; constructor( @@ -18,6 +19,7 @@ export class HiboutikService { private readonly customerHiboutikService: CustomerHiboutikService, private readonly productHiboutikService: ProductHiboutikService, private readonly supplierHiboutikService: SupplierHiboutikService, + private readonly syncLock: SyncLockDatabaseRepository, ) {} get isSyncInProgress(): boolean { @@ -25,19 +27,44 @@ export class HiboutikService { } /** - * Starts a full synchronization in the background. + * Starts a full synchronization in the background, if no other one is running on this instance or any other + * (Postgres advisory lock, held until the synchronization ends). * @returns false if a synchronization is already running (nothing is started). + * @throws if the lock cannot be requested (e.g. database unreachable). */ - startSync(): boolean { + async startSync(): Promise { if (this.syncInProgress) return false; - + // Set before the first await so that concurrent calls on this instance are refused while the lock is requested this.syncInProgress = true; - void this.syncAll() - .catch((error) => this.logger.error('Global Hiboutik synchronization crashed', (error as Error)?.stack)) - .finally(() => (this.syncInProgress = false)); + + let release: ReleaseLock | null; + try { + release = await this.syncLock.tryAcquire(); + } catch (error) { + this.syncInProgress = false; + throw error; + } + if (!release) { + this.syncInProgress = false; + return false; + } + + void this.runLocked(release); return true; } + /** Runs the synchronization, then always releases the lock. */ + private async runLocked(release: ReleaseLock): Promise { + try { + await this.syncAll(); + } catch (error) { + this.logger.error('Global Hiboutik synchronization crashed', (error as Error)?.stack); + } finally { + await release(); + this.syncInProgress = false; + } + } + /** Runs every sync step in order. A failing step is logged and does not stop the next ones. */ async syncAll() { this.logger.log('Starting global Hiboutik synchronization'); diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 07f5664..8051759 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -27,12 +27,12 @@ describe('AppController (e2e)', () => { }); it('POST /sync starts a synchronization (202)', () => { - hiboutikService.startSync.mockReturnValue(true); + hiboutikService.startSync.mockResolvedValue(true); return request(app.getHttpServer()).post('/sync').expect(202).expect({ message: 'Hiboutik synchronization started' }); }); it('POST /sync answers 409 when a synchronization is already running', () => { - hiboutikService.startSync.mockReturnValue(false); + hiboutikService.startSync.mockResolvedValue(false); return request(app.getHttpServer()).post('/sync').expect(409); }); diff --git a/test/jest-e2e.json b/test/jest-e2e.json index cbbd9e8..0847774 100644 --- a/test/jest-e2e.json +++ b/test/jest-e2e.json @@ -3,6 +3,7 @@ "rootDir": ".", "testEnvironment": "node", "testRegex": ".e2e-spec.ts$", + "setupFiles": ["/setup-env.ts"], "transform": { "^.+.ts$": "ts-jest", "^.+.js$": [ @@ -18,6 +19,6 @@ }, "transformIgnorePatterns": ["/node_modules/(?!@nestjs/config/)"], "moduleNameMapper": { - "^(.{1,2}/.*).js$": "$1" + "^(\\.{1,2}/.*)\\.js$": "$1" } } diff --git a/test/setup-env.ts b/test/setup-env.ts new file mode 100644 index 0000000..5fffd99 --- /dev/null +++ b/test/setup-env.ts @@ -0,0 +1,5 @@ +// The e2e test needs neither a database nor Hiboutik, but the app validates its environment at startup: provide dummy values. +process.env.DATABASE_URL ??= 'postgresql://test:test@localhost:5432/test'; +process.env.HIBOUTIK_BASE_URL ??= 'https://example.hiboutik.test/api'; +process.env.HIBOUTIK_USERNAME ??= 'test'; +process.env.HIBOUTIK_PASSWORD ??= 'test';