From 30c56f93308bf28844459f90d93a03477e078eed Mon Sep 17 00:00:00 2001 From: MastersGame Date: Sun, 8 Nov 2026 10:11:34 +0100 Subject: [PATCH 1/4] feat(sync): trust Hiboutik answers for deletions A successful (2xx) answer from Hiboutik is now the truth, even an empty list: records missing from it are deleted. The "empty list means API issue" safety nets are removed. Deletion stays safe because a failed call deletes nothing: HTTP errors throw, and the new HiboutikClient.getList (used by getAllPages and the non-paginated lists) also throws on a 2xx body that is not a list, so a malformed answer is never mistaken for an empty list. A 2xx without body is an empty list. Co-Authored-By: Claude Sonnet 5.5 --- src/hiboutik/hiboutik.client.spec.ts | 66 +++++++++++++++++++ src/hiboutik/hiboutik.client.ts | 15 ++++- src/hiboutik/stock.hiboutik.repository.ts | 2 +- src/hiboutik/supplier.hiboutik.repository.ts | 2 +- .../hiboutik/brand.hiboutik.service.ts | 6 +- .../category.hiboutik.service.spec.ts | 10 ++- .../hiboutik/category.hiboutik.service.ts | 6 +- .../customer.hiboutik.service.spec.ts | 10 ++- .../hiboutik/customer.hiboutik.service.ts | 9 +-- .../hiboutik/product.hiboutik.service.spec.ts | 11 +++- .../hiboutik/product.hiboutik.service.ts | 8 +-- .../hiboutik/supplier.hiboutik.service.ts | 6 +- 12 files changed, 115 insertions(+), 36 deletions(-) create mode 100644 src/hiboutik/hiboutik.client.spec.ts diff --git a/src/hiboutik/hiboutik.client.spec.ts b/src/hiboutik/hiboutik.client.spec.ts new file mode 100644 index 0000000..2b204ce --- /dev/null +++ b/src/hiboutik/hiboutik.client.spec.ts @@ -0,0 +1,66 @@ +import { of, throwError } from 'rxjs'; +import { HiboutikClient } from './hiboutik.client'; + +describe('HiboutikClient', () => { + const httpService = { get: jest.fn() }; + const configService = { get: jest.fn((key: string) => (key === 'HIBOUTIK_BASE_URL' ? 'https://shop.hiboutik.test/api' : 'x')) }; + let client: HiboutikClient; + + const answer = (data: unknown) => of({ data }); + const page = (size: number) => Array.from({ length: size }, (_, index) => ({ id: index })); + + beforeEach(() => { + jest.clearAllMocks(); + client = new HiboutikClient(httpService as never, configService as never); + }); + + describe('getList', () => { + it('returns the list of a 2xx answer, empty or not', async () => { + httpService.get.mockReturnValueOnce(answer([{ id: 1 }])).mockReturnValueOnce(answer([])); + + await expect(client.getList('/x')).resolves.toEqual([{ id: 1 }]); + await expect(client.getList('/x')).resolves.toEqual([]); + }); + + it('treats a 2xx answer without body as an empty list', async () => { + httpService.get.mockReturnValueOnce(answer('')).mockReturnValueOnce(answer(null)); + + await expect(client.getList('/x')).resolves.toEqual([]); + await expect(client.getList('/x')).resolves.toEqual([]); + }); + + it('rejects a 2xx answer that is not a list, without leaking its content', async () => { + httpService.get.mockReturnValueOnce(answer({ error: 'secret details' })); + + const result = client.getList('/x'); + + await expect(result).rejects.toThrow('a list was expected'); + await expect(result).rejects.not.toThrow('secret details'); + }); + + it('lets HTTP errors through', async () => { + httpService.get.mockReturnValueOnce(throwError(() => new Error('Request failed with status code 500'))); + + await expect(client.getList('/x')).rejects.toThrow('500'); + }); + }); + + describe('getAllPages', () => { + it('walks the pages until a short one', async () => { + httpService.get.mockReturnValueOnce(answer(page(250))).mockReturnValueOnce(answer(page(10))); + + const results = await client.getAllPages('/x'); + + expect(results).toHaveLength(260); + expect(httpService.get).toHaveBeenCalledTimes(2); + }); + + it('fails as a whole when a later page fails, instead of returning a truncated list', async () => { + httpService.get + .mockReturnValueOnce(answer(page(250))) + .mockReturnValueOnce(throwError(() => new Error('Request failed with status code 502'))); + + await expect(client.getAllPages('/x')).rejects.toThrow('502'); + }); + }); +}); diff --git a/src/hiboutik/hiboutik.client.ts b/src/hiboutik/hiboutik.client.ts index 049806d..f0bb9d5 100644 --- a/src/hiboutik/hiboutik.client.ts +++ b/src/hiboutik/hiboutik.client.ts @@ -33,6 +33,17 @@ export class HiboutikClient { return response.data; } + /** + * GET on a list endpoint. HTTP errors (4xx/5xx) throw, a 2xx answer without body is an empty list, and any other + * non-list body throws: an empty list triggers deletions in the sync, so it must never be inferred from a malformed answer. + */ + async getList(path: string, params?: Record): Promise { + const data = await this.get(path, params); + if (data === '' || data == null) return []; + if (!Array.isArray(data)) throw new Error(`Unexpected answer from Hiboutik ${path}: a list was expected`); + return data; + } + /** * Fetches every page of a paginated endpoint (`p` param, starting at 1) and concatenates the results. * Only for paginated endpoints: on a non-paginated one, a full first page would loop forever on the same results. @@ -42,8 +53,8 @@ export class HiboutikClient { let page = 1; while (true) { - const pageData = await this.get(path, { ...baseParams, p: page }); - if (!pageData || pageData.length === 0) break; + const pageData = await this.getList(path, { ...baseParams, p: page }); + if (pageData.length === 0) break; results.push(...pageData); // A page smaller than the page size is the last one: no need to request an empty page if (pageData.length < HIBOUTIK_PAGE_SIZE) break; diff --git a/src/hiboutik/stock.hiboutik.repository.ts b/src/hiboutik/stock.hiboutik.repository.ts index 80d893d..a77a6fe 100644 --- a/src/hiboutik/stock.hiboutik.repository.ts +++ b/src/hiboutik/stock.hiboutik.repository.ts @@ -9,7 +9,7 @@ export class StockHiboutikRepository { /** Stock of every product and size, all warehouses combined. Not paginated. */ async getStocks(): Promise { - const stocks = await this.hiboutikClient.get('/stock_available/all_wh', {}); + const stocks = await this.hiboutikClient.getList('/stock_available/all_wh', {}); // Hiboutik may return ids as strings: normalize them so strict comparisons work downstream return stocks.map((stock) => ({ ...stock, diff --git a/src/hiboutik/supplier.hiboutik.repository.ts b/src/hiboutik/supplier.hiboutik.repository.ts index 90fa397..666c799 100644 --- a/src/hiboutik/supplier.hiboutik.repository.ts +++ b/src/hiboutik/supplier.hiboutik.repository.ts @@ -9,7 +9,7 @@ export class SupplierHiboutikRepository { /** `/suppliers` is not paginated: everything comes in one call. */ async getSuppliers(): Promise { - return this.hiboutikClient.get('/suppliers', { + return this.hiboutikClient.getList('/suppliers', { order_by: 'supplier_id', sort: 'ASC', }); diff --git a/src/services/hiboutik/brand.hiboutik.service.ts b/src/services/hiboutik/brand.hiboutik.service.ts index ce55ee2..b3a1409 100644 --- a/src/services/hiboutik/brand.hiboutik.service.ts +++ b/src/services/hiboutik/brand.hiboutik.service.ts @@ -25,11 +25,7 @@ export class BrandHiboutikService { (brand) => `Hiboutik brand ${brand.hiboutikId}`, ); - // Safety net: an empty answer from Hiboutik most likely means an API issue - if (hiboutikBrands.length === 0) { - this.logger.warn('Hiboutik returned no brands: deletion skipped.'); - return; - } + // Hiboutik is trusted: a successful (2xx) answer, even an empty list, is the truth. An HTTP error throws before this point. const nbDeleted = await this.brandDatabaseRepository.deleteMissing(hiboutikBrands.map((dto) => Number(dto.brand_id))); this.logger.log( `Brands sync ended: ${hiboutikBrands.length - nbFailed}/${hiboutikBrands.length} synced, ${nbFailed} failed, ${nbDeleted} deleted.`, diff --git a/src/services/hiboutik/category.hiboutik.service.spec.ts b/src/services/hiboutik/category.hiboutik.service.spec.ts index 7d1dcdb..871adbf 100644 --- a/src/services/hiboutik/category.hiboutik.service.spec.ts +++ b/src/services/hiboutik/category.hiboutik.service.spec.ts @@ -79,11 +79,19 @@ describe('CategoryHiboutikService', () => { expect(categoryDatabaseRepository.deleteMissing).toHaveBeenCalledWith([1, 2]); }); - it('does not delete anything when Hiboutik returns no categories', async () => { + it('deletes every category when Hiboutik answers with an empty list', async () => { categoryHiboutikRepository.getCategories.mockResolvedValue([]); await service.syncCategories(); + expect(categoryDatabaseRepository.deleteMissing).toHaveBeenCalledWith([]); + }); + + it('deletes nothing when the Hiboutik call fails', async () => { + categoryHiboutikRepository.getCategories.mockRejectedValue(new Error('Request failed with status code 500')); + + await expect(service.syncCategories()).rejects.toThrow('500'); + expect(categoryDatabaseRepository.deleteMissing).not.toHaveBeenCalled(); }); }); diff --git a/src/services/hiboutik/category.hiboutik.service.ts b/src/services/hiboutik/category.hiboutik.service.ts index e1454d5..c50ec64 100644 --- a/src/services/hiboutik/category.hiboutik.service.ts +++ b/src/services/hiboutik/category.hiboutik.service.ts @@ -37,11 +37,7 @@ export class CategoryHiboutikService { (dto) => `parent of Hiboutik category ${dto.category_id}`, ); - // Safety net: an empty answer from Hiboutik most likely means an API issue - if (hiboutikCategories.length === 0) { - this.logger.warn('Hiboutik returned no categories: deletion skipped.'); - return; - } + // Hiboutik is trusted: a successful (2xx) answer, even an empty list, is the truth. An HTTP error throws before this point. const nbDeleted = await this.categoryDatabaseRepository.deleteMissing(hiboutikCategories.map((dto) => Number(dto.category_id))); this.logger.log( `Categories sync ended: ${hiboutikCategories.length - nbFailed}/${hiboutikCategories.length} synced, ${nbFailed} failed, ` + diff --git a/src/services/hiboutik/customer.hiboutik.service.spec.ts b/src/services/hiboutik/customer.hiboutik.service.spec.ts index bf7115b..e59443c 100644 --- a/src/services/hiboutik/customer.hiboutik.service.spec.ts +++ b/src/services/hiboutik/customer.hiboutik.service.spec.ts @@ -48,9 +48,17 @@ describe('CustomerHiboutikService', () => { expect(customerDatabaseRepository.upsert).toHaveBeenCalledTimes(2); }); - it('does not delete customers when Hiboutik returns none', async () => { + it('deletes every customer when Hiboutik answers with an empty list', async () => { await service.syncCustomers(); + expect(customerDatabaseRepository.deleteMissing).toHaveBeenCalledWith([]); + }); + + it('deletes nothing when the Hiboutik call fails', async () => { + customerHiboutikRepository.getCustomers.mockRejectedValue(new Error('Request failed with status code 500')); + + await expect(service.syncCustomers()).rejects.toThrow('500'); + expect(customerDatabaseRepository.deleteMissing).not.toHaveBeenCalled(); }); diff --git a/src/services/hiboutik/customer.hiboutik.service.ts b/src/services/hiboutik/customer.hiboutik.service.ts index 996c09b..a8d959d 100644 --- a/src/services/hiboutik/customer.hiboutik.service.ts +++ b/src/services/hiboutik/customer.hiboutik.service.ts @@ -37,13 +37,8 @@ export class CustomerHiboutikService { async syncCustomers() { const hiboutikCustomers = await this.customerHiboutikRepository.getCustomers(); - // Safety net: an empty answer from Hiboutik most likely means an API issue - let nbDeleted = 0; - if (hiboutikCustomers.length === 0) { - this.logger.warn('Hiboutik returned no customers: deletion skipped.'); - } else { - nbDeleted = await this.customerDatabaseRepository.deleteMissing(hiboutikCustomers.map((dto) => Number(dto.customers_id))); - } + // Hiboutik is trusted: a successful (2xx) answer, even an empty list, is the truth. An HTTP error throws before this point. + const nbDeleted = await this.customerDatabaseRepository.deleteMissing(hiboutikCustomers.map((dto) => Number(dto.customers_id))); let nbFailed = 0; let nbFailedAddresses = 0; diff --git a/src/services/hiboutik/product.hiboutik.service.spec.ts b/src/services/hiboutik/product.hiboutik.service.spec.ts index ecbf373..5a0068d 100644 --- a/src/services/hiboutik/product.hiboutik.service.spec.ts +++ b/src/services/hiboutik/product.hiboutik.service.spec.ts @@ -89,12 +89,21 @@ describe('ProductHiboutikService', () => { expect(calls[0]).toBe('deleteProducts'); }); - it('does not delete anything when Hiboutik returns no products', async () => { + it('deletes every product when Hiboutik answers with an empty list', async () => { productHiboutikRepository.getProducts.mockResolvedValue([]); productDatabaseRepository.getAll.mockResolvedValue([{ id: 'uuid-1', hiboutikId: 1 }]); await service.syncProducts(); + expect(productDatabaseRepository.deleteAll).toHaveBeenCalledWith(['uuid-1']); + }); + + it('deletes nothing when the Hiboutik call fails', async () => { + productHiboutikRepository.getProducts.mockRejectedValue(new Error('Request failed with status code 500')); + productDatabaseRepository.getAll.mockResolvedValue([{ id: 'uuid-1', hiboutikId: 1 }]); + + await expect(service.syncProducts()).rejects.toThrow('500'); + expect(productDatabaseRepository.deleteAll).not.toHaveBeenCalled(); }); diff --git a/src/services/hiboutik/product.hiboutik.service.ts b/src/services/hiboutik/product.hiboutik.service.ts index 2a64ca2..e0172f7 100644 --- a/src/services/hiboutik/product.hiboutik.service.ts +++ b/src/services/hiboutik/product.hiboutik.service.ts @@ -27,7 +27,6 @@ export class ProductHiboutikService { /** Mirrors Hiboutik products, their versions (sizes) and stocks. Products missing from Hiboutik are deleted. */ async syncProducts() { - this.logger.log('Starting products sync'); const [productsHiboutik, hiboutikStocks, existingProducts] = await Promise.all([ this.productHiboutikRepository.getProducts(), this.stockHiboutikRepository.getStocks(), @@ -56,12 +55,7 @@ export class ProductHiboutikService { /** @returns the number of deleted products. */ private async deleteMissingProducts(productsHiboutik: ProductHiboutikDto[], existingProducts: Product[]): Promise { - // Safety net: an empty answer from Hiboutik most likely means an API issue, not an empty catalog - if (productsHiboutik.length === 0 && existingProducts.length > 0) { - this.logger.warn(`Hiboutik returned no products while ${existingProducts.length} exist in database: deletion skipped.`); - return 0; - } - + // Hiboutik is trusted: a successful (2xx) answer, even an empty list, is the truth. An HTTP error throws before this point. const hiboutikIds = new Set(productsHiboutik.map((product) => Number(product.product_id))); const missingProductIds = existingProducts.filter((product) => !hiboutikIds.has(product.hiboutikId)).map((product) => product.id); return this.productDatabaseRepository.deleteAll(missingProductIds); diff --git a/src/services/hiboutik/supplier.hiboutik.service.ts b/src/services/hiboutik/supplier.hiboutik.service.ts index 5a5673b..9353aae 100644 --- a/src/services/hiboutik/supplier.hiboutik.service.ts +++ b/src/services/hiboutik/supplier.hiboutik.service.ts @@ -25,11 +25,7 @@ export class SupplierHiboutikService { (supplier) => `Hiboutik supplier ${supplier.hiboutikId}`, ); - // Safety net: an empty answer from Hiboutik most likely means an API issue - if (hiboutikSuppliers.length === 0) { - this.logger.warn('Hiboutik returned no suppliers: deletion skipped.'); - return; - } + // Hiboutik is trusted: a successful (2xx) answer, even an empty list, is the truth. An HTTP error throws before this point. const nbDeleted = await this.supplierDatabaseRepository.deleteMissing(hiboutikSuppliers.map((dto) => Number(dto.supplier_id))); this.logger.log( `Suppliers sync ended: ${hiboutikSuppliers.length - nbFailed}/${hiboutikSuppliers.length} synced, ${nbFailed} failed, ${nbDeleted} deleted.`, -- 2.48.1 From 05911f4230ac16aa51ab831b31d89c8c06499484 Mon Sep 17 00:00:00 2001 From: MastersGame Date: Sun, 8 Nov 2026 10:11:35 +0100 Subject: [PATCH 2/4] feat(security): harden the API and lock synchronizations across instances - Validate the environment at startup (src/config): required variables must be present, HIBOUTIK_BASE_URL must be HTTPS. - Global ValidationPipe (whitelist, forbidNonWhitelisted, transform). - Helmet, CORS disabled unless CORS_ORIGINS is set, TRUST_PROXY support, HTTP header/request timeouts, shutdown hooks. - Rate limiting: 60 req/min/IP globally, 5 req/min on POST /sync. - AllExceptionsFilter: unexpected errors are logged (message + stack, never the raw error object) and answered with a generic 500. - Postgres advisory lock (dedicated connection) so only one synchronization runs at a time across all instances; released by Postgres itself if the process dies. - Fix the Jest moduleNameMapper (unescaped dot also matched .cjs files). - Document the input validation and security rules in CLAUDE.md. - Add class-validator, class-transformer, helmet and @nestjs/throttler; npm audit fix refreshed package-lock.json. Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 20 +- package-lock.json | 778 ++++++++---------- package.json | 6 +- src/app.controller.spec.ts | 12 +- src/app.controller.ts | 11 +- src/app.module.ts | 11 + src/config/env.validation.spec.ts | 39 + src/config/env.validation.ts | 51 ++ .../syncLock.database.repository.spec.ts | 74 ++ src/database/syncLock.database.repository.ts | 52 ++ src/filters/all-exceptions.filter.spec.ts | 58 ++ src/filters/all-exceptions.filter.ts | 29 + src/main.ts | 32 +- .../hiboutik/hiboutik.service.spec.ts | 58 +- src/services/hiboutik/hiboutik.service.ts | 41 +- test/app.e2e-spec.ts | 4 +- test/jest-e2e.json | 3 +- test/setup-env.ts | 5 + 18 files changed, 830 insertions(+), 454 deletions(-) create mode 100644 src/config/env.validation.spec.ts create mode 100644 src/config/env.validation.ts create mode 100644 src/database/syncLock.database.repository.spec.ts create mode 100644 src/database/syncLock.database.repository.ts create mode 100644 src/filters/all-exceptions.filter.spec.ts create mode 100644 src/filters/all-exceptions.filter.ts create mode 100644 test/setup-env.ts 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'; -- 2.48.1 From 96fca40847e80558e5a5e592250abb10c2e45870 Mon Sep 17 00:00:00 2001 From: MastersGame Date: Sun, 8 Nov 2026 10:21:27 +0100 Subject: [PATCH 3/4] refactor(sync): rely on the advisory lock only and make syncAll private Each startSync request takes the Postgres advisory lock on its own connection (its own session), so it already refuses concurrent calls on the same instance: the in-process syncInProgress flag and its isSyncInProgress getter were redundant and are removed. syncAll becomes private: syncs start only through startSync. Co-Authored-By: Claude Sonnet 5.5 --- .../hiboutik/hiboutik.service.spec.ts | 25 +++------------ src/services/hiboutik/hiboutik.service.ts | 31 ++++--------------- 2 files changed, 11 insertions(+), 45 deletions(-) diff --git a/src/services/hiboutik/hiboutik.service.spec.ts b/src/services/hiboutik/hiboutik.service.spec.ts index 6da351d..87ef133 100644 --- a/src/services/hiboutik/hiboutik.service.spec.ts +++ b/src/services/hiboutik/hiboutik.service.spec.ts @@ -62,30 +62,17 @@ describe('HiboutikService', () => { it('refuses a second synchronization while one is running, and unlocks at the end', async () => { let finishBrands: () => void = () => undefined; brandService.syncBrands.mockReturnValue(new Promise((resolve) => (finishBrands = resolve))); + // The lock is the only guard: the second request does not get it, even from the same instance + syncLock.tryAcquire.mockResolvedValueOnce(release).mockResolvedValueOnce(null); await expect(service.startSync()).resolves.toBe(true); - expect(service.isSyncInProgress).toBe(true); await expect(service.startSync()).resolves.toBe(false); - expect(syncLock.tryAcquire).toHaveBeenCalledTimes(1); + expect(brandService.syncBrands).toHaveBeenCalledTimes(1); expect(release).not.toHaveBeenCalled(); finishBrands(); 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 () => { @@ -93,7 +80,6 @@ describe('HiboutikService', () => { await expect(service.startSync()).resolves.toBe(false); - expect(service.isSyncInProgress).toBe(false); expect(brandService.syncBrands).not.toHaveBeenCalled(); expect(release).not.toHaveBeenCalled(); }); @@ -102,19 +88,18 @@ describe('HiboutikService', () => { syncLock.tryAcquire.mockRejectedValueOnce(new Error('database unreachable')); await expect(service.startSync()).rejects.toThrow('database unreachable'); - expect(service.isSyncInProgress).toBe(false); + expect(brandService.syncBrands).not.toHaveBeenCalled(); 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')); + jest.spyOn(service as never, '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 89205df..54375ec 100644 --- a/src/services/hiboutik/hiboutik.service.ts +++ b/src/services/hiboutik/hiboutik.service.ts @@ -10,8 +10,6 @@ import { ReleaseLock, SyncLockDatabaseRepository } from '../../database/syncLock @Injectable() export class HiboutikService { private readonly logger = new Logger('HiboutikService'); - /** Cheap in-process guard, checked before the database lock (which is the one that holds across instances). */ - private syncInProgress = false; constructor( private readonly brandHiboutikService: BrandHiboutikService, @@ -22,32 +20,16 @@ export class HiboutikService { private readonly syncLock: SyncLockDatabaseRepository, ) {} - get isSyncInProgress(): boolean { - return this.syncInProgress; - } - /** - * 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). + * Starts a full synchronization in the background, if no other one is running on this instance or any other. + * The Postgres advisory lock is the only guard: each request takes it on its own connection (its own session), + * so it also refuses concurrent calls on this very instance. It is 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). */ 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; - - let release: ReleaseLock | null; - try { - release = await this.syncLock.tryAcquire(); - } catch (error) { - this.syncInProgress = false; - throw error; - } - if (!release) { - this.syncInProgress = false; - return false; - } + const release = await this.syncLock.tryAcquire(); + if (!release) return false; void this.runLocked(release); return true; @@ -61,12 +43,11 @@ export class HiboutikService { 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() { + private async syncAll() { this.logger.log('Starting global Hiboutik synchronization'); // Order matters: products connect to brands, categories and suppliers by hiboutikId. const steps: [string, () => Promise][] = [ -- 2.48.1 From d8e05b1b3acc6c5d285070660e226050e2705534 Mon Sep 17 00:00:00 2001 From: MastersGame Date: Sun, 8 Nov 2026 10:34:50 +0100 Subject: [PATCH 4/4] feat(sync): update jsdoc --- src/utils/sync.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/utils/sync.ts b/src/utils/sync.ts index 4477887..d35a6d1 100644 --- a/src/utils/sync.ts +++ b/src/utils/sync.ts @@ -2,6 +2,9 @@ import { Logger } from '@nestjs/common'; /** * Runs `action` on each item, one at a time: a failing item is logged and skipped, the others are still processed. + * @param items + * @param action + * @param logger * @param describe label of an item in the error log, e.g. "Hiboutik brand 12". * @returns the number of failed items. */ -- 2.48.1