From cad067c11e1ac0578e74c8c81ae2102621f15ef3 Mon Sep 17 00:00:00 2001 From: Tim Fabian Date: Tue, 14 Apr 2026 19:41:14 +0200 Subject: [PATCH 1/6] added event system, added context system, added exclude property flag, extended default & required property flags --- package-lock.json | 2842 ++++++++++------- package.json | 54 +- sandbox/src/data-sources/db/db.data-source.ts | 6 +- .../pages/mailing-list-preferences.tsx | 4 +- src/__testing__/constants.ts | 2 + .../create-test-data-source.function.ts | 6 +- .../test-server/start-test-server.function.ts | 27 +- src/application.ts | 16 +- .../2fa/methods/otp/otp.two-factor-method.ts | 12 +- .../methods/two-factor-method.interface.ts | 6 +- src/auth/2fa/two-factor-service.interface.ts | 6 +- src/auth/2fa/two-factor.service.ts | 12 +- src/auth/auth-service.interface.ts | 18 +- src/auth/auth.service.ts | 43 +- .../strategies/auth-strategy.interface.ts | 12 +- src/auth/strategies/jwt/jwt.auth-strategy.ts | 28 +- src/change-sets/change-set-repository.ts | 64 +- src/change-sets/soft-delete-repository.ts | 17 +- src/context/als.utilities.ts | 61 + src/context/base-context.ts | 10 + .../request/http-request.context.test.ts | 93 + src/context/request/http-request.context.ts | 41 + .../request/request-context-token.model.ts | 95 + .../request/websocket-request.context.ts | 43 + src/data-source/exclude-property.test.ts | 383 +++ ...e-filter-to-find-options-where.function.ts | 40 +- src/data-source/repository.ts | 184 +- src/di/default/zibri-di-providers.default.ts | 14 +- src/di/default/zibri-di-tokens.default.ts | 11 +- src/di/di-container.ts | 4 +- src/di/models/di-provider.model.ts | 12 + src/entity/decorators/property.decorator.ts | 70 +- .../models/base-property-metadata.model.ts | 40 +- .../models/file-property-metadata.model.ts | 2 +- src/error-handling/errors/validation.error.ts | 5 +- src/event/event-cleanup.cron-job.ts | 42 + src/event/event-processing.error.ts | 15 + src/event/event-service.interface.ts | 62 + src/event/event-subscriber-run.model.ts | 43 + src/event/event.model.ts | 66 + src/event/event.service.ts | 249 ++ .../model-registry/default-descriptor.ts | 104 + .../model-registry/exclude-descriptor.ts | 106 + src/global/model-registry/model.registry.ts | 65 + .../remove-exclude-properties.function.ts | 76 + .../restore-exclude-properties.function.ts | 54 + .../set-default-values.function.ts | 56 + src/http-client/http-client.interface.ts | 5 +- src/http-client/http-client.ts | 50 +- src/http/known-header.enum.ts | 52 +- src/index.ts | 19 +- src/jest.setup.ts | 6 +- .../formatting/format-price.function.ts | 1 + src/logging/logger.ts | 22 +- .../services/multithreading.service.test.ts | 100 +- .../services/multithreading.service.ts | 2 +- src/open-api/open-api.service.ts | 40 +- .../form-data/form-data.body-parser.ts | 11 +- src/parsing/json/json.body-parser.ts | 2 +- .../services/invoice-calc.service.ts | 2 +- .../invoicing/services/invoice-pdf.service.ts | 1 + src/routing/decorators/body.decorator.ts | 3 +- .../models/array-param-metadata.model.ts | 2 +- .../models/boolean-param-metadata.model.ts | 2 +- .../models/date-param-metadata.model.ts | 2 +- .../models/number-param-metadata.model.ts | 5 +- .../models/object-param-metadata.model.ts | 5 +- .../models/string-param-metadata.model.ts | 5 +- src/routing/request.context.ts | 35 - src/routing/resolve-route-params.function.ts | 116 +- src/routing/router.ts | 132 +- src/utilities/metadata-injection-keys.enum.ts | 3 +- src/utilities/promise.utilities.ts | 10 +- .../functions/validate-boolean.function.ts | 32 +- .../functions/validate-date.function.ts | 30 +- .../functions/validate-file.function.ts | 25 +- .../functions/validate-number.function.ts | 33 +- .../functions/validate-string.function.ts | 14 +- .../validation-service.interface.ts | 10 +- src/validation/validation.service.ts | 162 +- src/websocket/services/websocket.service.ts | 126 +- tsconfig.cjs.json | 2 +- tsconfig.esm.json | 4 +- 83 files changed, 4285 insertions(+), 2007 deletions(-) create mode 100644 src/context/als.utilities.ts create mode 100644 src/context/base-context.ts create mode 100644 src/context/request/http-request.context.test.ts create mode 100644 src/context/request/http-request.context.ts create mode 100644 src/context/request/request-context-token.model.ts create mode 100644 src/context/request/websocket-request.context.ts create mode 100644 src/data-source/exclude-property.test.ts create mode 100644 src/event/event-cleanup.cron-job.ts create mode 100644 src/event/event-processing.error.ts create mode 100644 src/event/event-service.interface.ts create mode 100644 src/event/event-subscriber-run.model.ts create mode 100644 src/event/event.model.ts create mode 100644 src/event/event.service.ts create mode 100644 src/global/model-registry/default-descriptor.ts create mode 100644 src/global/model-registry/exclude-descriptor.ts create mode 100644 src/global/model-registry/model.registry.ts create mode 100644 src/global/model-registry/remove-exclude-properties.function.ts create mode 100644 src/global/model-registry/restore-exclude-properties.function.ts create mode 100644 src/global/model-registry/set-default-values.function.ts delete mode 100644 src/routing/request.context.ts diff --git a/package-lock.json b/package-lock.json index 0c2d5cf..c81d66f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,80 +1,80 @@ { "name": "zibri", - "version": "2.3.0", + "version": "2.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zibri", - "version": "2.3.0", + "version": "2.4.0", "license": "MIT", "dependencies": { "@fastify/busboy": "^3.2.0", - "cors": "^2.8.5", - "express": "^5.1.0", + "cors": "^2.8.6", + "express": "^5.2.1", "glob": "^13.0.6", "node-cron": "^4.2.1", - "nodemailer": "^8.0.4", - "pg": "^8.16.3", + "nodemailer": "^8.0.5", + "pg": "^8.20.0", "prom-client": "^15.1.3", "reflect-metadata": "^0.2.2", "swagger-ui-express": "^5.0.1", "swagger2openapi": "^7.0.8", - "systeminformation": "^5.27.10", - "typeorm": "^0.3.27" + "systeminformation": "^5.31.5", + "typeorm": "^0.3.28" }, "devDependencies": { "@faker-js/faker": "^9.9.0", - "@jest/globals": "^30.2.0", - "@swc/core": "^1.13.5", - "@testcontainers/postgresql": "^11.6.0", + "@jest/globals": "^30.3.0", + "@swc/core": "^1.15.24", + "@testcontainers/postgresql": "^11.14.0", "@types/cors": "^2.8.19", - "@types/express": "^5.0.3", + "@types/express": "^5.0.6", "@types/jsonwebtoken": "^9.0.10", - "@types/node": "^24.10.13", - "@types/nodemailer": "^7.0.1", + "@types/node": "^25.6.0", + "@types/nodemailer": "^8.0.0", "@types/pdfmake": "^0.2.11", "@types/swagger-ui-express": "^4.1.8", "@types/swagger2openapi": "^7.0.4", "eslint": "^9.36.0", "eslint-config-service-soft": "^2.1.6", - "jest": "^30.2.0", + "jest": "^30.3.0", "npm-run-all": "^4.1.5", "openapi3-ts": "^4.5.0", - "testcontainers": "^11.6.0", - "ts-jest": "^29.4.6", - "typedoc": "^0.28.17", - "typescript": "^5.9.3" + "testcontainers": "^11.14.0", + "ts-jest": "^29.4.9", + "typedoc": "^0.28.19", + "typescript": "^5.9.2" }, "engines": { "node": ">=20" }, "peerDependencies": { - "axios": "^1.13.2", - "bcryptjs": "^3.0.2", - "bignumber.js": "^9.3.1", - "handlebars": "^4.7.8", + "axios": "^1.15.0", + "bcryptjs": "^3.0.3", + "bignumber.js": "^10.0.2", + "handlebars": "^4.7.9", "hi-base32": "^0.5.1", - "jsonwebtoken": "^9.0.2", - "otpauth": "^9.4.1", + "jsonwebtoken": "^9.0.3", + "otpauth": "^9.5.0", "pdfmake": "^0.2.2", - "preact": "^10.28.3", - "preact-render-to-string": "^6.6.5", + "preact": "^10.29.1", + "preact-render-to-string": "^6.6.7", "rxjs": "^7.8.2", - "socket.io": "^4.8.1", + "socket.io": "^4.8.3", "ts-node": "^10.9.2", "uuid": "^11.1.0", "xmlbuilder2": "^4.0.3" } }, "node_modules/@angular-devkit/architect": { - "version": "0.2003.22", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2003.22.tgz", - "integrity": "sha512-gxVOslVweD+Co6gpRVlByHus/3HVAnsl99MobS9PBh8vh2g6bJ011PBgl0TKsP/pqBGawZOkJXYrRPeMKnobYA==", + "version": "0.2003.23", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2003.23.tgz", + "integrity": "sha512-o9fzWCxcLcUPxd7xP0gA10cQAwg9kNrS4VHFCjJ7+kB6pi8GSZqqEw/N1BB0s/+zpJ4bQ4EC82hDC6Cu5Fpv9Q==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "20.3.22", + "@angular-devkit/core": "20.3.23", "rxjs": "7.8.2" }, "engines": { @@ -83,6 +83,58 @@ "yarn": ">= 1.13.0" } }, + "node_modules/@angular-devkit/architect/node_modules/@angular-devkit/core": { + "version": "20.3.23", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-20.3.23.tgz", + "integrity": "sha512-NOcoT8FMXHAiqvfTb5LCmT7/mtsYwQ+p5a49bo2uWYeKdSwniAdGGR+7yDxLdYXJpe8dc/epo/uiAq/coi+7YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.18.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.4", + "rxjs": "7.8.2", + "source-map": "0.7.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^4.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/architect/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "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/architect/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": { "version": "20.3.22", "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-20.3.22.tgz", @@ -154,21 +206,6 @@ "yarn": ">= 1.13.0" } }, - "node_modules/@angular-eslint/builder": { - "version": "20.7.0", - "resolved": "https://registry.npmjs.org/@angular-eslint/builder/-/builder-20.7.0.tgz", - "integrity": "sha512-qgf4Cfs1z0VsVpzF/OnxDRvBp60OIzeCsp4mzlckWYVniKo19EPIN6kFDol5eTAIOMPgiBQlMIwgQMHgocXEig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@angular-devkit/architect": ">= 0.2000.0 < 0.2100.0", - "@angular-devkit/core": ">= 20.0.0 < 21.0.0" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": "*" - } - }, "node_modules/@angular-eslint/bundled-angular-compiler": { "version": "20.7.0", "resolved": "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-20.7.0.tgz", @@ -176,7 +213,23 @@ "dev": true, "license": "MIT" }, - "node_modules/@angular-eslint/eslint-plugin": { + "node_modules/@angular-eslint/schematics": { + "version": "20.7.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/schematics/-/schematics-20.7.0.tgz", + "integrity": "sha512-S0onfRipDUIL6gFGTFjiWwUDhi42XYrBoi3kJ3wBbKBeIgYv9SP1ppTKDD4ZoDaDU9cQE8nToX7iPn9ifMw6eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": ">= 20.0.0 < 21.0.0", + "@angular-devkit/schematics": ">= 20.0.0 < 21.0.0", + "@angular-eslint/eslint-plugin": "20.7.0", + "@angular-eslint/eslint-plugin-template": "20.7.0", + "ignore": "7.0.5", + "semver": "7.7.3", + "strip-json-comments": "3.1.1" + } + }, + "node_modules/@angular-eslint/schematics/node_modules/@angular-eslint/eslint-plugin": { "version": "20.7.0", "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin/-/eslint-plugin-20.7.0.tgz", "integrity": "sha512-aHH2YTiaonojsKN+y2z4IMugCwdsH/dYIjYBig6kfoSPyf9rGK4zx+gnNGq/pGRjF3bOYrmFgIviYpQVb80inQ==", @@ -193,7 +246,7 @@ "typescript": "*" } }, - "node_modules/@angular-eslint/eslint-plugin-template": { + "node_modules/@angular-eslint/schematics/node_modules/@angular-eslint/eslint-plugin-template": { "version": "20.7.0", "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-20.7.0.tgz", "integrity": "sha512-WFmvW2vBR6ExsSKEaActQTteyw6ikWyuJau9XmWEPFd+2eusEt/+wO21ybjDn3uc5FTp1IcdhfYy+U5OdDjH5w==", @@ -213,33 +266,7 @@ "typescript": "*" } }, - "node_modules/@angular-eslint/schematics": { - "version": "20.7.0", - "resolved": "https://registry.npmjs.org/@angular-eslint/schematics/-/schematics-20.7.0.tgz", - "integrity": "sha512-S0onfRipDUIL6gFGTFjiWwUDhi42XYrBoi3kJ3wBbKBeIgYv9SP1ppTKDD4ZoDaDU9cQE8nToX7iPn9ifMw6eQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@angular-devkit/core": ">= 20.0.0 < 21.0.0", - "@angular-devkit/schematics": ">= 20.0.0 < 21.0.0", - "@angular-eslint/eslint-plugin": "20.7.0", - "@angular-eslint/eslint-plugin-template": "20.7.0", - "ignore": "7.0.5", - "semver": "7.7.3", - "strip-json-comments": "3.1.1" - } - }, - "node_modules/@angular-eslint/schematics/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@angular-eslint/template-parser": { + "node_modules/@angular-eslint/schematics/node_modules/@angular-eslint/template-parser": { "version": "20.7.0", "resolved": "https://registry.npmjs.org/@angular-eslint/template-parser/-/template-parser-20.7.0.tgz", "integrity": "sha512-CVskZnF38IIxVVlKWi1VCz7YH/gHMJu2IY9bD1AVoBBGIe0xA4FRXJkW2Y+EDs9vQqZTkZZljhK5gL65Ro1PeQ==", @@ -255,26 +282,7 @@ "typescript": "*" } }, - "node_modules/@angular-eslint/template-parser/node_modules/eslint-scope": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.0.tgz", - "integrity": "sha512-CkWE42hOJsNj9FJRaoMX9waUFYhqY4jmyLFdAdzZr6VaCg3ynLYx4WnOdkaIifGfH4gsUcBTn4OZbHXkpLD0FQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@types/esrecurse": "^4.3.1", - "@types/estree": "^1.0.8", - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@angular-eslint/utils": { + "node_modules/@angular-eslint/schematics/node_modules/@angular-eslint/utils": { "version": "20.7.0", "resolved": "https://registry.npmjs.org/@angular-eslint/utils/-/utils-20.7.0.tgz", "integrity": "sha512-B6EJHbsk2W/lnS3kS/gm56VGvX735419z/DzgbRDcOvqMGMLwD1ILzv5OTEcL1rzpnB0AHW+IxOu6y/aCzSNUA==", @@ -289,6 +297,16 @@ "typescript": "*" } }, + "node_modules/@angular-eslint/schematics/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -356,45 +374,6 @@ "semver": "bin/semver.js" } }, - "node_modules/@babel/eslint-parser": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.28.6.tgz", - "integrity": "sha512-QGmsKi2PBO/MHSQk+AAgA9R6OHQr+VqnniFE0eMWZcVcfBZoA2dKn2hUsl3Csg/Plt9opRUWdY7//VXsrIlEiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nicolo-ribaudo/eslint-scope-5-internals": "5.1.1-v1", - "eslint-visitor-keys": "^2.1.0", - "semver": "^6.3.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || >=14.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.11.0", - "eslint": "^7.5.0 || ^8.0.0 || ^9.0.0" - } - }, - "node_modules/@babel/eslint-parser/node_modules/eslint-visitor-keys": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", - "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10" - } - }, - "node_modules/@babel/eslint-parser/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/generator": { "version": "7.29.1", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", @@ -1009,9 +988,9 @@ } }, "node_modules/@cspell/dict-companies": { - "version": "3.2.10", - "resolved": "https://registry.npmjs.org/@cspell/dict-companies/-/dict-companies-3.2.10.tgz", - "integrity": "sha512-bJ1qnO1DkTn7JYGXvxp8FRQc4yq6tRXnrII+jbP8hHmq5TX5o1Wu+rdfpoUQaMWTl6balRvcMYiINDesnpR9Bw==", + "version": "3.2.11", + "resolved": "https://registry.npmjs.org/@cspell/dict-companies/-/dict-companies-3.2.11.tgz", + "integrity": "sha512-0cmafbcz2pTHXLd59eLR1gvDvN6aWAOM0+cIL4LLF9GX9yB2iKDNrKsvs4tJRqutoaTdwNFBbV0FYv+6iCtebQ==", "dev": true, "license": "MIT" }, @@ -1037,9 +1016,9 @@ "license": "MIT" }, "node_modules/@cspell/dict-css": { - "version": "4.0.19", - "resolved": "https://registry.npmjs.org/@cspell/dict-css/-/dict-css-4.0.19.tgz", - "integrity": "sha512-VYHtPnZt/Zd/ATbW3rtexWpBnHUohUrQOHff/2JBhsVgxOrksAxJnLAO43Q1ayLJBJUUwNVo+RU0sx0aaysZfg==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-css/-/dict-css-4.1.1.tgz", + "integrity": "sha512-y/Vgo6qY08e1t9OqR56qjoFLBCpi4QfWMf2qzD1l9omRZwvSMQGRPz4x0bxkkkU4oocMAeztjzCsmLew//c/8w==", "dev": true, "license": "MIT", "peer": true @@ -1080,9 +1059,9 @@ "license": "MIT" }, "node_modules/@cspell/dict-dotnet": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/@cspell/dict-dotnet/-/dict-dotnet-5.0.12.tgz", - "integrity": "sha512-FiV934kNieIjGTkiApu/WKvLYi/KBpvfWB2TSqpDQtmXZlt3uSa5blwblO1ZC8OvjH8RCq/31H5IdEYmTaZS7A==", + "version": "5.0.13", + "resolved": "https://registry.npmjs.org/@cspell/dict-dotnet/-/dict-dotnet-5.0.13.tgz", + "integrity": "sha512-xPp7jMnFpOri7tzmqmm/dXMolXz1t2bhNqxYkOyMqXhvs08oc7BFs+EsbDY0X7hqiISgeFZGNqn0dOCr+ncPYw==", "dev": true, "license": "MIT" }, @@ -1094,9 +1073,9 @@ "license": "MIT" }, "node_modules/@cspell/dict-en_us": { - "version": "4.4.29", - "resolved": "https://registry.npmjs.org/@cspell/dict-en_us/-/dict-en_us-4.4.29.tgz", - "integrity": "sha512-G3B27++9ziRdgbrY/G/QZdFAnMzzx17u8nCb2Xyd4q6luLpzViRM/CW3jA+Mb/cGT5zR/9N+Yz9SrGu1s0bq7g==", + "version": "4.4.33", + "resolved": "https://registry.npmjs.org/@cspell/dict-en_us/-/dict-en_us-4.4.33.tgz", + "integrity": "sha512-zWftVqfUStDA37wO1ZNDN1qMJOfcxELa8ucHW8W8wBAZY3TK5Nb6deLogCK/IJi/Qljf30dwwuqqv84Qqle9Tw==", "dev": true, "license": "MIT" }, @@ -1108,16 +1087,16 @@ "license": "CC BY-SA 4.0" }, "node_modules/@cspell/dict-en-gb-mit": { - "version": "3.1.18", - "resolved": "https://registry.npmjs.org/@cspell/dict-en-gb-mit/-/dict-en-gb-mit-3.1.18.tgz", - "integrity": "sha512-AXaMzbaxhSc32MSzKX0cpwT+Thv1vPfxQz1nTly1VHw3wQcwPqVFSqrLOYwa8VNqAPR45583nnhD6iqJ9YESoQ==", + "version": "3.1.22", + "resolved": "https://registry.npmjs.org/@cspell/dict-en-gb-mit/-/dict-en-gb-mit-3.1.22.tgz", + "integrity": "sha512-xE5Vg6gGdMkZ1Ep6z9SJMMioGkkT1GbxS5Mm0U3Ey1/H68P0G7cJcyiVr1CARxFbLqKE4QUpoV1o6jz1Z5Yl9Q==", "dev": true, "license": "MIT" }, "node_modules/@cspell/dict-filetypes": { - "version": "3.0.16", - "resolved": "https://registry.npmjs.org/@cspell/dict-filetypes/-/dict-filetypes-3.0.16.tgz", - "integrity": "sha512-SyrtuK2/sx+cr94jOp2/uOAb43ngZEVISUTRj4SR6SfoGULVV1iJS7Drqn7Ul9HJ731QDttwWlOUgcQ+yMRblg==", + "version": "3.0.18", + "resolved": "https://registry.npmjs.org/@cspell/dict-filetypes/-/dict-filetypes-3.0.18.tgz", + "integrity": "sha512-yU7RKD/x1IWmDLzWeiItMwgV+6bUcU/af23uS0+uGiFUbsY1qWV/D4rxlAAO6Z7no3J2z8aZOkYIOvUrJq0Rcw==", "dev": true, "license": "MIT" }, @@ -1129,9 +1108,9 @@ "license": "MIT" }, "node_modules/@cspell/dict-fonts": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@cspell/dict-fonts/-/dict-fonts-4.0.5.tgz", - "integrity": "sha512-BbpkX10DUX/xzHs6lb7yzDf/LPjwYIBJHJlUXSBXDtK/1HaeS+Wqol4Mlm2+NAgZ7ikIE5DQMViTgBUY3ezNoQ==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@cspell/dict-fonts/-/dict-fonts-4.0.6.tgz", + "integrity": "sha512-aR/0csY01dNb0A1tw/UmN9rKgHruUxsYsvXu6YlSBJFu60s26SKr/k1o4LavpHTQ+lznlYMqAvuxGkE4Flliqw==", "dev": true, "license": "MIT" }, @@ -1143,9 +1122,9 @@ "license": "MIT" }, "node_modules/@cspell/dict-fullstack": { - "version": "3.2.8", - "resolved": "https://registry.npmjs.org/@cspell/dict-fullstack/-/dict-fullstack-3.2.8.tgz", - "integrity": "sha512-J6EeoeThvx/DFrcA2rJiCA6vfqwJMbkG0IcXhlsmRZmasIpanmxgt90OEaUazbZahFiuJT8wrhgQ1QgD1MsqBw==", + "version": "3.2.9", + "resolved": "https://registry.npmjs.org/@cspell/dict-fullstack/-/dict-fullstack-3.2.9.tgz", + "integrity": "sha512-diZX+usW5aZ4/b2T0QM/H/Wl9aNMbdODa1Jq0ReBr/jazmNeWjd+PyqeVgzd1joEaHY+SAnjrf/i9CwKd2ZtWQ==", "dev": true, "license": "MIT" }, @@ -1185,9 +1164,9 @@ "license": "MIT" }, "node_modules/@cspell/dict-html": { - "version": "4.0.14", - "resolved": "https://registry.npmjs.org/@cspell/dict-html/-/dict-html-4.0.14.tgz", - "integrity": "sha512-2bf7n+kS92g+cMKV0wr9o/Oq9n8JzU7CcrB96gIh2GHgnF+0xDOqO2W/1KeFAqOfqosoOVE48t+4dnEMkkoJ2Q==", + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@cspell/dict-html/-/dict-html-4.0.15.tgz", + "integrity": "sha512-GJYnYKoD9fmo2OI0aySEGZOjThnx3upSUvV7mmqUu8oG+mGgzqm82P/f7OqsuvTaInZZwZbo+PwJQd/yHcyFIw==", "dev": true, "license": "MIT", "peer": true @@ -1257,14 +1236,14 @@ "license": "MIT" }, "node_modules/@cspell/dict-markdown": { - "version": "2.0.14", - "resolved": "https://registry.npmjs.org/@cspell/dict-markdown/-/dict-markdown-2.0.14.tgz", - "integrity": "sha512-uLKPNJsUcumMQTsZZgAK9RgDLyQhUz/uvbQTEkvF/Q4XfC1i/BnA8XrOrd0+Vp6+tPOKyA+omI5LRWfMu5K/Lw==", + "version": "2.0.16", + "resolved": "https://registry.npmjs.org/@cspell/dict-markdown/-/dict-markdown-2.0.16.tgz", + "integrity": "sha512-976RRqKv6cwhrxdFCQP2DdnBVB86BF57oQtPHy4Zbf4jF/i2Oy29MCrxirnOBalS1W6KQeto7NdfDXRAwkK4PQ==", "dev": true, "license": "MIT", "peerDependencies": { - "@cspell/dict-css": "^4.0.19", - "@cspell/dict-html": "^4.0.14", + "@cspell/dict-css": "^4.1.1", + "@cspell/dict-html": "^4.0.15", "@cspell/dict-html-symbol-entities": "^4.0.5", "@cspell/dict-typescript": "^3.2.3" } @@ -1284,9 +1263,9 @@ "license": "MIT" }, "node_modules/@cspell/dict-npm": { - "version": "5.2.35", - "resolved": "https://registry.npmjs.org/@cspell/dict-npm/-/dict-npm-5.2.35.tgz", - "integrity": "sha512-w0VIDUvzHSTt4S9pfvSatApxtCesLMFrDUYD0Wjtw91EBRkB2wVw/RV3q1Ni9Nzpx6pCFpcB7c1xBY8l22cyiQ==", + "version": "5.2.38", + "resolved": "https://registry.npmjs.org/@cspell/dict-npm/-/dict-npm-5.2.38.tgz", + "integrity": "sha512-21ucGRPYYhr91C2cDBoMPTrcIOStQv33xOqJB0JLoC5LAs2Sfj9EoPGhGb+gIFVHz6Ia7JQWE2SJsOVFJD1wmg==", "dev": true, "license": "MIT" }, @@ -1312,9 +1291,9 @@ "license": "MIT" }, "node_modules/@cspell/dict-python": { - "version": "4.2.25", - "resolved": "https://registry.npmjs.org/@cspell/dict-python/-/dict-python-4.2.25.tgz", - "integrity": "sha512-hDdN0YhKgpbtZVRjQ2c8jk+n0wQdidAKj1Fk8w7KEHb3YlY5uPJ0mAKJk7AJKPNLOlILoUmN+HAVJz+cfSbWYg==", + "version": "4.2.26", + "resolved": "https://registry.npmjs.org/@cspell/dict-python/-/dict-python-4.2.26.tgz", + "integrity": "sha512-hbjN6BjlSgZOG2dA2DtvYNGBM5Aq0i0dHaZjMOI9K/9vRicVvKbcCiBSSrR3b+jwjhQL5ff7HwG5xFaaci0GQA==", "dev": true, "license": "MIT", "dependencies": { @@ -1329,9 +1308,9 @@ "license": "MIT" }, "node_modules/@cspell/dict-ruby": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@cspell/dict-ruby/-/dict-ruby-5.1.0.tgz", - "integrity": "sha512-9PJQB3cfkBULrMLp5kSAcFPpzf8oz9vFN+QYZABhQwWkGbuzCIXSorHrmWSASlx4yejt3brjaWS57zZ/YL5ZQQ==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-ruby/-/dict-ruby-5.1.1.tgz", + "integrity": "sha512-LHrp84oEV6q1ZxPPyj4z+FdKyq1XAKYPtmGptrd+uwHbrF/Ns5+fy6gtSi7pS+uc0zk3JdO9w/tPK+8N1/7WUA==", "dev": true, "license": "MIT" }, @@ -1357,9 +1336,9 @@ "license": "MIT" }, "node_modules/@cspell/dict-software-terms": { - "version": "5.1.23", - "resolved": "https://registry.npmjs.org/@cspell/dict-software-terms/-/dict-software-terms-5.1.23.tgz", - "integrity": "sha512-YzxBeqP1j8+hg/+pmw7XHvYrQLO5ttDpZ0rqZiS7y2vnku3Cv1OQZgt9y/3SsTgcUPSCWSRHGgWfrMGqEGNB6g==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@cspell/dict-software-terms/-/dict-software-terms-5.2.2.tgz", + "integrity": "sha512-0CaYd6TAsKtEoA7tNswm1iptEblTzEe3UG8beG2cpSTHk7afWIVMtJLgXDv0f/Li67Lf3Z1Jf3JeXR7GsJ2TRw==", "dev": true, "license": "MIT" }, @@ -1427,25 +1406,6 @@ "node": ">=20" } }, - "node_modules/@cspell/eslint-plugin": { - "version": "9.6.4", - "resolved": "https://registry.npmjs.org/@cspell/eslint-plugin/-/eslint-plugin-9.6.4.tgz", - "integrity": "sha512-MldCPtfj7XWQY7bnnLS/7A/YfLKWGUQALg2hSNy6AQ28R26o1HesGYY27lzePa2sbgTTy2X1tONCyaOIkmnSmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cspell/cspell-types": "9.6.4", - "@cspell/url": "9.6.4", - "cspell-lib": "9.6.4", - "synckit": "^0.11.12" - }, - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "eslint": "^7 || ^8 || ^9" - } - }, "node_modules/@cspell/filetypes": { "version": "9.6.4", "resolved": "https://registry.npmjs.org/@cspell/filetypes/-/filetypes-9.6.4.tgz", @@ -1509,21 +1469,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", - "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.1.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", "dev": true, "license": "MIT", "optional": true, @@ -1532,9 +1492,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", - "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", "optional": true, @@ -1611,37 +1571,16 @@ "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@eslint/compat": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-1.4.1.tgz", - "integrity": "sha512-cfO82V9zxxGBxcQDr1lfaYB7wykTa0b00mGa36FrJl7iTFd0Z2cHfEYuxcBRP/iNijCsWsEkA+jzT8hGYmv33w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "peerDependencies": { - "eslint": "^8.40 || 9" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } - } - }, "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, "license": "Apache-2.0", "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", - "minimatch": "^3.1.2" + "minimatch": "^3.1.5" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1674,20 +1613,20 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", - "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "^6.12.4", + "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", - "minimatch": "^3.1.2", + "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, "engines": { @@ -1698,9 +1637,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.3", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", - "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", "dev": true, "license": "MIT", "engines": { @@ -1809,16 +1748,16 @@ "license": "MIT" }, "node_modules/@gerrit0/mini-shiki": { - "version": "3.22.0", - "resolved": "https://registry.npmjs.org/@gerrit0/mini-shiki/-/mini-shiki-3.22.0.tgz", - "integrity": "sha512-jMpciqEVUBKE1QwU64S4saNMzpsSza6diNCk4MWAeCxO2+LFi2FIFmL2S0VDLzEJCxuvCbU783xi8Hp/gkM5CQ==", + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@gerrit0/mini-shiki/-/mini-shiki-3.23.0.tgz", + "integrity": "sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==", "dev": true, "license": "MIT", "dependencies": { - "@shikijs/engine-oniguruma": "^3.22.0", - "@shikijs/langs": "^3.22.0", - "@shikijs/themes": "^3.22.0", - "@shikijs/types": "^3.22.0", + "@shikijs/engine-oniguruma": "^3.23.0", + "@shikijs/langs": "^3.23.0", + "@shikijs/themes": "^3.23.0", + "@shikijs/types": "^3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, @@ -2104,17 +2043,17 @@ } }, "node_modules/@jest/console": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.2.0.tgz", - "integrity": "sha512-+O1ifRjkvYIkBqASKWgLxrpEhQAAE7hY77ALLUufSk5717KfOShg6IbqLmdsLMPdUiFvA2kTs0R7YZy+l0IzZQ==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.3.0.tgz", + "integrity": "sha512-PAwCvFJ4696XP2qZj+LAn1BWjZaJ6RjG6c7/lkMaUJnkyMS34ucuIsfqYvfskVNvUI27R/u4P1HMYFnlVXG/Ww==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", - "jest-message-util": "30.2.0", - "jest-util": "30.2.0", + "jest-message-util": "30.3.0", + "jest-util": "30.3.0", "slash": "^3.0.0" }, "engines": { @@ -2122,39 +2061,38 @@ } }, "node_modules/@jest/core": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.2.0.tgz", - "integrity": "sha512-03W6IhuhjqTlpzh/ojut/pDB2LPRygyWX8ExpgHtQA8H/3K7+1vKmcINx5UzeOX1se6YEsBsOHQ1CRzf3fOwTQ==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.3.0.tgz", + "integrity": "sha512-U5mVPsBxLSO6xYbf+tgkymLx+iAhvZX43/xI1+ej2ZOPnPdkdO1CzDmFKh2mZBn2s4XZixszHeQnzp1gm/DIxw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.2.0", + "@jest/console": "30.3.0", "@jest/pattern": "30.0.1", - "@jest/reporters": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", + "@jest/reporters": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "ci-info": "^4.2.0", "exit-x": "^0.2.2", "graceful-fs": "^4.2.11", - "jest-changed-files": "30.2.0", - "jest-config": "30.2.0", - "jest-haste-map": "30.2.0", - "jest-message-util": "30.2.0", + "jest-changed-files": "30.3.0", + "jest-config": "30.3.0", + "jest-haste-map": "30.3.0", + "jest-message-util": "30.3.0", "jest-regex-util": "30.0.1", - "jest-resolve": "30.2.0", - "jest-resolve-dependencies": "30.2.0", - "jest-runner": "30.2.0", - "jest-runtime": "30.2.0", - "jest-snapshot": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", - "jest-watcher": "30.2.0", - "micromatch": "^4.0.8", - "pretty-format": "30.2.0", + "jest-resolve": "30.3.0", + "jest-resolve-dependencies": "30.3.0", + "jest-runner": "30.3.0", + "jest-runtime": "30.3.0", + "jest-snapshot": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", + "jest-watcher": "30.3.0", + "pretty-format": "30.3.0", "slash": "^3.0.0" }, "engines": { @@ -2170,9 +2108,9 @@ } }, "node_modules/@jest/diff-sequences": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", - "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.3.0.tgz", + "integrity": "sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA==", "dev": true, "license": "MIT", "engines": { @@ -2180,39 +2118,39 @@ } }, "node_modules/@jest/environment": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.2.0.tgz", - "integrity": "sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.3.0.tgz", + "integrity": "sha512-SlLSF4Be735yQXyh2+mctBOzNDx5s5uLv88/j8Qn1wH679PDcwy67+YdADn8NJnGjzlXtN62asGH/T4vWOkfaw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/fake-timers": "30.2.0", - "@jest/types": "30.2.0", + "@jest/fake-timers": "30.3.0", + "@jest/types": "30.3.0", "@types/node": "*", - "jest-mock": "30.2.0" + "jest-mock": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/expect": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.2.0.tgz", - "integrity": "sha512-V9yxQK5erfzx99Sf+7LbhBwNWEZ9eZay8qQ9+JSC0TrMR1pMDHLMY+BnVPacWU6Jamrh252/IKo4F1Xn/zfiqA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.3.0.tgz", + "integrity": "sha512-76Nlh4xJxk2D/9URCn3wFi98d2hb19uWE1idLsTt2ywhvdOldbw3S570hBgn25P4ICUZ/cBjybrBex2g17IDbg==", "dev": true, "license": "MIT", "dependencies": { - "expect": "30.2.0", - "jest-snapshot": "30.2.0" + "expect": "30.3.0", + "jest-snapshot": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/expect-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.2.0.tgz", - "integrity": "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.3.0.tgz", + "integrity": "sha512-j0+W5iQQ8hBh7tHZkTQv3q2Fh/M7Je72cIsYqC4OaktgtO7v1So9UTjp6uPBHIaB6beoF/RRsCgMJKvti0wADA==", "dev": true, "license": "MIT", "dependencies": { @@ -2223,18 +2161,18 @@ } }, "node_modules/@jest/fake-timers": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.2.0.tgz", - "integrity": "sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.3.0.tgz", + "integrity": "sha512-WUQDs8SOP9URStX1DzhD425CqbN/HxUYCTwVrT8sTVBfMvFqYt/s61EK5T05qnHu0po6RitXIvP9otZxYDzTGQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", - "@sinonjs/fake-timers": "^13.0.0", + "@jest/types": "30.3.0", + "@sinonjs/fake-timers": "^15.0.0", "@types/node": "*", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", - "jest-util": "30.2.0" + "jest-message-util": "30.3.0", + "jest-mock": "30.3.0", + "jest-util": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -2251,16 +2189,16 @@ } }, "node_modules/@jest/globals": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.2.0.tgz", - "integrity": "sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.3.0.tgz", + "integrity": "sha512-+owLCBBdfpgL3HU+BD5etr1SvbXpSitJK0is1kiYjJxAAJggYMRQz5hSdd5pq1sSggfxPbw2ld71pt4x5wwViA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.2.0", - "@jest/expect": "30.2.0", - "@jest/types": "30.2.0", - "jest-mock": "30.2.0" + "@jest/environment": "30.3.0", + "@jest/expect": "30.3.0", + "@jest/types": "30.3.0", + "jest-mock": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -2281,32 +2219,32 @@ } }, "node_modules/@jest/reporters": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.2.0.tgz", - "integrity": "sha512-DRyW6baWPqKMa9CzeiBjHwjd8XeAyco2Vt8XbcLFjiwCOEKOvy82GJ8QQnJE9ofsxCMPjH4MfH8fCWIHHDKpAQ==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.3.0.tgz", + "integrity": "sha512-a09z89S+PkQnL055bVj8+pe2Caed2PBOaczHcXCykW5ngxX9EWx/1uAwncxc/HiU0oZqfwseMjyhxgRjS49qPw==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", + "@jest/console": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", "@jridgewell/trace-mapping": "^0.3.25", "@types/node": "*", "chalk": "^4.1.2", "collect-v8-coverage": "^1.0.2", "exit-x": "^0.2.2", - "glob": "^10.3.10", + "glob": "^10.5.0", "graceful-fs": "^4.2.11", "istanbul-lib-coverage": "^3.0.0", "istanbul-lib-instrument": "^6.0.0", "istanbul-lib-report": "^3.0.0", "istanbul-lib-source-maps": "^5.0.0", "istanbul-reports": "^3.1.3", - "jest-message-util": "30.2.0", - "jest-util": "30.2.0", - "jest-worker": "30.2.0", + "jest-message-util": "30.3.0", + "jest-util": "30.3.0", + "jest-worker": "30.3.0", "slash": "^3.0.0", "string-length": "^4.0.2", "v8-to-istanbul": "^9.0.1" @@ -2324,9 +2262,9 @@ } }, "node_modules/@jest/reporters/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dev": true, "license": "MIT", "dependencies": { @@ -2409,13 +2347,13 @@ } }, "node_modules/@jest/snapshot-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.2.0.tgz", - "integrity": "sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.3.0.tgz", + "integrity": "sha512-ORbRN9sf5PP82v3FXNSwmO1OTDR2vzR2YTaR+E3VkSBZ8zadQE6IqYdYEeFH1NIkeB2HIGdF02dapb6K0Mj05g==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "natural-compare": "^1.4.0" @@ -2440,14 +2378,14 @@ } }, "node_modules/@jest/test-result": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.2.0.tgz", - "integrity": "sha512-RF+Z+0CCHkARz5HT9mcQCBulb1wgCP3FBvl9VFokMX27acKphwyQsNuWH3c+ojd1LeWBLoTYoxF0zm6S/66mjg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.3.0.tgz", + "integrity": "sha512-e/52nJGuD74AKTSe0P4y5wFRlaXP0qmrS17rqOMHeSwm278VyNyXE3gFO/4DTGF9w+65ra3lo3VKj0LBrzmgdQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.2.0", - "@jest/types": "30.2.0", + "@jest/console": "30.3.0", + "@jest/types": "30.3.0", "@types/istanbul-lib-coverage": "^2.0.6", "collect-v8-coverage": "^1.0.2" }, @@ -2456,15 +2394,15 @@ } }, "node_modules/@jest/test-sequencer": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.2.0.tgz", - "integrity": "sha512-wXKgU/lk8fKXMu/l5Hog1R61bL4q5GCdT6OJvdAFz1P+QrpoFuLU68eoKuVc4RbrTtNnTL5FByhWdLgOPSph+Q==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.3.0.tgz", + "integrity": "sha512-dgbWy9b8QDlQeRZcv7LNF+/jFiiYHTKho1xirauZ7kVwY7avjFF6uTT0RqlgudB5OuIPagFdVtfFMosjVbk1eA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "30.2.0", + "@jest/test-result": "30.3.0", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", + "jest-haste-map": "30.3.0", "slash": "^3.0.0" }, "engines": { @@ -2472,24 +2410,23 @@ } }, "node_modules/@jest/transform": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.2.0.tgz", - "integrity": "sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.3.0.tgz", + "integrity": "sha512-TLKY33fSLVd/lKB2YI1pH69ijyUblO/BQvCj566YvnwuzoTNr648iE0j22vRvVNk2HsPwByPxATg3MleS3gf5A==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@jridgewell/trace-mapping": "^0.3.25", "babel-plugin-istanbul": "^7.0.1", "chalk": "^4.1.2", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", + "jest-haste-map": "30.3.0", "jest-regex-util": "30.0.1", - "jest-util": "30.2.0", - "micromatch": "^4.0.8", + "jest-util": "30.3.0", "pirates": "^4.0.7", "slash": "^3.0.0", "write-file-atomic": "^5.0.1" @@ -2499,9 +2436,9 @@ } }, "node_modules/@jest/types": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", - "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.3.0.tgz", + "integrity": "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==", "dev": true, "license": "MIT", "dependencies": { @@ -2827,40 +2764,40 @@ "license": "Apache-2.0" }, "node_modules/@shikijs/engine-oniguruma": { - "version": "3.22.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.22.0.tgz", - "integrity": "sha512-DyXsOG0vGtNtl7ygvabHd7Mt5EY8gCNqR9Y7Lpbbd/PbJvgWrqaKzH1JW6H6qFkuUa8aCxoiYVv8/YfFljiQxA==", + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz", + "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==", "dev": true, "license": "MIT", "dependencies": { - "@shikijs/types": "3.22.0", + "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "node_modules/@shikijs/langs": { - "version": "3.22.0", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.22.0.tgz", - "integrity": "sha512-x/42TfhWmp6H00T6uwVrdTJGKgNdFbrEdhaDwSR5fd5zhQ1Q46bHq9EO61SCEWJR0HY7z2HNDMaBZp8JRmKiIA==", + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.23.0.tgz", + "integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==", "dev": true, "license": "MIT", "dependencies": { - "@shikijs/types": "3.22.0" + "@shikijs/types": "3.23.0" } }, "node_modules/@shikijs/themes": { - "version": "3.22.0", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.22.0.tgz", - "integrity": "sha512-o+tlOKqsr6FE4+mYJG08tfCFDS+3CG20HbldXeVoyP+cYSUxDhrFf3GPjE60U55iOkkjbpY2uC3It/eeja35/g==", + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.23.0.tgz", + "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==", "dev": true, "license": "MIT", "dependencies": { - "@shikijs/types": "3.22.0" + "@shikijs/types": "3.23.0" } }, "node_modules/@shikijs/types": { - "version": "3.22.0", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.22.0.tgz", - "integrity": "sha512-491iAekgKDBFE67z70Ok5a8KBMsQ2IJwOWw3us/7ffQkIBCyOQfm/aNwVMBUriP02QshIfgHCBSIYAl3u2eWjg==", + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz", + "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2906,9 +2843,9 @@ } }, "node_modules/@sinonjs/fake-timers": { - "version": "13.0.5", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", - "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "version": "15.3.2", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.3.2.tgz", + "integrity": "sha512-mrn35Jl2pCpns+mE3HaZa1yPN5EYCRgiMI+135COjr2hr8Cls9DXqIZ57vZe2cz7y2XVSq92tcs6kGQcT1J8Rw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -2949,16 +2886,16 @@ } }, "node_modules/@swc/core": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.11.tgz", - "integrity": "sha512-iLmLTodbYxU39HhMPaMUooPwO/zqJWvsqkrXv1ZI38rMb048p6N7qtAtTp37sw9NzSrvH6oli8EdDygo09IZ/w==", + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.24.tgz", + "integrity": "sha512-5Hj8aNasue7yusUt8LGCUe/AjM7RMAce8ZoyDyiFwx7Al+GbYKL+yE7g4sJk8vEr1dKIkTRARkNIJENc4CjkBQ==", "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "peer": true, "dependencies": { "@swc/counter": "^0.1.3", - "@swc/types": "^0.1.25" + "@swc/types": "^0.1.26" }, "engines": { "node": ">=10" @@ -2968,16 +2905,18 @@ "url": "https://opencollective.com/swc" }, "optionalDependencies": { - "@swc/core-darwin-arm64": "1.15.11", - "@swc/core-darwin-x64": "1.15.11", - "@swc/core-linux-arm-gnueabihf": "1.15.11", - "@swc/core-linux-arm64-gnu": "1.15.11", - "@swc/core-linux-arm64-musl": "1.15.11", - "@swc/core-linux-x64-gnu": "1.15.11", - "@swc/core-linux-x64-musl": "1.15.11", - "@swc/core-win32-arm64-msvc": "1.15.11", - "@swc/core-win32-ia32-msvc": "1.15.11", - "@swc/core-win32-x64-msvc": "1.15.11" + "@swc/core-darwin-arm64": "1.15.24", + "@swc/core-darwin-x64": "1.15.24", + "@swc/core-linux-arm-gnueabihf": "1.15.24", + "@swc/core-linux-arm64-gnu": "1.15.24", + "@swc/core-linux-arm64-musl": "1.15.24", + "@swc/core-linux-ppc64-gnu": "1.15.24", + "@swc/core-linux-s390x-gnu": "1.15.24", + "@swc/core-linux-x64-gnu": "1.15.24", + "@swc/core-linux-x64-musl": "1.15.24", + "@swc/core-win32-arm64-msvc": "1.15.24", + "@swc/core-win32-ia32-msvc": "1.15.24", + "@swc/core-win32-x64-msvc": "1.15.24" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" @@ -2989,9 +2928,9 @@ } }, "node_modules/@swc/core-darwin-arm64": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.11.tgz", - "integrity": "sha512-QoIupRWVH8AF1TgxYyeA5nS18dtqMuxNwchjBIwJo3RdwLEFiJq6onOx9JAxHtuPwUkIVuU2Xbp+jCJ7Vzmgtg==", + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.24.tgz", + "integrity": "sha512-uM5ZGfFXjtvtJ+fe448PVBEbn/CSxS3UAyLj3O9xOqKIWy3S6hPTXSPbszxkSsGDYKi+YFhzAsR4r/eXLxEQ0g==", "cpu": [ "arm64" ], @@ -3005,9 +2944,9 @@ } }, "node_modules/@swc/core-darwin-x64": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.11.tgz", - "integrity": "sha512-S52Gu1QtPSfBYDiejlcfp9GlN+NjTZBRRNsz8PNwBgSE626/FUf2PcllVUix7jqkoMC+t0rS8t+2/aSWlMuQtA==", + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.24.tgz", + "integrity": "sha512-fMIb/Zfn929pw25VMBhV7Ji2Dl+lCWtUPNdYJQYOke+00E5fcQ9ynxtP8+qhUo/HZc+mYQb1gJxwHM9vty+lXg==", "cpu": [ "x64" ], @@ -3021,9 +2960,9 @@ } }, "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.11.tgz", - "integrity": "sha512-lXJs8oXo6Z4yCpimpQ8vPeCjkgoHu5NoMvmJZ8qxDyU99KVdg6KwU9H79vzrmB+HfH+dCZ7JGMqMF//f8Cfvdg==", + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.24.tgz", + "integrity": "sha512-vOkjsyjjxnoYx3hMEWcGxQrMgnNrRm6WAegBXrN8foHtDAR+zpdhpGF5a4lj1bNPgXAvmysjui8cM1ov/Clkaw==", "cpu": [ "arm" ], @@ -3037,9 +2976,9 @@ } }, "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.11.tgz", - "integrity": "sha512-chRsz1K52/vj8Mfq/QOugVphlKPWlMh10V99qfH41hbGvwAU6xSPd681upO4bKiOr9+mRIZZW+EfJqY42ZzRyA==", + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.24.tgz", + "integrity": "sha512-h/oNu+upkXJ6Cicnq7YGVj9PkdfarLCdQa8l/FlHYvfv8CEiMaeeTnpLU7gSBH/rGxosM6Qkfa/J9mThGF9CLA==", "cpu": [ "arm64" ], @@ -3053,9 +2992,9 @@ } }, "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.11.tgz", - "integrity": "sha512-PYftgsTaGnfDK4m6/dty9ryK1FbLk+LosDJ/RJR2nkXGc8rd+WenXIlvHjWULiBVnS1RsjHHOXmTS4nDhe0v0w==", + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.24.tgz", + "integrity": "sha512-ZpF/pRe1guk6sKzQI9D1jAORtjTdNlyeXn9GDz8ophof/w2WhojRblvSDJaGe7rJjcPN8AaOkhwdRUh7q8oYIg==", "cpu": [ "arm64" ], @@ -3068,12 +3007,12 @@ "node": ">=10" } }, - "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.11.tgz", - "integrity": "sha512-DKtnJKIHiZdARyTKiX7zdRjiDS1KihkQWatQiCHMv+zc2sfwb4Glrodx2VLOX4rsa92NLR0Sw8WLcPEMFY1szQ==", + "node_modules/@swc/core-linux-ppc64-gnu": { + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.24.tgz", + "integrity": "sha512-QZEsZfisHTSJlmyChgDFNmKPb3W6Lhbfo/O76HhIngfEdnQNmukS38/VSe1feho+xkV5A5hETyCbx3sALBZKAQ==", "cpu": [ - "x64" + "ppc64" ], "license": "Apache-2.0 AND MIT", "optional": true, @@ -3084,12 +3023,12 @@ "node": ">=10" } }, - "node_modules/@swc/core-linux-x64-musl": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.11.tgz", - "integrity": "sha512-mUjjntHj4+8WBaiDe5UwRNHuEzLjIWBTSGTw0JT9+C9/Yyuh4KQqlcEQ3ro6GkHmBGXBFpGIj/o5VMyRWfVfWw==", + "node_modules/@swc/core-linux-s390x-gnu": { + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.24.tgz", + "integrity": "sha512-DLdJKVsJgglqQrJBuoUYNmzm3leI7kUZhLbZGHv42onfKsGf6JDS3+bzCUQfte/XOqDjh/tmmn1DR/CF/tCJFw==", "cpu": [ - "x64" + "s390x" ], "license": "Apache-2.0 AND MIT", "optional": true, @@ -3100,28 +3039,60 @@ "node": ">=10" } }, - "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.11.tgz", - "integrity": "sha512-ZkNNG5zL49YpaFzfl6fskNOSxtcZ5uOYmWBkY4wVAvgbSAQzLRVBp+xArGWh2oXlY/WgL99zQSGTv7RI5E6nzA==", + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.24.tgz", + "integrity": "sha512-IpLYfposPA/XLxYOKpRfeccl1p5dDa3+okZDHHTchBkXEaVCnq5MADPmIWwIYj1tudt7hORsEHccG5no6IUQRw==", "cpu": [ - "arm64" + "x64" ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ - "win32" + "linux" ], "engines": { "node": ">=10" } }, - "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.11.tgz", - "integrity": "sha512-6XnzORkZCQzvTQ6cPrU7iaT9+i145oLwnin8JrfsLG41wl26+5cNQ2XV3zcbrnFEV6esjOceom9YO1w9mGJByw==", + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.24.tgz", + "integrity": "sha512-JHy3fMSc0t/EPWgo74+OK5TGr51aElnzqfUPaiRf2qJ/BfX5CUCfMiWVBuhI7qmVMBnk1jTRnL/xZnOSHDPLYg==", "cpu": [ - "ia32" + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.24.tgz", + "integrity": "sha512-Txj+qUH1z2bUd1P3JvwByfjKFti3cptlAxhWgmunBUUxy/IW3CXLZ6l6Gk4liANadKkU71nIU1X30Z5vpMT3BA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.24.tgz", + "integrity": "sha512-15D/nl3XwrhFpMv+MADFOiVwv3FvH9j8c6Rf8EXBT3Q5LoMh8YnDnSgPYqw1JzPnksvsBX6QPXLiPqmcR/Z4qQ==", + "cpu": [ + "ia32" ], "license": "Apache-2.0 AND MIT", "optional": true, @@ -3133,9 +3104,9 @@ } }, "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.11.tgz", - "integrity": "sha512-IQ2n6af7XKLL6P1gIeZACskSxK8jWtoKpJWLZmdXTDj1MGzktUy4i+FvpdtxFmJWNavRWH1VmTr6kAubRDHeKw==", + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.24.tgz", + "integrity": "sha512-PR0PlTlPra2JbaDphrOAzm6s0v9rA0F17YzB+XbWD95B4g2cWcZY9LAeTa4xll70VLw9Jr7xBrlohqlQmelMFQ==", "cpu": [ "x64" ], @@ -3156,9 +3127,9 @@ "license": "Apache-2.0" }, "node_modules/@swc/types": { - "version": "0.1.25", - "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz", - "integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==", + "version": "0.1.26", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.26.tgz", + "integrity": "sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==", "devOptional": true, "license": "Apache-2.0", "dependencies": { @@ -3166,13 +3137,13 @@ } }, "node_modules/@testcontainers/postgresql": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-11.12.0.tgz", - "integrity": "sha512-w4ZK0H+WIYBUBk57H9wCSxPMSMZUNsFpx2MZAX4iru0Aevz9HFWDfAhFLAu+/SwsHtEJUD7XfWUDlqBGC3OF0Q==", + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-11.14.0.tgz", + "integrity": "sha512-wYbJn8GRTj8qfqzfVubxioYWlHJU/ImIjuzPwyy9C5Qfo6g3GLduPZAj+BifvqTZjgT3gd4gFVLCPhBji7dc1w==", "dev": true, "license": "MIT", "dependencies": { - "testcontainers": "^11.12.0" + "testcontainers": "^11.14.0" } }, "node_modules/@tsconfig/node10": { @@ -3424,19 +3395,19 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.10.13", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz", - "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "license": "MIT", "peer": true, "dependencies": { - "undici-types": "~7.16.0" + "undici-types": "~7.19.0" } }, "node_modules/@types/nodemailer": { - "version": "7.0.9", - "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.9.tgz", - "integrity": "sha512-vI8oF1M+8JvQhsId0Pc38BdUP2evenIIys7c7p+9OZXSPOH5c1dyINP1jT8xQ2xPuBUXmIC87s+91IZMDjH8Ow==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz", + "integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==", "dev": true, "license": "MIT", "dependencies": { @@ -3590,20 +3561,20 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz", - "integrity": "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz", + "integrity": "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.56.0", - "@typescript-eslint/type-utils": "8.56.0", - "@typescript-eslint/utils": "8.56.0", - "@typescript-eslint/visitor-keys": "8.56.0", + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/type-utils": "8.58.2", + "@typescript-eslint/utils": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3613,9 +3584,9 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.56.0", + "@typescript-eslint/parser": "^8.58.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { @@ -3629,17 +3600,17 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz", - "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.2.tgz", + "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.56.0", - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/typescript-estree": "8.56.0", - "@typescript-eslint/visitor-keys": "8.56.0", + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", "debug": "^4.4.3" }, "engines": { @@ -3651,18 +3622,18 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.0.tgz", - "integrity": "sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.2.tgz", + "integrity": "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.56.0", - "@typescript-eslint/types": "^8.56.0", + "@typescript-eslint/tsconfig-utils": "^8.58.2", + "@typescript-eslint/types": "^8.58.2", "debug": "^4.4.3" }, "engines": { @@ -3673,18 +3644,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz", - "integrity": "sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.2.tgz", + "integrity": "sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/visitor-keys": "8.56.0" + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3695,9 +3666,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.0.tgz", - "integrity": "sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.2.tgz", + "integrity": "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==", "dev": true, "license": "MIT", "engines": { @@ -3708,21 +3679,21 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.0.tgz", - "integrity": "sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.2.tgz", + "integrity": "sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/typescript-estree": "8.56.0", - "@typescript-eslint/utils": "8.56.0", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/utils": "8.58.2", "debug": "^4.4.3", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3733,13 +3704,13 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.0.tgz", - "integrity": "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.2.tgz", + "integrity": "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==", "dev": true, "license": "MIT", "peer": true, @@ -3752,21 +3723,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.0.tgz", - "integrity": "sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.2.tgz", + "integrity": "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.56.0", - "@typescript-eslint/tsconfig-utils": "8.56.0", - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/visitor-keys": "8.56.0", + "@typescript-eslint/project-service": "8.58.2", + "@typescript-eslint/tsconfig-utils": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", "debug": "^4.4.3", - "minimatch": "^9.0.5", + "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3776,47 +3747,60 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.2" + "brace-expansion": "^5.0.5" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/@typescript-eslint/utils": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.0.tgz", - "integrity": "sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.2.tgz", + "integrity": "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.56.0", - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/typescript-estree": "8.56.0" + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3827,17 +3811,17 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.0.tgz", - "integrity": "sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz", + "integrity": "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/types": "8.58.2", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -4384,9 +4368,9 @@ } }, "node_modules/archiver-utils/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dev": true, "license": "MIT", "dependencies": { @@ -4679,9 +4663,9 @@ } }, "node_modules/axe-core": { - "version": "4.11.1", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz", - "integrity": "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==", + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.3.tgz", + "integrity": "sha512-zBQouZixDTbo3jMGqHKyePxYxr1e5W8UdTmBQ7sNtaA9M2bE32daxxPLS/jojhKOHxQ7LWwPjfiwf/fhaJWzlg==", "dev": true, "license": "MPL-2.0", "engines": { @@ -4689,15 +4673,15 @@ } }, "node_modules/axios": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", - "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", "license": "MIT", "peer": true, "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "proxy-from-env": "^2.1.0" } }, "node_modules/axobject-query": { @@ -4711,9 +4695,9 @@ } }, "node_modules/b4a": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.4.tgz", - "integrity": "sha512-u20zJLDaSWpxaZ+zaAkEIB2dZZ1o+DF4T/MRbmsvGp9nletHOyiai19OzX1fF8xUBYsO1bPXxODvcd0978pnug==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", + "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", "dev": true, "license": "Apache-2.0", "peerDependencies": { @@ -4726,16 +4710,16 @@ } }, "node_modules/babel-jest": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", - "integrity": "sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.3.0.tgz", + "integrity": "sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/transform": "30.2.0", + "@jest/transform": "30.3.0", "@types/babel__core": "^7.20.5", "babel-plugin-istanbul": "^7.0.1", - "babel-preset-jest": "30.2.0", + "babel-preset-jest": "30.3.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "slash": "^3.0.0" @@ -4768,9 +4752,9 @@ } }, "node_modules/babel-plugin-jest-hoist": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.2.0.tgz", - "integrity": "sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.3.0.tgz", + "integrity": "sha512-+TRkByhsws6sfPjVaitzadk1I0F5sPvOVUH5tyTSzhePpsGIVrdeunHSw/C36QeocS95OOk8lunc4rlu5Anwsg==", "dev": true, "license": "MIT", "dependencies": { @@ -4808,13 +4792,13 @@ } }, "node_modules/babel-preset-jest": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.2.0.tgz", - "integrity": "sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.3.0.tgz", + "integrity": "sha512-6ZcUbWHC+dMz2vfzdNwi87Z1gQsLNK2uLuK1Q89R11xdvejcivlYYwDlEv0FHX3VwEXpbBQ9uufB/MUNpZGfhQ==", "dev": true, "license": "MIT", "dependencies": { - "babel-plugin-jest-hoist": "30.2.0", + "babel-plugin-jest-hoist": "30.3.0", "babel-preset-current-node-syntax": "^1.2.0" }, "engines": { @@ -4846,12 +4830,11 @@ } }, "node_modules/bare-fs": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.4.tgz", - "integrity": "sha512-POK4oplfA7P7gqvetNmCs4CNtm9fNsx+IAh7jH7GgU0OJdge2rso0R20TNWVq6VoWcCvsTdlNDaleLHGaKx8CA==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.0.tgz", + "integrity": "sha512-xzqKsCFxAek9aezYhjJuJRXBIaYlg/0OGDTZp+T8eYmYMlm66cs6cYko02drIyjN2CBbi+I6L7YfXyqpqtKRXA==", "dev": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", @@ -4872,12 +4855,11 @@ } }, "node_modules/bare-os": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", - "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", + "version": "3.8.7", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.8.7.tgz", + "integrity": "sha512-G4Gr1UsGeEy2qtDTZwL7JFLo2wapUarz7iTMcYcMFdS89AIQuBoyjgXZz0Utv7uHs3xA9LckhVbeBi8lEQrC+w==", "dev": true, "license": "Apache-2.0", - "optional": true, "engines": { "bare": ">=1.14.0" } @@ -4888,26 +4870,29 @@ "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", "dev": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "bare-os": "^3.0.1" } }, "node_modules/bare-stream": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz", - "integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==", + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.0.tgz", + "integrity": "sha512-3zAJRZMDFGjdn+RVnNpF9kuELw+0Fl3lpndM4NcEOhb9zwtSo/deETfuIwMSE5BXanA0FrN1qVjffGwAg2Y7EA==", "dev": true, "license": "Apache-2.0", - "optional": true, "dependencies": { - "streamx": "^2.21.0" + "streamx": "^2.25.0", + "teex": "^1.0.1" }, "peerDependencies": { + "bare-abort-controller": "*", "bare-buffer": "*", "bare-events": "*" }, "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, "bare-buffer": { "optional": true }, @@ -4917,12 +4902,11 @@ } }, "node_modules/bare-url": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", - "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.0.tgz", + "integrity": "sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA==", "dev": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "bare-path": "^3.0.0" } @@ -4973,14 +4957,11 @@ } }, "node_modules/bignumber.js": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", - "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-10.0.2.tgz", + "integrity": "sha512-E8Wp9O06QA6lneJ4aRUXKYf/1GIomqUEmUMwtIOMtDxf1U52ffJY+y7JBk/8wRafA8qOIqLnXQGqonYXZdBnFQ==", "license": "MIT", - "peer": true, - "engines": { - "node": "*" - } + "peer": true }, "node_modules/bintrees": { "version": "1.0.2", @@ -5075,19 +5056,6 @@ "concat-map": "0.0.1" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/brotli": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", @@ -5584,14 +5552,13 @@ } }, "node_modules/comment-json": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.5.1.tgz", - "integrity": "sha512-taEtr3ozUmOB7it68Jll7s0Pwm+aoiHyXKrEC8SEodL4rNpdfDLqa7PfBlrgFoCNNdR8ImL+muti5IGvktJAAg==", + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.6.2.tgz", + "integrity": "sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w==", "dev": true, "license": "MIT", "dependencies": { "array-timsort": "^1.0.3", - "core-util-is": "^1.0.3", "esprima": "^4.0.1" }, "engines": { @@ -6135,9 +6102,9 @@ } }, "node_modules/docker-compose": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/docker-compose/-/docker-compose-1.3.1.tgz", - "integrity": "sha512-rF0wH69G3CCcmkN9J1RVMQBaKe8o77LT/3XmqcLIltWWVxcWAzp2TnO7wS3n/umZHN3/EVrlT3exSBMal+Ou1w==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/docker-compose/-/docker-compose-1.4.2.tgz", + "integrity": "sha512-rPHigTKGaEHpkUmfd69QgaOp+Os5vGJwG/Ry8lcr8W/382AmI+z/D7qoa9BybKIkqNppaIbs8RYeHSevdQjWww==", "dev": true, "license": "MIT", "dependencies": { @@ -6148,9 +6115,9 @@ } }, "node_modules/docker-modem": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.6.tgz", - "integrity": "sha512-ens7BiayssQz/uAxGzH8zGXCtiV24rRWXdjNha5V4zSOcxmAZsfGVm/PPFbwQdqEkDnhG+SyR9E3zSHUbOKXBQ==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.7.tgz", + "integrity": "sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -6179,16 +6146,16 @@ } }, "node_modules/dockerode": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.9.tgz", - "integrity": "sha512-iND4mcOWhPaCNh54WmK/KoSb35AFqPAUWFMffTQcp52uQt36b5uNwEJTSXntJZBbeGad72Crbi/hvDIv6us/6Q==", + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.10.tgz", + "integrity": "sha512-8L/P9JynLBiG7/coiA4FlQXegHltRqS0a+KqI44P1zgQh8QLHTg7FKOwhkBgSJwZTeHsq30WRoVFLuwkfK0YFg==", "dev": true, "license": "Apache-2.0", "dependencies": { "@balena/dockerignore": "^1.0.2", "@grpc/grpc-js": "^1.11.1", "@grpc/proto-loader": "^0.7.13", - "docker-modem": "^5.0.6", + "docker-modem": "^5.0.7", "protobufjs": "^7.3.2", "tar-fs": "^2.1.4", "uuid": "^10.0.0" @@ -6652,26 +6619,26 @@ } }, "node_modules/eslint": { - "version": "9.39.3", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz", - "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", + "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.3", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "ajv": "^6.12.4", + "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", @@ -6690,7 +6657,7 @@ "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -6759,86 +6726,717 @@ "jsonc-eslint-parser": "3.1.0" } }, - "node_modules/eslint-import-resolver-node": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", - "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^3.2.7", - "is-core-module": "^2.13.0", - "resolve": "^1.22.4" - } - }, - "node_modules/eslint-import-resolver-node/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "node_modules/eslint-config-service-soft/node_modules/@angular-eslint/builder": { + "version": "20.7.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/builder/-/builder-20.7.0.tgz", + "integrity": "sha512-qgf4Cfs1z0VsVpzF/OnxDRvBp60OIzeCsp4mzlckWYVniKo19EPIN6kFDol5eTAIOMPgiBQlMIwgQMHgocXEig==", "dev": true, "license": "MIT", "dependencies": { - "ms": "^2.1.1" + "@angular-devkit/architect": ">= 0.2000.0 < 0.2100.0", + "@angular-devkit/core": ">= 20.0.0 < 21.0.0" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": "*" } }, - "node_modules/eslint-json-compat-utils": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/eslint-json-compat-utils/-/eslint-json-compat-utils-0.2.2.tgz", - "integrity": "sha512-KcTUifi8VSSHkrOY0FzB7smuTZRU9T2nCrcCy6k2b+Q77+uylBQVIxN4baVCIWvWJEpud+IsrYgco4JJ6io05g==", + "node_modules/eslint-config-service-soft/node_modules/@angular-eslint/eslint-plugin": { + "version": "20.7.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin/-/eslint-plugin-20.7.0.tgz", + "integrity": "sha512-aHH2YTiaonojsKN+y2z4IMugCwdsH/dYIjYBig6kfoSPyf9rGK4zx+gnNGq/pGRjF3bOYrmFgIviYpQVb80inQ==", "dev": true, "license": "MIT", "dependencies": { - "esquery": "^1.6.0" - }, - "engines": { - "node": ">=12" + "@angular-eslint/bundled-angular-compiler": "20.7.0", + "@angular-eslint/utils": "20.7.0", + "ts-api-utils": "^2.1.0" }, "peerDependencies": { - "eslint": "*", - "jsonc-eslint-parser": "^2.4.0 || ^3.0.0" - }, - "peerDependenciesMeta": { - "@eslint/json": { - "optional": true - } + "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": "*" } }, - "node_modules/eslint-module-utils": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", - "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "node_modules/eslint-config-service-soft/node_modules/@angular-eslint/eslint-plugin-template": { + "version": "20.7.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-20.7.0.tgz", + "integrity": "sha512-WFmvW2vBR6ExsSKEaActQTteyw6ikWyuJau9XmWEPFd+2eusEt/+wO21ybjDn3uc5FTp1IcdhfYy+U5OdDjH5w==", "dev": true, "license": "MIT", "dependencies": { - "debug": "^3.2.7" - }, - "engines": { - "node": ">=4" + "@angular-eslint/bundled-angular-compiler": "20.7.0", + "@angular-eslint/utils": "20.7.0", + "aria-query": "5.3.2", + "axobject-query": "4.1.0" }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } + "peerDependencies": { + "@angular-eslint/template-parser": "20.7.0", + "@typescript-eslint/types": "^7.11.0 || ^8.0.0", + "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": "*" } }, - "node_modules/eslint-module-utils/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "node_modules/eslint-config-service-soft/node_modules/@angular-eslint/template-parser": { + "version": "20.7.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/template-parser/-/template-parser-20.7.0.tgz", + "integrity": "sha512-CVskZnF38IIxVVlKWi1VCz7YH/gHMJu2IY9bD1AVoBBGIe0xA4FRXJkW2Y+EDs9vQqZTkZZljhK5gL65Ro1PeQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "ms": "^2.1.1" + "@angular-eslint/bundled-angular-compiler": "20.7.0", + "eslint-scope": "^9.0.0" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": "*" } }, - "node_modules/eslint-plugin-escompat": { - "version": "3.11.4", - "resolved": "https://registry.npmjs.org/eslint-plugin-escompat/-/eslint-plugin-escompat-3.11.4.tgz", - "integrity": "sha512-j0ywwNnIufshOzgAu+PfIig1c7VRClKSNKzpniMT2vXQ4leL5q+e/SpMFQU0nrdL2WFFM44XmhSuwmxb3G0CJg==", + "node_modules/eslint-config-service-soft/node_modules/@angular-eslint/utils": { + "version": "20.7.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/utils/-/utils-20.7.0.tgz", + "integrity": "sha512-B6EJHbsk2W/lnS3kS/gm56VGvX735419z/DzgbRDcOvqMGMLwD1ILzv5OTEcL1rzpnB0AHW+IxOu6y/aCzSNUA==", "dev": true, "license": "MIT", "dependencies": { - "browserslist": "^4.23.1" + "@angular-eslint/bundled-angular-compiler": "20.7.0" + }, + "peerDependencies": { + "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": "*" + } + }, + "node_modules/eslint-config-service-soft/node_modules/@babel/eslint-parser": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.28.6.tgz", + "integrity": "sha512-QGmsKi2PBO/MHSQk+AAgA9R6OHQr+VqnniFE0eMWZcVcfBZoA2dKn2hUsl3Csg/Plt9opRUWdY7//VXsrIlEiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nicolo-ribaudo/eslint-scope-5-internals": "5.1.1-v1", + "eslint-visitor-keys": "^2.1.0", + "semver": "^6.3.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || >=14.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0", + "eslint": "^7.5.0 || ^8.0.0 || ^9.0.0" + } + }, + "node_modules/eslint-config-service-soft/node_modules/@babel/eslint-parser/node_modules/eslint-visitor-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", + "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10" + } + }, + "node_modules/eslint-config-service-soft/node_modules/@cspell/eslint-plugin": { + "version": "9.6.4", + "resolved": "https://registry.npmjs.org/@cspell/eslint-plugin/-/eslint-plugin-9.6.4.tgz", + "integrity": "sha512-MldCPtfj7XWQY7bnnLS/7A/YfLKWGUQALg2hSNy6AQ28R26o1HesGYY27lzePa2sbgTTy2X1tONCyaOIkmnSmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-types": "9.6.4", + "@cspell/url": "9.6.4", + "cspell-lib": "9.6.4", + "synckit": "^0.11.12" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "eslint": "^7 || ^8 || ^9" + } + }, + "node_modules/eslint-config-service-soft/node_modules/@eslint/compat": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-1.4.1.tgz", + "integrity": "sha512-cfO82V9zxxGBxcQDr1lfaYB7wykTa0b00mGa36FrJl7iTFd0Z2cHfEYuxcBRP/iNijCsWsEkA+jzT8hGYmv33w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "eslint": "^8.40 || 9" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-config-service-soft/node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz", + "integrity": "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.56.0", + "@typescript-eslint/type-utils": "8.56.0", + "@typescript-eslint/utils": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.56.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/eslint-config-service-soft/node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint-config-service-soft/node_modules/@typescript-eslint/parser": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz", + "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.56.0", + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/eslint-config-service-soft/node_modules/@typescript-eslint/project-service": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.0.tgz", + "integrity": "sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.56.0", + "@typescript-eslint/types": "^8.56.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/eslint-config-service-soft/node_modules/@typescript-eslint/scope-manager": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz", + "integrity": "sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/eslint-config-service-soft/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.0.tgz", + "integrity": "sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/eslint-config-service-soft/node_modules/@typescript-eslint/type-utils": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.0.tgz", + "integrity": "sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.0", + "@typescript-eslint/utils": "8.56.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/eslint-config-service-soft/node_modules/@typescript-eslint/types": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.0.tgz", + "integrity": "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/eslint-config-service-soft/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.0.tgz", + "integrity": "sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.56.0", + "@typescript-eslint/tsconfig-utils": "8.56.0", + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/eslint-config-service-soft/node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/eslint-config-service-soft/node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/eslint-config-service-soft/node_modules/@typescript-eslint/utils": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.0.tgz", + "integrity": "sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.56.0", + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/eslint-config-service-soft/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.0.tgz", + "integrity": "sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/eslint-config-service-soft/node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-service-soft/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/eslint-config-service-soft/node_modules/eslint-plugin-github": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-github/-/eslint-plugin-github-6.0.0.tgz", + "integrity": "sha512-J8MvUoiR/TU/Y9NnEmg1AnbvMUj9R6IO260z47zymMLLvso7B4c80IKjd8diqmqtSmeXXlbIus4i0SvK84flag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint/compat": "^1.2.3", + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "^9.14.0", + "@github/browserslist-config": "^1.0.0", + "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/parser": "^8.0.0", + "aria-query": "^5.3.0", + "eslint-config-prettier": ">=8.0.0", + "eslint-plugin-escompat": "^3.11.3", + "eslint-plugin-eslint-comments": "^3.2.0", + "eslint-plugin-filenames": "^1.3.2", + "eslint-plugin-i18n-text": "^1.0.1", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jsx-a11y": "^6.10.2", + "eslint-plugin-no-only-tests": "^3.0.0", + "eslint-plugin-prettier": "^5.2.1", + "eslint-rule-documentation": ">=1.0.0", + "globals": "^16.0.0", + "jsx-ast-utils": "^3.3.2", + "prettier": "^3.0.0", + "svg-element-attributes": "^1.3.1", + "typescript": "^5.7.3", + "typescript-eslint": "^8.14.0" + }, + "bin": { + "eslint-ignore-errors": "bin/eslint-ignore-errors.js" + }, + "peerDependencies": { + "eslint": "^8 || ^9" + } + }, + "node_modules/eslint-config-service-soft/node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-config-service-soft/node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-config-service-soft/node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-config-service-soft/node_modules/eslint-plugin-promise": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-7.2.1.tgz", + "integrity": "sha512-SWKjd+EuvWkYaS+uN2csvj0KoP43YTu7+phKQ5v+xw6+A0gutVX2yqCeCkC3uLCJFiPfR2dD8Es5L7yUsmvEaA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" + } + }, + "node_modules/eslint-config-service-soft/node_modules/eslint-plugin-unused-imports": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.4.1.tgz", + "integrity": "sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0", + "eslint": "^10.0.0 || ^9.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + } + } + }, + "node_modules/eslint-config-service-soft/node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-config-service-soft/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", + "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.16.1", + "resolve": "^2.0.0-next.6" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/resolve": { + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-json-compat-utils": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/eslint-json-compat-utils/-/eslint-json-compat-utils-0.2.2.tgz", + "integrity": "sha512-KcTUifi8VSSHkrOY0FzB7smuTZRU9T2nCrcCy6k2b+Q77+uylBQVIxN4baVCIWvWJEpud+IsrYgco4JJ6io05g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esquery": "^1.6.0" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "eslint": "*", + "jsonc-eslint-parser": "^2.4.0 || ^3.0.0" + }, + "peerDependenciesMeta": { + "@eslint/json": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-escompat": { + "version": "3.11.4", + "resolved": "https://registry.npmjs.org/eslint-plugin-escompat/-/eslint-plugin-escompat-3.11.4.tgz", + "integrity": "sha512-j0ywwNnIufshOzgAu+PfIig1c7VRClKSNKzpniMT2vXQ4leL5q+e/SpMFQU0nrdL2WFFM44XmhSuwmxb3G0CJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.23.1" }, "peerDependencies": { "eslint": ">=5.14.1" @@ -6890,57 +7488,6 @@ "eslint": "*" } }, - "node_modules/eslint-plugin-github": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-github/-/eslint-plugin-github-6.0.0.tgz", - "integrity": "sha512-J8MvUoiR/TU/Y9NnEmg1AnbvMUj9R6IO260z47zymMLLvso7B4c80IKjd8diqmqtSmeXXlbIus4i0SvK84flag==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint/compat": "^1.2.3", - "@eslint/eslintrc": "^3.1.0", - "@eslint/js": "^9.14.0", - "@github/browserslist-config": "^1.0.0", - "@typescript-eslint/eslint-plugin": "^8.0.0", - "@typescript-eslint/parser": "^8.0.0", - "aria-query": "^5.3.0", - "eslint-config-prettier": ">=8.0.0", - "eslint-plugin-escompat": "^3.11.3", - "eslint-plugin-eslint-comments": "^3.2.0", - "eslint-plugin-filenames": "^1.3.2", - "eslint-plugin-i18n-text": "^1.0.1", - "eslint-plugin-import": "^2.31.0", - "eslint-plugin-jsx-a11y": "^6.10.2", - "eslint-plugin-no-only-tests": "^3.0.0", - "eslint-plugin-prettier": "^5.2.1", - "eslint-rule-documentation": ">=1.0.0", - "globals": "^16.0.0", - "jsx-ast-utils": "^3.3.2", - "prettier": "^3.0.0", - "svg-element-attributes": "^1.3.1", - "typescript": "^5.7.3", - "typescript-eslint": "^8.14.0" - }, - "bin": { - "eslint-ignore-errors": "bin/eslint-ignore-errors.js" - }, - "peerDependencies": { - "eslint": "^8 || ^9" - } - }, - "node_modules/eslint-plugin-github/node_modules/globals": { - "version": "16.5.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", - "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/eslint-plugin-i18n-text": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/eslint-plugin-i18n-text/-/eslint-plugin-i18n-text-1.0.1.tgz", @@ -6951,60 +7498,6 @@ "eslint": ">=5.0.0" } }, - "node_modules/eslint-plugin-import": { - "version": "2.32.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", - "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rtsao/scc": "^1.1.0", - "array-includes": "^3.1.9", - "array.prototype.findlastindex": "^1.2.6", - "array.prototype.flat": "^1.3.3", - "array.prototype.flatmap": "^1.3.3", - "debug": "^3.2.7", - "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.12.1", - "hasown": "^2.0.2", - "is-core-module": "^2.16.1", - "is-glob": "^4.0.3", - "minimatch": "^3.1.2", - "object.fromentries": "^2.0.8", - "object.groupby": "^1.0.3", - "object.values": "^1.2.1", - "semver": "^6.3.1", - "string.prototype.trimend": "^1.0.9", - "tsconfig-paths": "^3.15.0" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" - } - }, - "node_modules/eslint-plugin-import/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-import/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/eslint-plugin-jsdoc": { "version": "62.7.0", "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-62.7.0.tgz", @@ -7125,41 +7618,11 @@ "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^1.1.0", - "levn": "^0.4.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/eslint-plugin-jsx-a11y": { - "version": "6.10.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", - "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "aria-query": "^5.3.2", - "array-includes": "^3.1.8", - "array.prototype.flatmap": "^1.3.2", - "ast-types-flow": "^0.0.8", - "axe-core": "^4.10.0", - "axobject-query": "^4.1.0", - "damerau-levenshtein": "^1.0.8", - "emoji-regex": "^9.2.2", - "hasown": "^2.0.2", - "jsx-ast-utils": "^3.3.5", - "language-tags": "^1.0.9", - "minimatch": "^3.1.2", - "object.fromentries": "^2.0.8", - "safe-regex-test": "^1.0.3", - "string.prototype.includes": "^2.0.1" + "@eslint/core": "^1.1.0", + "levn": "^0.4.1" }, "engines": { - "node": ">=4.0" - }, - "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/eslint-plugin-no-only-tests": { @@ -7203,25 +7666,6 @@ } } }, - "node_modules/eslint-plugin-promise": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-7.2.1.tgz", - "integrity": "sha512-SWKjd+EuvWkYaS+uN2csvj0KoP43YTu7+phKQ5v+xw6+A0gutVX2yqCeCkC3uLCJFiPfR2dD8Es5L7yUsmvEaA==", - "dev": true, - "license": "ISC", - "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" - } - }, "node_modules/eslint-plugin-sonarjs": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/eslint-plugin-sonarjs/-/eslint-plugin-sonarjs-4.0.2.tgz", @@ -7358,22 +7802,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint-plugin-unused-imports": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.4.1.tgz", - "integrity": "sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0", - "eslint": "^10.0.0 || ^9.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@typescript-eslint/eslint-plugin": { - "optional": true - } - } - }, "node_modules/eslint-rule-documentation": { "version": "1.0.23", "resolved": "https://registry.npmjs.org/eslint-rule-documentation/-/eslint-rule-documentation-1.0.23.tgz", @@ -7385,17 +7813,19 @@ } }, "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -7414,6 +7844,23 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", @@ -7566,18 +8013,18 @@ } }, "node_modules/expect": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-30.2.0.tgz", - "integrity": "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.3.0.tgz", + "integrity": "sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q==", "dev": true, "license": "MIT", "dependencies": { - "@jest/expect-utils": "30.2.0", + "@jest/expect-utils": "30.3.0", "@jest/get-type": "30.1.0", - "jest-matcher-utils": "30.2.0", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", - "jest-util": "30.2.0" + "jest-matcher-utils": "30.3.0", + "jest-message-util": "30.3.0", + "jest-mock": "30.3.0", + "jest-util": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -7736,19 +8183,6 @@ "node": ">=16.0.0" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -7822,9 +8256,9 @@ "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -8101,9 +8535,9 @@ } }, "node_modules/get-port": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.1.0.tgz", - "integrity": "sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.2.0.tgz", + "integrity": "sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg==", "dev": true, "license": "MIT", "engines": { @@ -9003,16 +9437,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/is-number-object": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", @@ -9317,17 +9741,17 @@ } }, "node_modules/jest": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest/-/jest-30.2.0.tgz", - "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.3.0.tgz", + "integrity": "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@jest/core": "30.2.0", - "@jest/types": "30.2.0", + "@jest/core": "30.3.0", + "@jest/types": "30.3.0", "import-local": "^3.2.0", - "jest-cli": "30.2.0" + "jest-cli": "30.3.0" }, "bin": { "jest": "bin/jest.js" @@ -9345,14 +9769,14 @@ } }, "node_modules/jest-changed-files": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.2.0.tgz", - "integrity": "sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.3.0.tgz", + "integrity": "sha512-B/7Cny6cV5At6M25EWDgf9S617lHivamL8vl6KEpJqkStauzcG4e+WPfDgMMF+H4FVH4A2PLRyvgDJan4441QA==", "dev": true, "license": "MIT", "dependencies": { "execa": "^5.1.1", - "jest-util": "30.2.0", + "jest-util": "30.3.0", "p-limit": "^3.1.0" }, "engines": { @@ -9360,29 +9784,29 @@ } }, "node_modules/jest-circus": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.2.0.tgz", - "integrity": "sha512-Fh0096NC3ZkFx05EP2OXCxJAREVxj1BcW/i6EWqqymcgYKWjyyDpral3fMxVcHXg6oZM7iULer9wGRFvfpl+Tg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.3.0.tgz", + "integrity": "sha512-PyXq5szeSfR/4f1lYqCmmQjh0vqDkURUYi9N6whnHjlRz4IUQfMcXkGLeEoiJtxtyPqgUaUUfyQlApXWBSN1RA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.2.0", - "@jest/expect": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/types": "30.2.0", + "@jest/environment": "30.3.0", + "@jest/expect": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "co": "^4.6.0", "dedent": "^1.6.0", "is-generator-fn": "^2.1.0", - "jest-each": "30.2.0", - "jest-matcher-utils": "30.2.0", - "jest-message-util": "30.2.0", - "jest-runtime": "30.2.0", - "jest-snapshot": "30.2.0", - "jest-util": "30.2.0", + "jest-each": "30.3.0", + "jest-matcher-utils": "30.3.0", + "jest-message-util": "30.3.0", + "jest-runtime": "30.3.0", + "jest-snapshot": "30.3.0", + "jest-util": "30.3.0", "p-limit": "^3.1.0", - "pretty-format": "30.2.0", + "pretty-format": "30.3.0", "pure-rand": "^7.0.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" @@ -9392,21 +9816,21 @@ } }, "node_modules/jest-cli": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.2.0.tgz", - "integrity": "sha512-Os9ukIvADX/A9sLt6Zse3+nmHtHaE6hqOsjQtNiugFTbKRHYIYtZXNGNK9NChseXy7djFPjndX1tL0sCTlfpAA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.3.0.tgz", + "integrity": "sha512-l6Tqx+j1fDXJEW5bqYykDQQ7mQg+9mhWXtnj+tQZrTWYHyHoi6Be8HPumDSA+UiX2/2buEgjA58iJzdj146uCw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/types": "30.2.0", + "@jest/core": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/types": "30.3.0", "chalk": "^4.1.2", "exit-x": "^0.2.2", "import-local": "^3.2.0", - "jest-config": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", + "jest-config": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", "yargs": "^17.7.2" }, "bin": { @@ -9425,34 +9849,33 @@ } }, "node_modules/jest-config": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.2.0.tgz", - "integrity": "sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.3.0.tgz", + "integrity": "sha512-WPMAkMAtNDY9P/oKObtsRG/6KTrhtgPJoBTmk20uDn4Uy6/3EJnnaZJre/FMT1KVRx8cve1r7/FlMIOfRVWL4w==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", "@jest/get-type": "30.1.0", "@jest/pattern": "30.0.1", - "@jest/test-sequencer": "30.2.0", - "@jest/types": "30.2.0", - "babel-jest": "30.2.0", + "@jest/test-sequencer": "30.3.0", + "@jest/types": "30.3.0", + "babel-jest": "30.3.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "deepmerge": "^4.3.1", - "glob": "^10.3.10", + "glob": "^10.5.0", "graceful-fs": "^4.2.11", - "jest-circus": "30.2.0", + "jest-circus": "30.3.0", "jest-docblock": "30.2.0", - "jest-environment-node": "30.2.0", + "jest-environment-node": "30.3.0", "jest-regex-util": "30.0.1", - "jest-resolve": "30.2.0", - "jest-runner": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", - "micromatch": "^4.0.8", + "jest-resolve": "30.3.0", + "jest-runner": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", "parse-json": "^5.2.0", - "pretty-format": "30.2.0", + "pretty-format": "30.3.0", "slash": "^3.0.0", "strip-json-comments": "^3.1.1" }, @@ -9477,9 +9900,9 @@ } }, "node_modules/jest-config/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dev": true, "license": "MIT", "dependencies": { @@ -9549,16 +9972,16 @@ } }, "node_modules/jest-diff": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", - "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.3.0.tgz", + "integrity": "sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/diff-sequences": "30.0.1", + "@jest/diff-sequences": "30.3.0", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "pretty-format": "30.2.0" + "pretty-format": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -9578,57 +10001,57 @@ } }, "node_modules/jest-each": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.2.0.tgz", - "integrity": "sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.3.0.tgz", + "integrity": "sha512-V8eMndg/aZ+3LnCJgSm13IxS5XSBM22QSZc9BtPK8Dek6pm+hfUNfwBdvsB3d342bo1q7wnSkC38zjX259qZNA==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "chalk": "^4.1.2", - "jest-util": "30.2.0", - "pretty-format": "30.2.0" + "jest-util": "30.3.0", + "pretty-format": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-environment-node": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.2.0.tgz", - "integrity": "sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.3.0.tgz", + "integrity": "sha512-4i6HItw/JSiJVsC5q0hnKIe/hbYfZLVG9YJ/0pU9Hz2n/9qZe3Rhn5s5CUZA5ORZlcdT/vmAXRMyONXJwPrmYQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.2.0", - "@jest/fake-timers": "30.2.0", - "@jest/types": "30.2.0", + "@jest/environment": "30.3.0", + "@jest/fake-timers": "30.3.0", + "@jest/types": "30.3.0", "@types/node": "*", - "jest-mock": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0" + "jest-mock": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-haste-map": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.2.0.tgz", - "integrity": "sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.3.0.tgz", + "integrity": "sha512-mMi2oqG4KRU0R9QEtscl87JzMXfUhbKaFqOxmjb2CKcbHcUGFrJCBWHmnTiUqi6JcnzoBlO4rWfpdl2k/RfLCA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/node": "*", "anymatch": "^3.1.3", "fb-watchman": "^2.0.2", "graceful-fs": "^4.2.11", "jest-regex-util": "30.0.1", - "jest-util": "30.2.0", - "jest-worker": "30.2.0", - "micromatch": "^4.0.8", + "jest-util": "30.3.0", + "jest-worker": "30.3.0", + "picomatch": "^4.0.3", "walker": "^1.0.8" }, "engines": { @@ -9639,49 +10062,49 @@ } }, "node_modules/jest-leak-detector": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.2.0.tgz", - "integrity": "sha512-M6jKAjyzjHG0SrQgwhgZGy9hFazcudwCNovY/9HPIicmNSBuockPSedAP9vlPK6ONFJ1zfyH/M2/YYJxOz5cdQ==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.3.0.tgz", + "integrity": "sha512-cuKmUUGIjfXZAiGJ7TbEMx0bcqNdPPI6P1V+7aF+m/FUJqFDxkFR4JqkTu8ZOiU5AaX/x0hZ20KaaIPXQzbMGQ==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", - "pretty-format": "30.2.0" + "pretty-format": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-matcher-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz", - "integrity": "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.3.0.tgz", + "integrity": "sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "jest-diff": "30.2.0", - "pretty-format": "30.2.0" + "jest-diff": "30.3.0", + "pretty-format": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-message-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", - "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.3.0.tgz", + "integrity": "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "micromatch": "^4.0.8", - "pretty-format": "30.2.0", + "picomatch": "^4.0.3", + "pretty-format": "30.3.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" }, @@ -9690,15 +10113,15 @@ } }, "node_modules/jest-mock": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", - "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.3.0.tgz", + "integrity": "sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/node": "*", - "jest-util": "30.2.0" + "jest-util": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -9733,18 +10156,18 @@ } }, "node_modules/jest-resolve": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.2.0.tgz", - "integrity": "sha512-TCrHSxPlx3tBY3hWNtRQKbtgLhsXa1WmbJEqBlTBrGafd5fiQFByy2GNCEoGR+Tns8d15GaL9cxEzKOO3GEb2A==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.3.0.tgz", + "integrity": "sha512-NRtTAHQlpd15F9rUR36jqwelbrDV/dY4vzNte3S2kxCKUJRYNd5/6nTSbYiak1VX5g8IoFF23Uj5TURkUW8O5g==", "dev": true, "license": "MIT", "dependencies": { "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", + "jest-haste-map": "30.3.0", "jest-pnp-resolver": "^1.2.3", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", "slash": "^3.0.0", "unrs-resolver": "^1.7.11" }, @@ -9753,46 +10176,46 @@ } }, "node_modules/jest-resolve-dependencies": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.2.0.tgz", - "integrity": "sha512-xTOIGug/0RmIe3mmCqCT95yO0vj6JURrn1TKWlNbhiAefJRWINNPgwVkrVgt/YaerPzY3iItufd80v3lOrFJ2w==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.3.0.tgz", + "integrity": "sha512-9ev8s3YN6Hsyz9LV75XUwkCVFlwPbaFn6Wp75qnI0wzAINYWY8Fb3+6y59Rwd3QaS3kKXffHXsZMziMavfz/nw==", "dev": true, "license": "MIT", "dependencies": { "jest-regex-util": "30.0.1", - "jest-snapshot": "30.2.0" + "jest-snapshot": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-runner": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.2.0.tgz", - "integrity": "sha512-PqvZ2B2XEyPEbclp+gV6KO/F1FIFSbIwewRgmROCMBo/aZ6J1w8Qypoj2pEOcg3G2HzLlaP6VUtvwCI8dM3oqQ==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.3.0.tgz", + "integrity": "sha512-gDv6C9LGKWDPLia9TSzZwf4h3kMQCqyTpq+95PODnTRDO0g9os48XIYYkS6D236vjpBir2fF63YmJFtqkS5Duw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.2.0", - "@jest/environment": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", + "@jest/console": "30.3.0", + "@jest/environment": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "emittery": "^0.13.1", "exit-x": "^0.2.2", "graceful-fs": "^4.2.11", "jest-docblock": "30.2.0", - "jest-environment-node": "30.2.0", - "jest-haste-map": "30.2.0", - "jest-leak-detector": "30.2.0", - "jest-message-util": "30.2.0", - "jest-resolve": "30.2.0", - "jest-runtime": "30.2.0", - "jest-util": "30.2.0", - "jest-watcher": "30.2.0", - "jest-worker": "30.2.0", + "jest-environment-node": "30.3.0", + "jest-haste-map": "30.3.0", + "jest-leak-detector": "30.3.0", + "jest-message-util": "30.3.0", + "jest-resolve": "30.3.0", + "jest-runtime": "30.3.0", + "jest-util": "30.3.0", + "jest-watcher": "30.3.0", + "jest-worker": "30.3.0", "p-limit": "^3.1.0", "source-map-support": "0.5.13" }, @@ -9801,32 +10224,32 @@ } }, "node_modules/jest-runtime": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.2.0.tgz", - "integrity": "sha512-p1+GVX/PJqTucvsmERPMgCPvQJpFt4hFbM+VN3n8TMo47decMUcJbt+rgzwrEme0MQUA/R+1de2axftTHkKckg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.3.0.tgz", + "integrity": "sha512-CgC+hIBJbuh78HEffkhNKcbXAytQViplcl8xupqeIWyKQF50kCQA8J7GeJCkjisC6hpnC9Muf8jV5RdtdFbGng==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.2.0", - "@jest/fake-timers": "30.2.0", - "@jest/globals": "30.2.0", + "@jest/environment": "30.3.0", + "@jest/fake-timers": "30.3.0", + "@jest/globals": "30.3.0", "@jest/source-map": "30.0.1", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "cjs-module-lexer": "^2.1.0", "collect-v8-coverage": "^1.0.2", - "glob": "^10.3.10", + "glob": "^10.5.0", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", + "jest-haste-map": "30.3.0", + "jest-message-util": "30.3.0", + "jest-mock": "30.3.0", "jest-regex-util": "30.0.1", - "jest-resolve": "30.2.0", - "jest-snapshot": "30.2.0", - "jest-util": "30.2.0", + "jest-resolve": "30.3.0", + "jest-snapshot": "30.3.0", + "jest-util": "30.3.0", "slash": "^3.0.0", "strip-bom": "^4.0.0" }, @@ -9835,9 +10258,9 @@ } }, "node_modules/jest-runtime/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dev": true, "license": "MIT", "dependencies": { @@ -9907,9 +10330,9 @@ } }, "node_modules/jest-snapshot": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.2.0.tgz", - "integrity": "sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.3.0.tgz", + "integrity": "sha512-f14c7atpb4O2DeNhwcvS810Y63wEn8O1HqK/luJ4F6M4NjvxmAKQwBUWjbExUtMxWJQ0wVgmCKymeJK6NZMnfQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9918,20 +10341,20 @@ "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/types": "^7.27.3", - "@jest/expect-utils": "30.2.0", + "@jest/expect-utils": "30.3.0", "@jest/get-type": "30.1.0", - "@jest/snapshot-utils": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", + "@jest/snapshot-utils": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", "babel-preset-current-node-syntax": "^1.2.0", "chalk": "^4.1.2", - "expect": "30.2.0", + "expect": "30.3.0", "graceful-fs": "^4.2.11", - "jest-diff": "30.2.0", - "jest-matcher-utils": "30.2.0", - "jest-message-util": "30.2.0", - "jest-util": "30.2.0", - "pretty-format": "30.2.0", + "jest-diff": "30.3.0", + "jest-matcher-utils": "30.3.0", + "jest-message-util": "30.3.0", + "jest-util": "30.3.0", + "pretty-format": "30.3.0", "semver": "^7.7.2", "synckit": "^0.11.8" }, @@ -9940,36 +10363,36 @@ } }, "node_modules/jest-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", - "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", + "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", - "picomatch": "^4.0.2" + "picomatch": "^4.0.3" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-validate": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.2.0.tgz", - "integrity": "sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.3.0.tgz", + "integrity": "sha512-I/xzC8h5G+SHCb2P2gWkJYrNiTbeL47KvKeW5EzplkyxzBRBw1ssSHlI/jXec0ukH2q7x2zAWQm7015iusg62Q==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "camelcase": "^6.3.0", "chalk": "^4.1.2", "leven": "^3.1.0", - "pretty-format": "30.2.0" + "pretty-format": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -9989,19 +10412,19 @@ } }, "node_modules/jest-watcher": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.2.0.tgz", - "integrity": "sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.3.0.tgz", + "integrity": "sha512-PJ1d9ThtTR8aMiBWUdcownq9mDdLXsQzJayTk4kmaBRHKvwNQn+ANveuhEBUyNI2hR1TVhvQ8D5kHubbzBHR/w==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "30.2.0", - "@jest/types": "30.2.0", + "@jest/test-result": "30.3.0", + "@jest/types": "30.3.0", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "emittery": "^0.13.1", - "jest-util": "30.2.0", + "jest-util": "30.3.0", "string-length": "^4.0.2" }, "engines": { @@ -10009,15 +10432,15 @@ } }, "node_modules/jest-worker": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.2.0.tgz", - "integrity": "sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.3.0.tgz", + "integrity": "sha512-DrCKkaQwHexjRUFTmPzs7sHQe0TSj9nvDALKGdwmK5mW9v7j90BudWirKAJHt3QQ9Dhrg1F7DogPzhChppkJpQ==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", "@ungap/structured-clone": "^1.3.0", - "jest-util": "30.2.0", + "jest-util": "30.3.0", "merge-stream": "^2.0.0", "supports-color": "^8.1.1" }, @@ -10698,33 +11121,6 @@ "dev": true, "license": "MIT" }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -10834,9 +11230,9 @@ "license": "MIT" }, "node_modules/nan": { - "version": "2.25.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.25.0.tgz", - "integrity": "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==", + "version": "2.26.2", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.26.2.tgz", + "integrity": "sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==", "dev": true, "license": "MIT", "optional": true @@ -10895,6 +11291,35 @@ "node": ">=6.0.0" } }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -10951,9 +11376,9 @@ "license": "MIT" }, "node_modules/nodemailer": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz", - "integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==", + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz", + "integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==", "license": "MIT-0", "engines": { "node": ">=6.0.0" @@ -11353,6 +11778,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/object.fromentries": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", @@ -11759,15 +12200,15 @@ } }, "node_modules/pg": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", - "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", "peer": true, "dependencies": { - "pg-connection-string": "^2.11.0", - "pg-pool": "^3.11.0", - "pg-protocol": "^1.11.0", + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, @@ -11794,9 +12235,9 @@ "optional": true }, "node_modules/pg-connection-string": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz", - "integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==", + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", "license": "MIT" }, "node_modules/pg-int8": { @@ -11809,18 +12250,18 @@ } }, "node_modules/pg-pool": { - "version": "3.11.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz", - "integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==", + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", "license": "MIT", "peerDependencies": { "pg": ">=8.0" } }, "node_modules/pg-protocol": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz", - "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==", + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", "license": "MIT" }, "node_modules/pg-types": { @@ -12034,9 +12475,9 @@ } }, "node_modules/preact": { - "version": "10.28.3", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.3.tgz", - "integrity": "sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==", + "version": "10.29.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.1.tgz", + "integrity": "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==", "license": "MIT", "peer": true, "funding": { @@ -12045,9 +12486,9 @@ } }, "node_modules/preact-render-to-string": { - "version": "6.6.5", - "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.6.5.tgz", - "integrity": "sha512-O6MHzYNIKYaiSX3bOw0gGZfEbOmlIDtDfWwN1JJdc/T3ihzRT6tGGSEWE088dWrEDGa1u7101q+6fzQnO9XCPA==", + "version": "6.6.7", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.6.7.tgz", + "integrity": "sha512-3XdbsX3+vn9dQW+jJI/FsI9rlkgl6dbeUpqLsChak6jp3j3auFqBCkno7VChbMFs5Q8ylBj6DrUkKRwtVN3nvw==", "license": "MIT", "peer": true, "peerDependencies": { @@ -12065,9 +12506,9 @@ } }, "node_modules/prettier": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", - "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.2.tgz", + "integrity": "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==", "dev": true, "license": "MIT", "peer": true, @@ -12095,9 +12536,9 @@ } }, "node_modules/pretty-format": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", - "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", + "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", "dev": true, "license": "MIT", "dependencies": { @@ -12221,15 +12662,18 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/pump": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", "dev": true, "license": "MIT", "dependencies": { @@ -12363,9 +12807,9 @@ } }, "node_modules/readdir-glob/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dev": true, "license": "MIT", "dependencies": { @@ -13377,9 +13821,9 @@ } }, "node_modules/streamx": { - "version": "2.23.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", - "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz", + "integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==", "dev": true, "license": "MIT", "dependencies": { @@ -13792,9 +14236,9 @@ } }, "node_modules/systeminformation": { - "version": "5.31.1", - "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.1.tgz", - "integrity": "sha512-6pRwxoGeV/roJYpsfcP6tN9mep6pPeCtXbUOCdVa0nme05Brwcwdge/fVNhIZn2wuUitAKZm4IYa7QjnRIa9zA==", + "version": "5.31.5", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.5.tgz", + "integrity": "sha512-5SyLdip4/3alxD4Kh+63bUQTJmu7YMfYQTC+koZy7X73HgNqZSD2P4wOZQWtUncvPvcEmnfIjCoygN4MRoEejQ==", "license": "MIT", "os": [ "darwin", @@ -13818,9 +14262,9 @@ } }, "node_modules/tar-fs": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", - "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz", + "integrity": "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==", "dev": true, "license": "MIT", "dependencies": { @@ -13833,13 +14277,14 @@ } }, "node_modules/tar-stream": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", - "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz", + "integrity": "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==", "dev": true, "license": "MIT", "dependencies": { "b4a": "^1.6.4", + "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } @@ -13853,6 +14298,16 @@ "bintrees": "1.0.2" } }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -13891,9 +14346,9 @@ } }, "node_modules/testcontainers": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-11.12.0.tgz", - "integrity": "sha512-VWtH+UQejVYYvb53ohEZRbx2naxyDvwO9lQ6A0VgmVE2Oh8r9EF09I+BfmrXpd9N9ntpzhao9di2yNwibSz5KA==", + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-11.14.0.tgz", + "integrity": "sha512-r9pniwv/iwzyHaI7gwAvAm4Y+IvjJg3vBWdjrUCaDMc2AXIr4jKbq7jJO18Mw2ybs73pZy1Aj7p/4RVBGMRWjg==", "dev": true, "license": "MIT", "dependencies": { @@ -13903,21 +14358,21 @@ "async-lock": "^1.4.1", "byline": "^5.0.0", "debug": "^4.4.3", - "docker-compose": "^1.3.1", - "dockerode": "^4.0.9", - "get-port": "^7.1.0", + "docker-compose": "^1.4.2", + "dockerode": "^4.0.10", + "get-port": "^7.2.0", "proper-lockfile": "^4.1.2", "properties-reader": "^3.0.1", "ssh-remote-port-forward": "^1.0.4", - "tar-fs": "^3.1.1", + "tar-fs": "^3.1.2", "tmp": "^0.2.5", - "undici": "^7.22.0" + "undici": "^7.24.5" } }, "node_modules/text-decoder": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.6.tgz", - "integrity": "sha512-27FeW5GQFDfw0FpwMQhMagB7BztOOlmjcSRi97t2oplhKVTZtp0DZbSegSaXS5IIC6mxMvBG4AR1Sgc6BX3CQg==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -13931,14 +14386,14 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -13978,19 +14433,6 @@ "node": ">= 0.4" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/to-valid-identifier": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/to-valid-identifier/-/to-valid-identifier-1.0.0.tgz", @@ -14024,9 +14466,9 @@ "license": "MIT" }, "node_modules/ts-api-utils": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", - "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { @@ -14037,19 +14479,19 @@ } }, "node_modules/ts-jest": { - "version": "29.4.6", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.6.tgz", - "integrity": "sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==", + "version": "29.4.9", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.9.tgz", + "integrity": "sha512-LTb9496gYPMCqjeDLdPrKuXtncudeV1yRZnF4Wo5l3SFi0RYEnYRNgMrFIdg+FHvfzjCyQk1cLncWVqiSX+EvQ==", "dev": true, "license": "MIT", "dependencies": { "bs-logger": "^0.2.6", "fast-json-stable-stringify": "^2.1.0", - "handlebars": "^4.7.8", + "handlebars": "^4.7.9", "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", - "semver": "^7.7.3", + "semver": "^7.7.4", "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, @@ -14066,7 +14508,7 @@ "babel-jest": "^29.0.0 || ^30.0.0", "jest": "^29.0.0 || ^30.0.0", "jest-util": "^29.0.0 || ^30.0.0", - "typescript": ">=4.3 <6" + "typescript": ">=4.3 <7" }, "peerDependenciesMeta": { "@babel/core": { @@ -14089,6 +14531,19 @@ } } }, + "node_modules/ts-jest/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/ts-jest/node_modules/type-fest": { "version": "4.41.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", @@ -14323,17 +14778,17 @@ } }, "node_modules/typedoc": { - "version": "0.28.17", - "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.28.17.tgz", - "integrity": "sha512-ZkJ2G7mZrbxrKxinTQMjFqsCoYY6a5Luwv2GKbTnBCEgV2ihYm5CflA9JnJAwH0pZWavqfYxmDkFHPt4yx2oDQ==", + "version": "0.28.19", + "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.28.19.tgz", + "integrity": "sha512-wKh+lhdmMFivMlc6vRRcMGXeGEHGU2g8a2CkPTJjJlwRf1iXbimWIPcFolCqe4E0d/FRtGszpIrsp3WLpDB8Pw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@gerrit0/mini-shiki": "^3.17.0", + "@gerrit0/mini-shiki": "^3.23.0", "lunr": "^2.3.9", - "markdown-it": "^14.1.0", - "minimatch": "^9.0.5", - "yaml": "^2.8.1" + "markdown-it": "^14.1.1", + "minimatch": "^10.2.5", + "yaml": "^2.8.3" }, "bin": { "typedoc": "bin/typedoc" @@ -14343,30 +14798,43 @@ "pnpm": ">= 10" }, "peerDependencies": { - "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x" + "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x" + } + }, + "node_modules/typedoc/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/typedoc/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/typedoc/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.2" + "brace-expansion": "^5.0.5" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -14556,16 +15024,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.0.tgz", - "integrity": "sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.2.tgz", + "integrity": "sha512-V8iSng9mRbdZjl54VJ9NKr6ZB+dW0J3TzRXRGcSbLIej9jV86ZRtlYeTKDR/QLxXykocJ5icNzbsl2+5TzIvcQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.56.0", - "@typescript-eslint/parser": "8.56.0", - "@typescript-eslint/typescript-estree": "8.56.0", - "@typescript-eslint/utils": "8.56.0" + "@typescript-eslint/eslint-plugin": "8.58.2", + "@typescript-eslint/parser": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/utils": "8.58.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -14576,7 +15044,7 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/uc.micro": { @@ -14619,9 +15087,9 @@ } }, "node_modules/undici": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz", - "integrity": "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", "dev": true, "license": "MIT", "engines": { @@ -14629,9 +15097,9 @@ } }, "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", "license": "MIT" }, "node_modules/unicode-properties": { diff --git a/package.json b/package.json index 6b13cc3..a5dfa05 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zibri", - "version": "2.3.0", + "version": "2.4.0", "main": "./dist/cjs/index.js", "types": "./dist/cjs/index.d.ts", "module": "./dist/esm/index.mjs", @@ -49,58 +49,58 @@ "license": "MIT", "description": "TS Backend Framework", "peerDependencies": { - "axios": "^1.13.2", - "bcryptjs": "^3.0.2", - "bignumber.js": "^9.3.1", - "handlebars": "^4.7.8", + "axios": "^1.15.0", + "bcryptjs": "^3.0.3", + "bignumber.js": "^10.0.2", + "handlebars": "^4.7.9", "hi-base32": "^0.5.1", - "jsonwebtoken": "^9.0.2", - "otpauth": "^9.4.1", + "jsonwebtoken": "^9.0.3", + "otpauth": "^9.5.0", "pdfmake": "^0.2.2", - "preact": "^10.28.3", - "preact-render-to-string": "^6.6.5", + "preact": "^10.29.1", + "preact-render-to-string": "^6.6.7", "rxjs": "^7.8.2", - "socket.io": "^4.8.1", + "socket.io": "^4.8.3", "ts-node": "^10.9.2", "uuid": "^11.1.0", "xmlbuilder2": "^4.0.3" }, "dependencies": { "@fastify/busboy": "^3.2.0", - "cors": "^2.8.5", - "express": "^5.1.0", + "cors": "^2.8.6", + "express": "^5.2.1", "glob": "^13.0.6", "node-cron": "^4.2.1", - "nodemailer": "^8.0.4", - "pg": "^8.16.3", + "nodemailer": "^8.0.5", + "pg": "^8.20.0", "prom-client": "^15.1.3", "reflect-metadata": "^0.2.2", "swagger-ui-express": "^5.0.1", "swagger2openapi": "^7.0.8", - "systeminformation": "^5.27.10", - "typeorm": "^0.3.27" + "systeminformation": "^5.31.5", + "typeorm": "^0.3.28" }, "devDependencies": { "@faker-js/faker": "^9.9.0", - "@jest/globals": "^30.2.0", - "@swc/core": "^1.13.5", - "@testcontainers/postgresql": "^11.6.0", + "@jest/globals": "^30.3.0", + "@swc/core": "^1.15.24", + "@testcontainers/postgresql": "^11.14.0", "@types/cors": "^2.8.19", - "@types/express": "^5.0.3", + "@types/express": "^5.0.6", "@types/jsonwebtoken": "^9.0.10", - "@types/node": "^24.10.13", - "@types/nodemailer": "^7.0.1", + "@types/node": "^25.6.0", + "@types/nodemailer": "^8.0.0", "@types/pdfmake": "^0.2.11", "@types/swagger-ui-express": "^4.1.8", "@types/swagger2openapi": "^7.0.4", "eslint": "^9.36.0", "eslint-config-service-soft": "^2.1.6", - "jest": "^30.2.0", + "jest": "^30.3.0", "npm-run-all": "^4.1.5", "openapi3-ts": "^4.5.0", - "testcontainers": "^11.6.0", - "ts-jest": "^29.4.6", - "typedoc": "^0.28.17", - "typescript": "^5.9.3" + "testcontainers": "^11.14.0", + "ts-jest": "^29.4.9", + "typedoc": "^0.28.19", + "typescript": "^5.9.2" } } \ No newline at end of file diff --git a/sandbox/src/data-sources/db/db.data-source.ts b/sandbox/src/data-sources/db/db.data-source.ts index b9f7361..c60ba19 100644 --- a/sandbox/src/data-sources/db/db.data-source.ts +++ b/sandbox/src/data-sources/db/db.data-source.ts @@ -1,4 +1,4 @@ -import { PostgresDataSource, PostgresOptions, BaseEntity, DataSource, Newable, MigrationEntity, JwtRefreshToken, JwtCredentials, PasswordResetToken, MailingList, MailingListSubscriber, MailingListSubscriptionConfirmationToken, Log, Change, ChangeSet, Entity, OmitClass, OtpCredentials, BackupResourceEntity, BackupEntity, Invoice, NumberInvoices, Email, CronJobEntity, ThreadJobEntity, WebsocketChannel, WebsocketMessage } from 'zibri'; +import { PostgresDataSource, PostgresOptions, BaseEntity, DataSource, Newable, MigrationEntity, JwtRefreshToken, JwtCredentials, PasswordResetToken, MailingList, MailingListSubscriber, MailingListSubscriptionConfirmationToken, Log, Change, ChangeSet, Entity, OmitClass, OtpCredentials, BackupResourceEntity, BackupEntity, Invoice, NumberInvoices, Email, CronJobEntity, ThreadJobEntity, WebsocketChannel, WebsocketMessage, Event, EventSubscriberRun } from 'zibri'; import { Company, Test, User } from '../../models'; @@ -42,6 +42,8 @@ export class DbDataSource extends PostgresDataSource { CronJobEntity, ThreadJobEntity, WebsocketChannel, - WebsocketMessage + WebsocketMessage, + Event, + EventSubscriberRun ]; } \ No newline at end of file diff --git a/sandbox/src/templates/pages/mailing-list-preferences.tsx b/sandbox/src/templates/pages/mailing-list-preferences.tsx index b4124c5..e491172 100644 --- a/sandbox/src/templates/pages/mailing-list-preferences.tsx +++ b/sandbox/src/templates/pages/mailing-list-preferences.tsx @@ -11,7 +11,7 @@ type MailingListDisplayData = MailingList & { isSubscribedTo: boolean }; -export const MailingListPreferencesPage: MailingListPreferencesPageTemplate = ({ subscriber, mailingLists, managePreferencesLink }) => { +export const MailingListPreferencesPage: MailingListPreferencesPageTemplate = ({ subscriber, mailingLists, managePreferencesApiUrl }) => { let updateButton: HTMLButtonElement; let statusBar: HTMLDivElement; @@ -57,7 +57,7 @@ export const MailingListPreferencesPage: MailingListPreferencesPageTemplate = ({ setIsLoading(); const success: boolean = (await fetch( - managePreferencesLink, + managePreferencesApiUrl, { method: 'PATCH', body: JSON.stringify({ mailingListIds: currentCheckedMailingListIds }), diff --git a/src/__testing__/constants.ts b/src/__testing__/constants.ts index 2c47bd5..2250cb5 100644 --- a/src/__testing__/constants.ts +++ b/src/__testing__/constants.ts @@ -2,6 +2,8 @@ import { FsUtilities, FsPath } from '../utilities/fs.utilities'; export const testFileFolder: FsPath = FsUtilities.getPath(__dirname, 'file-output'); +export const testAssetsFolder: FsPath = FsUtilities.getPath(__dirname, 'mocks', 'assets'); + export const POSTGRES_TEST_IMAGE: string = 'postgres:17.6'; export const noOp: () => void = () => {}; diff --git a/src/__testing__/test-server/create-test-data-source.function.ts b/src/__testing__/test-server/create-test-data-source.function.ts index 205a4d7..b0fe957 100644 --- a/src/__testing__/test-server/create-test-data-source.function.ts +++ b/src/__testing__/test-server/create-test-data-source.function.ts @@ -10,6 +10,8 @@ import { DataSource } from '../../data-source/decorators/data-source.decorator'; import { MigrationEntity } from '../../data-source/migration/migration-entity.model'; import { Email } from '../../email/models/email.model'; import { BaseEntity } from '../../entity/base-entity.model'; +import { EventSubscriberRun } from '../../event/event-subscriber-run.model'; +import { Event } from '../../event/event.model'; import { Log } from '../../logging/log.model'; import { ThreadJobEntity } from '../../multithreading/models/thread-job-entity.model'; import { Newable } from '../../types/newable.type'; @@ -40,7 +42,9 @@ export const defaultTestServerEntities: Newable[] = [ JwtRefreshToken, JwtCredentials, OtpCredentials, - ThreadJobEntity + ThreadJobEntity, + Event, + EventSubscriberRun ]; export function createTestDataSource({ diff --git a/src/__testing__/test-server/start-test-server.function.ts b/src/__testing__/test-server/start-test-server.function.ts index 079cf9a..1ce2940 100644 --- a/src/__testing__/test-server/start-test-server.function.ts +++ b/src/__testing__/test-server/start-test-server.function.ts @@ -1,3 +1,5 @@ +import { AddressInfo } from 'node:net'; + import { jest } from '@jest/globals'; import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql'; import H from 'handlebars/runtime'; @@ -15,10 +17,11 @@ import { DiContainer } from '../../di/di-container'; import { inject } from '../../di/inject.function'; import { LoggerInterface } from '../../logging/logger.interface'; import { Newable } from '../../types/newable.type'; -import { noOp, POSTGRES_TEST_IMAGE } from '../constants'; +import { noOp, POSTGRES_TEST_IMAGE, testAssetsFolder } from '../constants'; import { createTestDataSource } from './create-test-data-source.function'; +import { AssetServiceInterface } from '../../assets/asset-service.interface'; -type StartTestServerOptions = Partial> & { +type StartTestServerOptions = Partial> & { dataSources?: Newable[] }; @@ -29,6 +32,18 @@ export class StartedTestServer { private readonly exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => undefined as never) ) {} + async start(): Promise { + await this.app.start(0); + const address: string | AddressInfo | null = this.app.server.address(); + if (address == undefined || typeof address === 'string') { + throw new Error('Failed to resolve test server port.'); + } + const host: string = address.address === '::' || address.address === '0.0.0.0' + ? '127.0.0.1' + : address.address; + return `http://${host}:${address.port}`; + } + async shutdown(): Promise { await this.app.shutdown(); this.exitSpy.mockRestore(); @@ -40,7 +55,8 @@ export async function startTestServer( { dataSources = [createTestDataSource()], providers = defaultTestServerProviders, - plugins = defaultTestServerPlugins + plugins = defaultTestServerPlugins, + controllers = [] }: StartTestServerOptions = {} ): Promise { // Reset singleton — every test file gets a clean container with no stale instances. @@ -66,11 +82,14 @@ export async function startTestServer( const info: typeof logger.info = logger.info; logger.info = noOp; + const assetService: AssetServiceInterface = inject(ZIBRI_DI_TOKENS.ASSET_SERVICE); + (assetService.assetsPath as string) = testAssetsFolder; + const app: ZibriApplication = new ZibriApplication({ name: 'test', version: '0.0.1', baseUrl: 'http://localhost:3000', - controllers: [], + controllers, websocketControllers: [], dataSources, providers, diff --git a/src/application.ts b/src/application.ts index ffb380e..e342843 100644 --- a/src/application.ts +++ b/src/application.ts @@ -1,4 +1,5 @@ import { createServer, Server } from 'node:http'; +import { AddressInfo } from 'node:net'; import cors from 'cors'; import express, { RequestHandler } from 'express'; @@ -168,6 +169,12 @@ export class ZibriApplication { this.use((req, _, next) => next(new UnmatchedRouteError(req.originalUrl))); this.use(inject(ZIBRI_DI_TOKENS.GLOBAL_ERROR_HANDLER)); this.server.listen(port); + if (port === 0) { + const address: string | AddressInfo | null = this.server.address(); + if (address != undefined && typeof address !== 'string') { + port = address.port; + } + } GlobalRegistry.markAppAsStarted(); await this.logger.info(`${this.options.name} is running on port ${port}`); } @@ -247,7 +254,8 @@ export class ZibriApplication { await Promise.all(elements.map(async e => { try { const timeoutInMs: number = e.shutdownTimeoutInMs ?? DEFAULT_SHUTDOWN_TIMEOUT_IN_MS; - await PromiseUtilities.withTimeout(e.afterAppShutdown(this, signal), timeoutInMs); + // no abort signal here because stopping the api does that. + await PromiseUtilities.withTimeout(() => e.afterAppShutdown(this, signal), timeoutInMs); } catch (error) { await this.logger.error( @@ -264,7 +272,8 @@ export class ZibriApplication { await Promise.all(elements.map(async e => { try { const timeoutInMs: number = e.shutdownTimeoutInMs ?? DEFAULT_SHUTDOWN_TIMEOUT_IN_MS; - await PromiseUtilities.withTimeout(e.onAppShutdown(this, signal), timeoutInMs); + // no abort signal here because stopping the api does that. + await PromiseUtilities.withTimeout(() => e.onAppShutdown(this, signal), timeoutInMs); } catch (error) { await this.logger.error( @@ -281,7 +290,8 @@ export class ZibriApplication { await Promise.all(elements.map(async e => { try { const timeoutInMs: number = e.shutdownTimeoutInMs ?? DEFAULT_SHUTDOWN_TIMEOUT_IN_MS; - await PromiseUtilities.withTimeout(e.beforeAppShutdown(this, signal), timeoutInMs); + // no abort signal here because stopping the api does that. + await PromiseUtilities.withTimeout(() => e.beforeAppShutdown(this, signal), timeoutInMs); } catch (error) { await this.logger.error( diff --git a/src/auth/2fa/methods/otp/otp.two-factor-method.ts b/src/auth/2fa/methods/otp/otp.two-factor-method.ts index c177db3..4f8f4ed 100644 --- a/src/auth/2fa/methods/otp/otp.two-factor-method.ts +++ b/src/auth/2fa/methods/otp/otp.two-factor-method.ts @@ -4,14 +4,14 @@ import { HiBase32Utilities } from './hi-base32.utilities'; import { TwoFactorMethod } from '../two-factor-method.interface'; import { OtpCredentials, OtpCredentialsCreateData } from './otp-credentials.model'; import { OtpUtilities } from './otp.utilities'; +import { HttpRequestContext } from '../../../../context/request/http-request.context'; +import { WebsocketRequestContext } from '../../../../context/request/websocket-request.context'; import { Repository } from '../../../../data-source/repository'; import { InjectRepository } from '../../../../di/decorators/inject-repository.decorator'; import { Inject } from '../../../../di/decorators/inject.decorator'; import { ZIBRI_DI_TOKENS } from '../../../../di/default/zibri-di-tokens.default'; import { UnauthorizedError } from '../../../../error-handling/errors/unauthorized.error'; -import { HttpRequest } from '../../../../http/http-request.model'; import { KnownHeader } from '../../../../http/known-header.enum'; -import { WebsocketRequest } from '../../../../websocket/models/websocket-request.model'; import { BaseUser } from '../../../models/base-user.model'; /** @@ -71,10 +71,10 @@ export class OtpTwoFactorMethod implements TwoFactorMethod>( user: UserType, - request: HttpRequest | WebsocketRequest + context: HttpRequestContext | WebsocketRequestContext ): Promise { const credentials: OtpCredentials[] = await this.otpCredentialsRepository.findAll({ where: { userId: user.id } }); - const token: string = this.extractTokenFromRequest(request); + const token: string = this.extractTokenFromRequestContext(context); for (const c of credentials) { if (OtpUtilities.validate(c.secret, token)) { return; @@ -83,8 +83,8 @@ export class OtpTwoFactorMethod implements TwoFactorMethod>( user: UserType, - request: HttpRequest | WebsocketRequest + context: HttpRequestContext | WebsocketRequestContext ) => void | Promise } diff --git a/src/auth/2fa/two-factor-service.interface.ts b/src/auth/2fa/two-factor-service.interface.ts index a655ab2..685b150 100644 --- a/src/auth/2fa/two-factor-service.interface.ts +++ b/src/auth/2fa/two-factor-service.interface.ts @@ -1,7 +1,7 @@ import { TwoFactorMethod } from './methods/two-factor-method.interface'; import { TwoFactorMethods } from './two-factor-methods.model'; -import { HttpRequest } from '../../http/http-request.model'; -import { WebsocketRequest } from '../../websocket/models/websocket-request.model'; +import { HttpRequestContext } from '../../context/request/http-request.context'; +import { WebsocketRequestContext } from '../../context/request/websocket-request.context'; import { BaseUser } from '../models/base-user.model'; /** @@ -52,7 +52,7 @@ export interface TwoFactorServiceInterface { */ has2fa: ( user: BaseUser, - request: HttpRequest | WebsocketRequest, + context: HttpRequestContext | WebsocketRequestContext, allowedMethods?: TwoFactorMethods ) => Promise } \ No newline at end of file diff --git a/src/auth/2fa/two-factor.service.ts b/src/auth/2fa/two-factor.service.ts index 14aa3df..f0474e9 100644 --- a/src/auth/2fa/two-factor.service.ts +++ b/src/auth/2fa/two-factor.service.ts @@ -1,13 +1,14 @@ import { ZibriApplication } from '../../application'; +import { HttpRequestContext } from '../../context/request/http-request.context'; +import { ZIBRI_REQUEST_CONTEXT_TOKENS } from '../../context/request/request-context-token.model'; +import { WebsocketRequestContext } from '../../context/request/websocket-request.context'; import { Inject } from '../../di/decorators/inject.decorator'; import { Injectable } from '../../di/decorators/injectable.decorator'; import { ZIBRI_DI_TOKENS } from '../../di/default/zibri-di-tokens.default'; import { inject } from '../../di/inject.function'; import { register } from '../../di/register.function'; import { OnAppInit } from '../../global/on-app-init.interface'; -import { HttpRequest } from '../../http/http-request.model'; import { type LoggerInterface } from '../../logging/logger.interface'; -import { WebsocketRequest } from '../../websocket/models/websocket-request.model'; import { BaseUser } from '../models/base-user.model'; import { TwoFactorMethod } from './methods/two-factor-method.interface'; import { TwoFactorMethods } from './two-factor-methods.model'; @@ -87,12 +88,15 @@ export class TwoFactorService implements TwoFactorServiceInterface, OnAppInit { // eslint-disable-next-line jsdoc/require-jsdoc async has2fa( user: BaseUser, - request: HttpRequest | WebsocketRequest, + context: HttpRequestContext | WebsocketRequestContext, allowedMethods: TwoFactorMethods = this.twoFactorMethods ): Promise { + if (context.has(ZIBRI_REQUEST_CONTEXT_TOKENS.HAS_2FA)) { + return context.get(ZIBRI_REQUEST_CONTEXT_TOKENS.HAS_2FA); + } try { await Promise.any( - allowedMethods.map(m => inject(m).validate(user, request)) + allowedMethods.map(m => inject(m).validate(user, context)) ); return true; } diff --git a/src/auth/auth-service.interface.ts b/src/auth/auth-service.interface.ts index 7bbfd61..3958eb6 100644 --- a/src/auth/auth-service.interface.ts +++ b/src/auth/auth-service.interface.ts @@ -1,5 +1,4 @@ import { BaseEntity } from '../entity/base-entity.model'; -import { HttpRequest } from '../http/http-request.model'; import { Newable } from '../types/newable.type'; import { BaseUser } from './models/base-user.model'; import { BelongsToMetadata } from './models/belongs-to-metadata.model'; @@ -9,7 +8,8 @@ import { IsNotLoggedInMetadata } from './models/is-not-logged-in-metadata.model' import { Require2faMetadata } from './models/require-2fa-metadata.model'; import { AuthStrategies } from './strategies/auth-strategies.model'; import { AuthStrategyInterface } from './strategies/auth-strategy.interface'; -import { WebsocketRequest } from '../websocket/models/websocket-request.model'; +import { HttpRequestContext } from '../context/request/http-request.context'; +import { WebsocketRequestContext } from '../context/request/websocket-request.context'; /** * Interface for an auth service. @@ -25,24 +25,28 @@ export interface AuthServiceInterface { checkAccess: ( controllerClass: Newable, controllerMethod: string, - request: HttpRequest | WebsocketRequest + context: HttpRequestContext | WebsocketRequestContext ) => Promise, /** * Checks whether there is a currently logged in user. */ isLoggedIn: ( - request: HttpRequest | WebsocketRequest, + context: HttpRequestContext | WebsocketRequestContext, allowedStrategies: AuthStrategies ) => Promise, /** * Checks whether the currently logged in user has one of the provided roles. */ - hasRole: (request: HttpRequest | WebsocketRequest, allowedStrategies: AuthStrategies, allowedRoles: string[]) => Promise, + hasRole: ( + context: HttpRequestContext | WebsocketRequestContext, + allowedStrategies: AuthStrategies, + allowedRoles: string[] + ) => Promise, /** * Checks whether the currently logged in user belongs to the target entity. */ belongsTo: >( - request: HttpRequest, + context: HttpRequestContext | WebsocketRequestContext, allowedStrategies: AuthStrategies, targetEntity: TargetEntity, targetUserIdKey: keyof InstanceType, @@ -149,7 +153,7 @@ export interface AuthServiceInterface { UserType extends BaseUser, B extends boolean = true >( - request: HttpRequest | WebsocketRequest, + context: HttpRequestContext | WebsocketRequestContext, allowedStrategies: AuthStrategies, required: B ) => Promise, diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 4381566..4ecedb3 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -12,18 +12,19 @@ import { Require2faMetadata, SkipRequire2faMetadata } from './models/require-2fa import { SkipAuthMetadata } from './models/skip-auth-metadata.model'; import { AuthStrategies } from './strategies/auth-strategies.model'; import { AuthStrategyInterface } from './strategies/auth-strategy.interface'; +import { HttpRequestContext } from '../context/request/http-request.context'; +import { ZIBRI_REQUEST_CONTEXT_TOKENS } from '../context/request/request-context-token.model'; +import { WebsocketRequestContext } from '../context/request/websocket-request.context'; import { Inject } from '../di/decorators/inject.decorator'; import { Injectable } from '../di/decorators/injectable.decorator'; import { ZIBRI_DI_TOKENS } from '../di/default/zibri-di-tokens.default'; import { inject } from '../di/inject.function'; import { UnauthorizedError } from '../error-handling/errors/unauthorized.error'; import { OnAppInit } from '../global/on-app-init.interface'; -import { HttpRequest } from '../http/http-request.model'; import { type LoggerInterface } from '../logging/logger.interface'; import { Newable } from '../types/newable.type'; import { MetadataUtilities } from '../utilities/metadata.utilities'; import { PromiseUtilities } from '../utilities/promise.utilities'; -import { WebsocketRequest } from '../websocket/models/websocket-request.model'; /** * Default auth service implementation of Zibri. @@ -119,15 +120,18 @@ export class AuthService implements AuthServiceInterface, OnAppInit { UserType extends BaseUser, B extends boolean = true >( - request: HttpRequest | WebsocketRequest, + context: HttpRequestContext | WebsocketRequestContext, allowedStrategies: AuthStrategies, required: B ): Promise { + if (context.has(ZIBRI_REQUEST_CONTEXT_TOKENS.CURRENT_USER)) { + return await context.get(ZIBRI_REQUEST_CONTEXT_TOKENS.CURRENT_USER) as B extends false ? UserType | undefined : UserType; + } // eslint-disable-next-line stylistic/max-len const strategies: AuthStrategyInterface[] = allowedStrategies.map( s => inject(s) ) as unknown as AuthStrategyInterface[]; - const res: PromiseSettledResult[] = await Promise.allSettled(strategies.map(s => s.resolveUser(request))); + const res: PromiseSettledResult[] = await Promise.allSettled(strategies.map(s => s.resolveUser(context))); const currentUser: UserType | undefined = ( res.find(r => r.status === 'fulfilled' && r.value !== undefined) as PromiseFulfilledResult | undefined )?.value; @@ -197,7 +201,7 @@ export class AuthService implements AuthServiceInterface, OnAppInit { async checkAccess( controllerClass: Newable, controllerMethod: string, - request: HttpRequest | WebsocketRequest + context: HttpRequestContext | WebsocketRequestContext ): Promise { const isLoggedInMetadata: IsLoggedInMetadata | undefined = await this.resolveIsLoggedInMetadata(controllerClass, controllerMethod); const isNotLoggedInMetadata: IsNotLoggedInMetadata | undefined = await this.resolveIsNotLoggedInMetadata( @@ -226,14 +230,14 @@ export class AuthService implements AuthServiceInterface, OnAppInit { // isLoggedIn if ( (isLoggedInMetadata || hasRoleMetadata || belongsToMetadata || require2faMetadata) - && !await this.isLoggedIn(request, isLoggedInMetadata?.allowedStrategies ?? this.strategies) + && !await this.isLoggedIn(context, isLoggedInMetadata?.allowedStrategies ?? this.strategies) ) { throw new UnauthorizedError('You need to be logged in to access this route.'); } // isNotLoggedIn if ( isNotLoggedInMetadata - && await this.isLoggedIn(request, isLoggedInMetadata?.allowedStrategies ?? this.strategies) + && await this.isLoggedIn(context, isNotLoggedInMetadata.allowedStrategies ?? this.strategies) ) { throw new UnauthorizedError('You cannot be logged in when accessing this route.'); } @@ -241,15 +245,15 @@ export class AuthService implements AuthServiceInterface, OnAppInit { // hasRole if ( hasRoleMetadata - && !await this.hasRole(request, hasRoleMetadata.allowedStrategies ?? this.strategies, hasRoleMetadata.allowedRoles) + && !await this.hasRole(context, hasRoleMetadata.allowedStrategies ?? this.strategies, hasRoleMetadata.allowedRoles) ) { throw new UnauthorizedError(`You need to have one role of ${hasRoleMetadata.allowedRoles} to access this route.`); } // require2fa if (require2faMetadata) { - const user: BaseUser = await this.getCurrentUser(request, this.strategies, true); - if (!await this.twoFactorService.has2fa(user, request, require2faMetadata.allowedMethods)) { + const user: BaseUser = await this.getCurrentUser(context, this.strategies, true); + if (!await this.twoFactorService.has2fa(user, context, require2faMetadata.allowedMethods)) { throw new UnauthorizedError('You need to provide a second factor to access this route.'); } } @@ -258,14 +262,14 @@ export class AuthService implements AuthServiceInterface, OnAppInit { if ( belongsToMetadata && !await this.belongsTo( - request, + context, belongsToMetadata.allowedStrategies ?? this.strategies, belongsToMetadata.targetEntity, belongsToMetadata.targetUserIdKey, belongsToMetadata.targetIdParamKey ) ) { - const targetId: string | undefined = request.params?.[belongsToMetadata.targetIdParamKey]; + const targetId: string | undefined = context.request.params?.[belongsToMetadata.targetIdParamKey]; throw new UnauthorizedError( // eslint-disable-next-line stylistic/max-len `You need to to have access to the ${belongsToMetadata.targetEntity.name} entity with the id ${String(targetId)} to access this route.` @@ -275,13 +279,16 @@ export class AuthService implements AuthServiceInterface, OnAppInit { // eslint-disable-next-line jsdoc/require-jsdoc async isLoggedIn( - request: HttpRequest | WebsocketRequest, + context: HttpRequestContext | WebsocketRequestContext, allowedStrategies: AuthStrategies ): Promise { + if (context.has(ZIBRI_REQUEST_CONTEXT_TOKENS.IS_LOGGED_IN)) { + return context.get(ZIBRI_REQUEST_CONTEXT_TOKENS.IS_LOGGED_IN); + } // eslint-disable-next-line stylistic/max-len const strategies: AuthStrategyInterface, unknown, unknown, unknown, unknown, unknown, unknown>[] = allowedStrategies.map(s => inject(s)); try { - return await PromiseUtilities.anyValueTrue(strategies, s => s.isLoggedIn(request)); + return await PromiseUtilities.anyValueTrue(strategies, s => s.isLoggedIn(context)); } catch { return false; @@ -290,14 +297,14 @@ export class AuthService implements AuthServiceInterface, OnAppInit { // eslint-disable-next-line jsdoc/require-jsdoc async hasRole( - request: HttpRequest | WebsocketRequest, + context: HttpRequestContext | WebsocketRequestContext, allowedStrategies: AuthStrategies, allowedRoles: string[] ): Promise { // eslint-disable-next-line stylistic/max-len const strategies: AuthStrategyInterface, unknown, unknown, unknown, unknown, unknown, unknown>[] = allowedStrategies.map(s => inject(s)); try { - return await PromiseUtilities.anyValueTrue(strategies, s => s.hasRole(request, allowedRoles)); + return await PromiseUtilities.anyValueTrue(strategies, s => s.hasRole(context, allowedRoles)); } catch { return false; @@ -306,7 +313,7 @@ export class AuthService implements AuthServiceInterface, OnAppInit { // eslint-disable-next-line jsdoc/require-jsdoc async belongsTo>( - request: HttpRequest | WebsocketRequest, + context: HttpRequestContext | WebsocketRequestContext, allowedStrategies: AuthStrategies, targetEntity: TargetEntity, targetUserIdKey: keyof InstanceType, @@ -317,7 +324,7 @@ export class AuthService implements AuthServiceInterface, OnAppInit { try { return await PromiseUtilities.anyValueTrue( strategies, - s => s.belongsTo(request, targetEntity, targetUserIdKey, targetIdParamKey) + s => s.belongsTo(context, targetEntity, targetUserIdKey, targetIdParamKey) ); } catch { diff --git a/src/auth/strategies/auth-strategy.interface.ts b/src/auth/strategies/auth-strategy.interface.ts index 92c881b..569c25f 100644 --- a/src/auth/strategies/auth-strategy.interface.ts +++ b/src/auth/strategies/auth-strategy.interface.ts @@ -1,9 +1,9 @@ import { AuthStrategies } from './auth-strategies.model'; +import { HttpRequestContext } from '../../context/request/http-request.context'; +import { WebsocketRequestContext } from '../../context/request/websocket-request.context'; import { BaseEntity } from '../../entity/base-entity.model'; -import { HttpRequest } from '../../http/http-request.model'; import { OpenApiSecuritySchemeObject } from '../../open-api/open-api.model'; import { Newable } from '../../types/newable.type'; -import { WebsocketRequest } from '../../websocket/models/websocket-request.model'; import { BaseUser } from '../models/base-user.model'; /** @@ -22,7 +22,7 @@ export interface AuthStrategyInterface< /** * Resolves the current user. */ - resolveUser: (request: HttpRequest | WebsocketRequest) => Promise, + resolveUser: (context: HttpRequestContext | WebsocketRequestContext) => Promise, /** * Logs in a user. */ @@ -38,16 +38,16 @@ export interface AuthStrategyInterface< /** * Checks whether a user is currently logged in. */ - isLoggedIn: (request: HttpRequest | WebsocketRequest) => Promise, + isLoggedIn: (context: HttpRequestContext | WebsocketRequestContext) => Promise, /** * Checks whether a currently logged in user has one of the provided roles. */ - hasRole: (request: HttpRequest | WebsocketRequest, allowedRoles: RoleType[]) => Promise, + hasRole: (context: HttpRequestContext | WebsocketRequestContext, allowedRoles: RoleType[]) => Promise, /** * Checks whether a currently logged belongs to the requested resource. */ belongsTo: >( - request: HttpRequest | WebsocketRequest, + context: HttpRequestContext | WebsocketRequestContext, targetEntity: TargetEntity, targetUserIdKey: keyof InstanceType, targetIdParamKey: string diff --git a/src/auth/strategies/jwt/jwt.auth-strategy.ts b/src/auth/strategies/jwt/jwt.auth-strategy.ts index 79e24df..740f1d1 100644 --- a/src/auth/strategies/jwt/jwt.auth-strategy.ts +++ b/src/auth/strategies/jwt/jwt.auth-strategy.ts @@ -10,6 +10,8 @@ import { JwtRefreshTokenPayload } from './jwt-refresh-token-payload.model'; import { JwtRefreshToken, JwtRefreshTokenCreateDto } from './jwt-refresh-token.model'; import { JwtRequestPasswordResetData } from './jwt-request-password-reset-data.model'; import { JwtUtilities } from './jwt.utilities'; +import { HttpRequestContext } from '../../../context/request/http-request.context'; +import { WebsocketRequestContext } from '../../../context/request/websocket-request.context'; import { Repository } from '../../../data-source/repository'; import { InjectRepository, repositoryTokenFor } from '../../../di/decorators/inject-repository.decorator'; import { Inject } from '../../../di/decorators/inject.decorator'; @@ -22,12 +24,10 @@ import { BaseEntity } from '../../../entity/base-entity.model'; import { TooManyRequestsError } from '../../../error-handling/errors/too-many-requests.error'; import { UnauthorizedError } from '../../../error-handling/errors/unauthorized.error'; import { GlobalRegistry } from '../../../global/global-registry'; -import { HttpRequest } from '../../../http/http-request.model'; import { OpenApiSecuritySchemeObject } from '../../../open-api/open-api.model'; import { Newable } from '../../../types/newable.type'; import { Ms } from '../../../utilities/ms'; import { UUIDUtilities } from '../../../utilities/uuid.utilities'; -import { WebsocketRequest } from '../../../websocket/models/websocket-request.model'; import { HashUtilities } from '../../hash.utilities'; import { BaseUser } from '../../models/base-user.model'; import { PasswordResetToken, PasswordResetTokenCreateData } from '../../models/password-reset-token.model'; @@ -291,8 +291,8 @@ implements AuthStrategyInterface< } // eslint-disable-next-line jsdoc/require-jsdoc - async resolveUser(request: HttpRequest | WebsocketRequest): Promise { - const jwt: string | undefined = this.extractAccessTokenFromRequest(request); + async resolveUser(context: HttpRequestContext | WebsocketRequestContext): Promise { + const jwt: string | undefined = this.extractAccessTokenFromRequestContext(context); if (!jwt) { return undefined; } @@ -305,8 +305,8 @@ implements AuthStrategyInterface< } // eslint-disable-next-line jsdoc/require-jsdoc - async isLoggedIn(request: HttpRequest | WebsocketRequest): Promise { - const jwt: string | undefined = this.extractAccessTokenFromRequest(request); + async isLoggedIn(context: HttpRequestContext | WebsocketRequestContext): Promise { + const jwt: string | undefined = this.extractAccessTokenFromRequestContext(context); if (!jwt) { return false; } @@ -315,8 +315,8 @@ implements AuthStrategyInterface< } // eslint-disable-next-line jsdoc/require-jsdoc - async hasRole(request: HttpRequest | WebsocketRequest, allowedRoles: RoleType[]): Promise { - const jwt: string | undefined = this.extractAccessTokenFromRequest(request); + async hasRole(context: HttpRequestContext | WebsocketRequestContext, allowedRoles: RoleType[]): Promise { + const jwt: string | undefined = this.extractAccessTokenFromRequestContext(context); if (!jwt) { return false; } @@ -329,12 +329,12 @@ implements AuthStrategyInterface< // eslint-disable-next-line jsdoc/require-jsdoc async belongsTo>( - request: HttpRequest | WebsocketRequest, + context: HttpRequestContext | WebsocketRequestContext, targetEntity: TargetEntity, targetUserIdKey: keyof InstanceType, targetIdParamKey: string ): Promise { - const jwt: string | undefined = this.extractAccessTokenFromRequest(request); + const jwt: string | undefined = this.extractAccessTokenFromRequestContext(context); if (!jwt) { return false; } @@ -344,7 +344,7 @@ implements AuthStrategyInterface< } try { const repo: Repository> = inject(repositoryTokenFor(targetEntity)); - const targetId: string | undefined = request.params?.[targetIdParamKey]; + const targetId: string | undefined = context.request.params?.[targetIdParamKey]; if (targetId == undefined) { throw new Error(`Could not find the target id specified as path param "${targetId}"`); } @@ -360,10 +360,10 @@ implements AuthStrategyInterface< } } - private extractAccessTokenFromRequest( - request: HttpRequest | WebsocketRequest + private extractAccessTokenFromRequestContext( + context: HttpRequestContext | WebsocketRequestContext ): string | undefined { - const authHeader: string | string[] | undefined = request.headers.Authorization; + const authHeader: string | string[] | undefined = context.request.headers.authorization; if (authHeader == undefined || typeof authHeader !== 'string') { return undefined; } diff --git a/src/change-sets/change-set-repository.ts b/src/change-sets/change-set-repository.ts index a9b8790..96f3cbf 100644 --- a/src/change-sets/change-set-repository.ts +++ b/src/change-sets/change-set-repository.ts @@ -7,7 +7,10 @@ import { AuthServiceInterface } from '../auth/auth-service.interface'; import { ChangeSetEntity } from './models/change-set-entity.model'; import { ChangeSetType } from './models/change-set-type.enum'; import { ChangeSet, CreateChangeSetData } from './models/change-set.model'; +import { NewChange } from './models/change.model'; import { BaseUser } from '../auth/models/base-user.model'; +import { HttpRequestContext } from '../context/request/http-request.context'; +import { WebsocketRequestContext } from '../context/request/websocket-request.context'; import { BaseRepositoryOptions } from '../data-source/models/options/base-repository-options.model'; import { CreateAllOptions } from '../data-source/models/options/create-all-options.model'; import { CreateOptions } from '../data-source/models/options/create-options.model'; @@ -20,14 +23,14 @@ import { ZIBRI_DI_TOKENS } from '../di/default/zibri-di-tokens.default'; import { inject } from '../di/inject.function'; import { PropertyMetadata } from '../entity/decorators/property.decorator'; import { BadRequestError } from '../error-handling/errors/bad-request.error'; -import { HttpRequest } from '../http/http-request.model'; +import { removeExcludeProperties } from '../global/model-registry/remove-exclude-properties.function'; +import { restoreExcludeProperties } from '../global/model-registry/restore-exclude-properties.function'; import { LoggerInterface } from '../logging/logger.interface'; +import { DeepPartial } from '../types/deep-partial.type'; import { Newable } from '../types/newable.type'; import { MetadataUtilities } from '../utilities/metadata.utilities'; import { ObjectUtilities } from '../utilities/object.utilities'; import { PromiseUtilities } from '../utilities/promise.utilities'; -import { NewChange } from './models/change.model'; -import { DeepPartial } from '../types/deep-partial.type'; /** * The result for resetting a change set on an entity. @@ -55,7 +58,7 @@ export class ChangeSetRepository< /** * Any keys that should be excluded from the change set. */ - protected readonly keysToExcludeFromChangeSets: (keyof T)[]; + protected readonly keysToExcludeFromChangeSets: Set = new Set(); private readonly changeSetRepository: Repository; private readonly authService: AuthServiceInterface; @@ -66,11 +69,11 @@ export class ChangeSetRepository< this.authService = inject(ZIBRI_DI_TOKENS.AUTH_SERVICE); this.changeSetRepository = inject(repositoryTokenFor(ChangeSet)); - this.keysToExcludeFromChangeSets = ['changeSets']; + this.keysToExcludeFromChangeSets.add('changeSets'); const props: Record = MetadataUtilities.getModelProperties(entityClass); for (const [key, m] of ObjectUtilities.entries(props)) { if (m.excludeFromChangeSets) { - this.keysToExcludeFromChangeSets.push(key as keyof T); + this.keysToExcludeFromChangeSets.add(key as keyof T); } } } @@ -348,16 +351,26 @@ export class ChangeSetRepository< force: boolean = false ): Promise { await setTimeout(1); // TODO: Better way to guarantee a different time stamp on change sets. + + restoreExcludeProperties(entityPriorChanges, this.entityClass); + restoreExcludeProperties(data, this.entityClass); + const changes: NewChange[] = this.getChangesFromData(entityPriorChanges, data, type); + await Promise.all([ + removeExcludeProperties(entityPriorChanges, this.entityClass), + removeExcludeProperties(data, this.entityClass) + ]); + + if (!force && !changes.length) { + return; + } + const changeSetData: CreateChangeSetData = { changeSetEntityId: entityPriorChanges.id, type: type, createdAt: new Date(), createdBy: await this.getCreatedBy(), - changes: this.getChangesFromData(entityPriorChanges, data, type) + changes }; - if (!force && !changeSetData.changes.length) { - return; - } await this.changeSetRepository.create(changeSetData, options); } @@ -379,12 +392,23 @@ export class ChangeSetRepository< ): Promise { const userId: string | undefined = await this.getCreatedBy(); await setTimeout(1); // TODO: Better way to guarantee a different time stamp on change sets. - let changeSetData: CreateChangeSetData[] = entitiesPriorChanges.map((e, i) => ({ - changeSetEntityId: e.id, - type: type, - createdAt: new Date(), - createdByUserId: userId, - changes: this.getChangesFromData(e, data[i], type) + + let changeSetData: CreateChangeSetData[] = await Promise.all(entitiesPriorChanges.map(async (e, i) => { + restoreExcludeProperties(e, this.entityClass); + restoreExcludeProperties(data[i], this.entityClass); + const changes: NewChange[] = this.getChangesFromData(e, data[i], type); + await Promise.all([ + removeExcludeProperties(e, this.entityClass), + removeExcludeProperties(data[i], this.entityClass) + ]); + + return { + changeSetEntityId: e.id, + type, + createdAt: new Date(), + createdByUserId: userId, + changes + }; })); if (!force) { changeSetData = changeSetData.filter(d => d.changes.length); @@ -423,12 +447,12 @@ export class ChangeSetRepository< * @returns The id of the currently logged in user or undefined if that didn't work. */ protected async getCreatedBy(): Promise { - const currentRequest: HttpRequest | undefined = inject(ZIBRI_DI_TOKENS.CURRENT_REQUEST); - if (!currentRequest) { + const context: HttpRequestContext | WebsocketRequestContext | undefined = inject(ZIBRI_DI_TOKENS.CURRENT_REQUEST_CONTEXT); + if (!context) { throw new Error('No request in context'); } const user: BaseUser | undefined = await this.authService.getCurrentUser( - currentRequest, + context, this.authService.strategies, false ); @@ -445,7 +469,7 @@ export class ChangeSetRepository< ): (keyof (CreateData | UpdateData | DeepPartial))[] { const keys: (keyof (CreateData | UpdateData | DeepPartial))[] = []; for (const key in data) { - if (!this.keysToExcludeFromChangeSets.includes(key as keyof T)) { + if (!this.keysToExcludeFromChangeSets.has(key as keyof T)) { keys.push(key as keyof (CreateData | UpdateData | DeepPartial)); } } diff --git a/src/change-sets/soft-delete-repository.ts b/src/change-sets/soft-delete-repository.ts index 8b6ec0e..b9b2471 100644 --- a/src/change-sets/soft-delete-repository.ts +++ b/src/change-sets/soft-delete-repository.ts @@ -18,6 +18,7 @@ import { SoftDeleteUpdateByIdOptions } from './models/soft-delete-update-by-id-o import { SoftDeleteWhere } from './models/soft-delete-where.model'; import { Where } from '../data-source/models/where/where-filter.model'; import { NotFoundError } from '../error-handling/errors/not-found.error'; +import { removeExcludeProperties } from '../global/model-registry/remove-exclude-properties.function'; import { LoggerInterface } from '../logging/logger.interface'; /** @@ -51,10 +52,11 @@ export class SoftDeleteRepository< > extends ChangeSetRepository { - protected override readonly keysToExcludeFromChangeSets: (keyof T)[] = ['changeSets', 'deleted']; + protected override readonly keysToExcludeFromChangeSets: Set = new Set(); constructor(entityClass: Newable, repo: TORepository | Repository, logger: LoggerInterface) { super(entityClass, repo, logger); + this.keysToExcludeFromChangeSets.add('deleted'); } // eslint-disable-next-line jsdoc/require-jsdoc @@ -129,17 +131,20 @@ export class SoftDeleteRepository< } // eslint-disable-next-line jsdoc/require-jsdoc - async deleteById(id: T['id'], options?: SoftDeleteByIdOptions): Promise { + async deleteById(id: T['id'], options?: SoftDeleteByIdOptions): Promise { if (options?.hardDelete === true) { - await super.deleteById(id, options); - return; + return await super.deleteById(id, options); } const entity: T = await this.findById(id, options); if (entity.deleted) { throw new NotFoundError(`Could not find ${this.entityClass.name} with id "${id}".`); } - await this.updateById(id, { deleted: true } as UpdateData, options); - await this.createChangeSet(entity, { deleted: true } as UpdateData, ChangeSetType.DELETE, options, true); + const res: T = await this.updateById(id, { deleted: true } as UpdateData, options); + await Promise.all([ + this.createChangeSet(entity, { deleted: true } as UpdateData, ChangeSetType.DELETE, options, true), + removeExcludeProperties(res, this.entityClass) + ]); + return res; } // eslint-disable-next-line jsdoc/require-jsdoc diff --git a/src/context/als.utilities.ts b/src/context/als.utilities.ts new file mode 100644 index 0000000..e70cab2 --- /dev/null +++ b/src/context/als.utilities.ts @@ -0,0 +1,61 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; + +import { HttpRequestContext } from './request/http-request.context'; +import { WebsocketRequestContext } from './request/websocket-request.context'; + +/** + * Encapsulates functionality around async local storage. + */ +export abstract class AlsUtilities { + private static readonly httpRequest: AsyncLocalStorage = new AsyncLocalStorage(); + private static readonly websocketRequest: AsyncLocalStorage = new AsyncLocalStorage(); + + /** + * Resolves the currently active request context from the async local storage. + * @returns The currently active request context. + * @throws When the async local storage store has not been initialized yet. + */ + static getCurrentRequestContext(): HttpRequestContext | WebsocketRequestContext | undefined { + return this.getCurrentHttpRequestContext() ?? this.getCurrentWebsocketRequestContext(); + } + + /** + * Runs the given function with the request context saved in async local storage. + * @param context - The request context. + * @param fn - The function to run. + * @returns The result of the function. + */ + static runWithHttpRequestContext(context: HttpRequestContext, fn: () => T): T { + return this.httpRequest.run(context, fn); + } + + /** + * Resolves the currently active request context from the async local storage. + * @returns The currently active http request context. + * @throws When the async local storage store has not been initialized yet. + */ + protected static getCurrentHttpRequestContext(): HttpRequestContext | undefined { + const store: HttpRequestContext | undefined = this.httpRequest.getStore(); + return store; + } + + /** + * Runs the given function with the request context saved in async local storage. + * @param context - The request context. + * @param fn - The function to run. + * @returns The result of the function. + */ + static runWithWebsocketRequestContext(context: WebsocketRequestContext, fn: () => T): T { + return this.websocketRequest.run(context, fn); + } + + /** + * Resolves the currently active request context from the async local storage. + * @returns The currently active websocket request context. + * @throws When the async local storage store has not been initialized yet. + */ + protected static getCurrentWebsocketRequestContext(): WebsocketRequestContext | undefined { + const store: WebsocketRequestContext | undefined = this.websocketRequest.getStore(); + return store; + } +} \ No newline at end of file diff --git a/src/context/base-context.ts b/src/context/base-context.ts new file mode 100644 index 0000000..15762f0 --- /dev/null +++ b/src/context/base-context.ts @@ -0,0 +1,10 @@ +/** + * The base context that any context needs to extend from. + */ +export abstract class BaseContext { + abstract readonly type: T; + /** + * The cached token values. + */ + protected readonly tokenValues: Map = new Map(); +} \ No newline at end of file diff --git a/src/context/request/http-request.context.test.ts b/src/context/request/http-request.context.test.ts new file mode 100644 index 0000000..1ba4816 --- /dev/null +++ b/src/context/request/http-request.context.test.ts @@ -0,0 +1,93 @@ + +import { afterAll, beforeAll, describe, expect, test } from '@jest/globals'; + +import { RequestContextToken } from './request-context-token.model'; +import { createTestDataSource, defaultTestServerEntities } from '../../__testing__/test-server/create-test-data-source.function'; +import { StartedTestServer, startTestServer } from '../../__testing__/test-server/start-test-server.function'; +import { Repository } from '../../data-source/repository'; +import { InjectRepository } from '../../di/decorators/inject-repository.decorator'; +import { BaseEntity } from '../../entity/base-entity.model'; +import { Entity } from '../../entity/decorators/entity.decorator'; +import { Property } from '../../entity/decorators/property.decorator'; +import { KnownHeader } from '../../http/known-header.enum'; +import { Controller } from '../../routing/decorators/controller.decorator'; +import { Get } from '../../routing/decorators/get.decorator'; + +const CURRENT_USER: RequestContextToken<{ isAdmin: boolean }> = new RequestContextToken( + 'current-user', + (ctx) => { + const auth: string | undefined = ctx.request.headers[KnownHeader.AUTHORIZATION]; + return { isAdmin: auth === 'admin' }; + } +); + +@Entity() +class Test extends BaseEntity { + @Property.string({ + exclude: async (_, ctx) => { + if (!ctx) { + return false; + } + const { isAdmin } = await ctx.get(CURRENT_USER); + return !isAdmin; + } + }) + secret!: string; + + @Property.string({ + default: async (_, ctx) => { + if (!ctx) { + throw new Error('Could not find request context when resolving default value for Test.label'); + } + const { isAdmin } = await ctx.get(CURRENT_USER); + return isAdmin ? 'admin-default' : 'user-default'; + } + }) + label!: string; +} + +@Controller('/context-test') +class ContextTestController { + constructor( + @InjectRepository(Test) + private readonly testRepository: Repository + ) {} + + @Get('/') + async get(): Promise { + return await this.testRepository.create({ secret: 'top-secret' }); + } +} + +let server: StartedTestServer; +let baseUrl: string; +describe('request context integration', () => { + beforeAll(async () => { + server = await startTestServer({ + dataSources: [createTestDataSource({ entities: [...defaultTestServerEntities, Test] })], + controllers: [ContextTestController] + }); + baseUrl = await server.start(); + }, 10000); + + afterAll(async () => { + await server.shutdown(); + }); + + test('context is available during exclude/default evaluation and is cached', async () => { + const res: Response = await fetch(`${baseUrl}/context-test`, { headers: { Authorization: 'admin' } }); + expect(res.status).toBe(200); + + const body: Test = await res.json() as Test; + expect(body.secret).toBe('top-secret'); + expect(body.label).toBe('admin-default'); + }); + + test('exclude sees the request context for non-admin users', async () => { + const res: Response = await fetch(`${baseUrl}/context-test`, { headers: { Authorization: 'user' } }); + + const body: Test = await res.json() as Test; + expect(body.secret).toBeUndefined(); + expect(body.label).toBe('user-default'); + }); +}); \ No newline at end of file diff --git a/src/context/request/http-request.context.ts b/src/context/request/http-request.context.ts new file mode 100644 index 0000000..1aaff85 --- /dev/null +++ b/src/context/request/http-request.context.ts @@ -0,0 +1,41 @@ +import { BaseContext } from '../base-context'; +import { RequestContextToken } from './request-context-token.model'; +import { HttpRequest } from '../../http/http-request.model'; +import { Newable } from '../../types/newable.type'; + +/** + * The context of an incoming http request. + */ +export class HttpRequestContext extends BaseContext<'http-request'> { + // eslint-disable-next-line jsdoc/require-jsdoc + readonly type: 'http-request' = 'http-request'; + + constructor( + readonly request: HttpRequest, + readonly controllerClass: Newable | undefined, + readonly controllerMethod: string | undefined + ) { + super(); + } + + /** + * Whether or not a value for the given token has been cached. + * @param token - The token to check. + * @returns True if a value has been cached, false otherwise. + */ + has(token: RequestContextToken): boolean { + return this.tokenValues.has(token.key); + } + + /** + * Either returns the cached value for the given token or initializes and caches a new value. + * @param token - The token to get the value of. + * @returns Either the cached or a new value. + */ + get(token: RequestContextToken): T | Promise { + if (!this.has(token)) { + this.tokenValues.set(token.key, token.fn(this)); + } + return this.tokenValues.get(token.key) as T | Promise; + } +} \ No newline at end of file diff --git a/src/context/request/request-context-token.model.ts b/src/context/request/request-context-token.model.ts new file mode 100644 index 0000000..f8414d4 --- /dev/null +++ b/src/context/request/request-context-token.model.ts @@ -0,0 +1,95 @@ +import { HttpRequestContext } from './http-request.context'; +import { WebsocketRequestContext } from './websocket-request.context'; +import { TwoFactorServiceInterface } from '../../auth/2fa/two-factor-service.interface'; +import { AuthServiceInterface } from '../../auth/auth-service.interface'; +import { CurrentUserMetadata } from '../../auth/decorators/current-user.decorator'; +import { BaseUser } from '../../auth/models/base-user.model'; +import { IsLoggedInMetadata } from '../../auth/models/is-logged-in-metadata.model'; +import { ZIBRI_DI_TOKENS } from '../../di/default/zibri-di-tokens.default'; +import { inject } from '../../di/inject.function'; +import { MetadataUtilities } from '../../utilities/metadata.utilities'; + +const allRequestContextTokenKeys: Set = new Set(); + +/** + * Defines a request context token for the given key. + */ +export class RequestContextToken { + // eslint-disable-next-line jsdoc/require-jsdoc + protected readonly __brand?: T; + + constructor( + readonly key: string, + readonly fn: (ctx: HttpRequestContext | WebsocketRequestContext) => T | Promise + ) { + if (allRequestContextTokenKeys.has(key)) { + throw new Error([`A RequestContextToken with the key "${key}" already exists.`].join('\n')); + } + allRequestContextTokenKeys.add(key); + } + + // eslint-disable-next-line jsdoc/require-jsdoc + toString(): string { + return this.key; + } +} + +/** + * Tokens for the current request. + */ +// eslint-disable-next-line typescript/typedef +export const ZIBRI_REQUEST_CONTEXT_TOKENS = { + CURRENT_USER: new RequestContextToken( + 'current_user', + async ctx => { + const authService: AuthServiceInterface = inject(ZIBRI_DI_TOKENS.AUTH_SERVICE); + if (!ctx.controllerClass || !ctx.controllerMethod) { + return await authService.getCurrentUser(ctx, authService.strategies, false); + } + + const currentUserMetadata: CurrentUserMetadata | undefined = MetadataUtilities.getRouteCurrentUser( + ctx.controllerClass, + ctx.controllerMethod + ); + + return await authService.getCurrentUser( + ctx, + currentUserMetadata?.allowedStrategies ?? authService.strategies, + currentUserMetadata?.required ?? false + ); + } + ), + IS_LOGGED_IN: new RequestContextToken( + 'is_logged_in', + async ctx => { + const authService: AuthServiceInterface = inject(ZIBRI_DI_TOKENS.AUTH_SERVICE); + if (!ctx.controllerClass || !ctx.controllerMethod) { + return await authService.isLoggedIn(ctx, authService.strategies); + } + + const isLoggedInMetadata: IsLoggedInMetadata | undefined = await authService.resolveIsLoggedInMetadata( + ctx.controllerClass, + ctx.controllerMethod + ); + return await authService.isLoggedIn( + ctx, + isLoggedInMetadata?.allowedStrategies ?? authService.strategies + ); + } + ), + HAS_2FA: new RequestContextToken( + 'has_2fa', + async (ctx): Promise => { + const twoFactorService: TwoFactorServiceInterface = inject(ZIBRI_DI_TOKENS.TWO_FACTOR_SERVICE); + if (!ctx.controllerClass || !ctx.controllerMethod) { + return false; + } + + const user: BaseUser | undefined = await ctx.get(ZIBRI_REQUEST_CONTEXT_TOKENS.CURRENT_USER); + if (!user) { + return false; + } + return await twoFactorService.has2fa(user, ctx); + } + ) +} as const satisfies Record>; \ No newline at end of file diff --git a/src/context/request/websocket-request.context.ts b/src/context/request/websocket-request.context.ts new file mode 100644 index 0000000..2a30152 --- /dev/null +++ b/src/context/request/websocket-request.context.ts @@ -0,0 +1,43 @@ +import { RequestContextToken } from './request-context-token.model'; +import { Newable } from '../../types/newable.type'; +import { BaseWebsocketConnection } from '../../websocket/models/connection/base-websocket-connection.model'; +import { WebsocketRequest } from '../../websocket/models/websocket-request.model'; +import { BaseContext } from '../base-context'; + +/** + * The context of an incoming websocket request. + */ +export class WebsocketRequestContext extends BaseContext<'websocket-request'> { + // eslint-disable-next-line jsdoc/require-jsdoc + readonly type: 'websocket-request' = 'websocket-request'; + + constructor( + readonly request: WebsocketRequest, + readonly connection: BaseWebsocketConnection | undefined, + readonly controllerClass: Newable | undefined, + readonly controllerMethod: string | undefined + ) { + super(); + } + + /** + * Whether or not a value for the given token has been cached. + * @param token - The token to check. + * @returns True if a value has been cached, false otherwise. + */ + has(token: RequestContextToken): boolean { + return this.tokenValues.has(token.key); + } + + /** + * Either returns the cached value for the given token or initializes and caches a new value. + * @param token - The token to get the value of. + * @returns Either the cached or a new value. + */ + get(token: RequestContextToken): T | Promise { + if (!this.has(token)) { + this.tokenValues.set(token.key, token.fn(this)); + } + return this.tokenValues.get(token.key) as T | Promise; + } +} \ No newline at end of file diff --git a/src/data-source/exclude-property.test.ts b/src/data-source/exclude-property.test.ts new file mode 100644 index 0000000..2863cc2 --- /dev/null +++ b/src/data-source/exclude-property.test.ts @@ -0,0 +1,383 @@ +/* eslint-disable typescript/no-unsafe-assignment */ +import { describe, expect, it } from '@jest/globals'; + +import { BaseEntity } from '../entity/base-entity.model'; +import { Property } from '../entity/decorators/property.decorator'; +import { removeExcludeProperties } from '../global/model-registry/remove-exclude-properties.function'; +import { restoreExcludeProperties } from '../global/model-registry/restore-exclude-properties.function'; + +// ─── Test entities ──────────────────────────────────────────────────────────── + +class Address { + @Property.string() + street!: string; + + @Property.string({ exclude: true }) + internalCode!: string; +} + +class Order { + @Property.string({ primary: true }) + id!: string; + + @Property.number() + total!: number; + + @Property.string({ exclude: true }) + internalNote!: string; + + @Property.manyToOne({ target: () => User, inverseSide: 'orders' }) + user!: unknown; +} + +class User extends BaseEntity { + @Property.string() + name!: string; + + @Property.string({ exclude: true }) + passwordHash!: string; + + @Property.string({ exclude: true }) + secret!: string; + + @Property.object({ cls: () => Address }) + address!: Address; + + @Property.oneToMany({ target: () => Order, inverseSide: 'user' }) + orders!: Order[]; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── +function makeUser(overrides: Partial = {}): User { + const user: User = new User(); + user.id = 'user-1'; + user.name = 'Alice'; + user.passwordHash = 'hashed-pw'; + user.secret = 'top-secret'; + const address: Address = new Address(); + address.street = '123 Main St'; + address.internalCode = 'INT-001'; + user.address = address; + const order: Order = new Order(); + order.id = 'order-1'; + order.total = 99; + order.internalNote = 'do not ship'; + user.orders = [order]; + return Object.assign(user, overrides); +} + +// ─── removeExcludeProperties ────────────────────────────────────────────────── + +describe('removeExcludeProperties', () => { + describe('basic exclusion', () => { + it('makes excluded properties non-enumerable', async () => { + const user: User = makeUser(); + await removeExcludeProperties(user, User); + + const descriptor: PropertyDescriptor | undefined = Object.getOwnPropertyDescriptor(user, 'passwordHash'); + expect(descriptor?.enumerable).toBe(false); + }); + + it('still allows reading excluded properties via getter', async () => { + const user: User = makeUser(); + await removeExcludeProperties(user, User); + + expect(user.passwordHash).toBe('hashed-pw'); + expect(user.secret).toBe('top-secret'); + }); + + it('does not affect non-excluded properties', async () => { + const user: User = makeUser(); + await removeExcludeProperties(user, User); + + const descriptor: PropertyDescriptor | undefined = Object.getOwnPropertyDescriptor(user, 'name'); + expect(descriptor?.enumerable).toBe(true); + expect(user.name).toBe('Alice'); + }); + + it('excluded properties do not appear in Object.keys', async () => { + const user: User = makeUser(); + await removeExcludeProperties(user, User); + + expect(Object.keys(user)).not.toContain('passwordHash'); + expect(Object.keys(user)).not.toContain('secret'); + expect(Object.keys(user)).toContain('name'); + expect(Object.keys(user)).toContain('id'); + }); + }); + + describe('JSON serialization', () => { + it('excluded properties are absent from JSON.stringify output', async () => { + const user: User = makeUser(); + await removeExcludeProperties(user, User); + + const json: unknown = JSON.parse(JSON.stringify(user)); + expect(json).not.toHaveProperty('passwordHash'); + expect(json).not.toHaveProperty('secret'); + }); + + it('non-excluded properties are present in JSON.stringify output', async () => { + const user: User = makeUser(); + await removeExcludeProperties(user, User); + + const json: unknown = JSON.parse(JSON.stringify(user)); + expect(json).toHaveProperty('name', 'Alice'); + expect(json).toHaveProperty('id', 'user-1'); + }); + }); + + describe('spreading', () => { + it('excluded properties are absent after spreading the entity', async () => { + const user: User = makeUser(); + await removeExcludeProperties(user, User); + + const spread: User = { ...user }; + expect(spread).not.toHaveProperty('passwordHash'); + expect(spread).not.toHaveProperty('secret'); + }); + + it('non-excluded properties are present after spreading', async () => { + const user: User = makeUser(); + await removeExcludeProperties(user, User); + + const spread: User = { ...user }; + expect(spread).toHaveProperty('name', 'Alice'); + }); + + it('explicitly copying an excluded property after spread preserves it', async () => { + const user: User = makeUser(); + await removeExcludeProperties(user, User); + + const spread: User = { ...user, passwordHash: user.passwordHash }; + expect(spread).toHaveProperty('passwordHash', 'hashed-pw'); + }); + }); + + describe('setter after hiding', () => { + it('updating an excluded property via setter is reflected in the getter', async () => { + const user: User = makeUser(); + await removeExcludeProperties(user, User); + + user.passwordHash = 'new-hash'; + expect(user.passwordHash).toBe('new-hash'); + }); + + it('updated value via setter is still not enumerable', async () => { + const user: User = makeUser(); + await removeExcludeProperties(user, User); + + user.passwordHash = 'new-hash'; + expect(Object.keys(user)).not.toContain('passwordHash'); + }); + }); + + describe('idempotency', () => { + it('calling removeExcludeProperties twice does not throw', () => { + const user: User = makeUser(); + expect(async () => { + await removeExcludeProperties(user, User); + await removeExcludeProperties(user, User); + }).not.toThrow(); + }); + + it('value is unchanged after double application', async () => { + const user: User = makeUser(); + await removeExcludeProperties(user, User); + await removeExcludeProperties(user, User); + + expect(user.passwordHash).toBe('hashed-pw'); + }); + }); + + describe('nested object property', () => { + it('excludes properties on nested objects', async () => { + const user: User = makeUser(); + await removeExcludeProperties(user, User); + + expect(Object.keys(user.address)).not.toContain('internalCode'); + }); + + it('excluded nested property is still readable', async () => { + const user: User = makeUser(); + await removeExcludeProperties(user, User); + + expect(user.address.internalCode).toBe('INT-001'); + }); + + it('nested excluded property absent from JSON output', async () => { + const user: User = makeUser(); + await removeExcludeProperties(user, User); + + const json: User = JSON.parse(JSON.stringify(user)); + expect(json.address).not.toHaveProperty('internalCode'); + expect(json.address).toHaveProperty('street', '123 Main St'); + }); + + it('nested excluded property absent after spreading nested object', async () => { + const user: User = makeUser(); + await removeExcludeProperties(user, User); + + const spread: Address = { ...user.address }; + expect(spread).not.toHaveProperty('internalCode'); + }); + }); + + describe('relation arrays (oneToMany)', () => { + it('excludes properties on entities inside relation arrays', async () => { + const user: User = makeUser(); + await removeExcludeProperties(user, User); + + expect(Object.keys(user.orders[0])).not.toContain('internalNote'); + }); + + it('excluded relation array item property is still readable', async () => { + const user: User = makeUser(); + await removeExcludeProperties(user, User); + + expect(user.orders[0].internalNote).toBe('do not ship'); + }); + + it('relation array items excluded properties absent from JSON output', async () => { + const user: User = makeUser(); + await removeExcludeProperties(user, User); + + const json: User = JSON.parse(JSON.stringify(user)); + expect(json.orders[0]).not.toHaveProperty('internalNote'); + expect(json.orders[0]).toHaveProperty('total', 99); + }); + + it('spreading a relation array item loses excluded property', async () => { + const user: User = makeUser(); + await removeExcludeProperties(user, User); + + const spread: Order = { ...user.orders[0] }; + expect(spread).not.toHaveProperty('internalNote'); + }); + }); + + describe('null / undefined handling', () => { + it('does not throw when called with null', async () => { + // eslint-disable-next-line unicorn/no-null + await expect(removeExcludeProperties(null, User)).resolves.not.toThrow(); + }); + + it('does not throw when called with undefined', async () => { + await expect(removeExcludeProperties(undefined, User)).resolves.not.toThrow(); + }); + + it('does not throw when a nested relation is null', async () => { + const user: User = makeUser(); + // eslint-disable-next-line unicorn/no-null + user.address = null as unknown as Address; + await expect(removeExcludeProperties(user, User)).resolves.not.toThrow(); + }); + }); +}); + +// ─── restoreExcludeProperties ───────────────────────────────────────────────── + +describe('restoreExcludeProperties', () => { + it('restores excluded properties to enumerable own properties', async () => { + const user: User = makeUser(); + await removeExcludeProperties(user, User); + restoreExcludeProperties(user, User); + + const descriptor: PropertyDescriptor | undefined = Object.getOwnPropertyDescriptor(user, 'passwordHash'); + expect(descriptor?.enumerable).toBe(true); + expect(descriptor?.get).toBeUndefined(); + }); + + it('restored value is correct', async () => { + const user: User = makeUser(); + await removeExcludeProperties(user, User); + restoreExcludeProperties(user, User); + + expect(user.passwordHash).toBe('hashed-pw'); + }); + + it('restored properties appear in Object.keys', async () => { + const user: User = makeUser(); + await removeExcludeProperties(user, User); + restoreExcludeProperties(user, User); + + expect(Object.keys(user)).toContain('passwordHash'); + expect(Object.keys(user)).toContain('secret'); + }); + + it('restored properties appear in JSON.stringify output', async () => { + const user: User = makeUser(); + await removeExcludeProperties(user, User); + restoreExcludeProperties(user, User); + + const json: User = JSON.parse(JSON.stringify(user)); + expect(json).toHaveProperty('passwordHash', 'hashed-pw'); + }); + + it('restores updated value, not original', async () => { + const user: User = makeUser(); + await removeExcludeProperties(user, User); + user.passwordHash = 'updated-hash'; + restoreExcludeProperties(user, User); + + expect(user.passwordHash).toBe('updated-hash'); + expect(Object.getOwnPropertyDescriptor(user, 'passwordHash')?.enumerable).toBe(true); + }); + + it('is safe to call on data that was never hidden (plain create data)', () => { + const plainData: Partial = { name: 'Bob', passwordHash: 'raw-hash' }; + expect(() => restoreExcludeProperties(plainData, User)).not.toThrow(); + expect(plainData.name).toBe('Bob'); + }); + + it('restores nested object excluded properties', async () => { + const user: User = makeUser(); + await removeExcludeProperties(user, User); + restoreExcludeProperties(user, User); + + const descriptor: PropertyDescriptor | undefined = Object.getOwnPropertyDescriptor(user.address, 'internalCode'); + expect(descriptor?.enumerable).toBe(true); + expect(user.address.internalCode).toBe('INT-001'); + }); + + it('restores relation array item excluded properties', async () => { + const user: User = makeUser(); + await removeExcludeProperties(user, User); + restoreExcludeProperties(user, User); + + const descriptor: PropertyDescriptor | undefined = Object.getOwnPropertyDescriptor(user.orders[0], 'internalNote'); + expect(descriptor?.enumerable).toBe(true); + expect(user.orders[0].internalNote).toBe('do not ship'); + }); + + describe('round-trip', () => { + it('remove then restore produces an object equal to the original', async () => { + const user: User = makeUser(); + const original: User = JSON.parse(JSON.stringify({ + ...user, + passwordHash: user.passwordHash, + secret: user.secret, + address: { ...user.address, internalCode: user.address.internalCode }, + orders: user.orders.map(o => ({ ...o, internalNote: o.internalNote })) + })); + + await removeExcludeProperties(user, User); + restoreExcludeProperties(user, User); + + expect(JSON.parse(JSON.stringify(user))).toEqual(original); + }); + }); +}); + +// ─── Decorator guard ────────────────────────────────────────────────────────── + +describe('Property decorator', () => { + it('throws when a primary key is marked as excluded', () => { + expect(() => { + // eslint-disable-next-line unusedImports/no-unused-vars + class BadEntity { + @Property.string({ primary: true, exclude: true }) + id!: string; + } + }).toThrow('BadEntity.id: Cannot mark a primary key with "exclude."'); + }); +}); \ No newline at end of file diff --git a/src/data-source/models/where/where-filter-to-find-options-where.function.ts b/src/data-source/models/where/where-filter-to-find-options-where.function.ts index ed2bf83..0da9789 100644 --- a/src/data-source/models/where/where-filter-to-find-options-where.function.ts +++ b/src/data-source/models/where/where-filter-to-find-options-where.function.ts @@ -1,3 +1,5 @@ +import assert from 'node:assert'; + import { FindOptionsWhere as ToFindOptionsWhere, FindOptionsWhereProperty as ToFindOptionsWhereProperty, Or, FindOperator, Equal, IsNull, Not, And, Like, In, MoreThan, LessThan, MoreThanOrEqual, LessThanOrEqual, ILike, ArrayContains, ArrayContainedBy, Raw } from 'typeorm'; import { ArrayWhereFilter } from './array-where-filter.model'; @@ -7,11 +9,8 @@ import { NumberWhereFilter } from './number-where-filter.model'; import { ObjectWhereFilter } from './object-where-filter.model'; import { StringWhereFilter } from './string-where-filter.model'; import { WhereFilter, Where, WhereFilterProperty } from './where-filter.model'; -import { BaseEntity } from '../../../entity/base-entity.model'; import { PropertyMetadata } from '../../../entity/decorators/property.decorator'; -import { ManyToOnePropertyMetadata } from '../../../entity/models/many-to-one-property-metadata.model'; import { ObjectPropertyMetadata } from '../../../entity/models/object-property-metadata.model'; -import { OneToOnePropertyMetadata } from '../../../entity/models/one-to-one-property-metadata.model'; import { Relation } from '../../../entity/models/relation.enum'; import { ExcludeStrict } from '../../../types/exclude-strict.type'; import { Newable } from '../../../types/newable.type'; @@ -58,14 +57,16 @@ function singleWhereFilterToFindOptionsWhere( } const propertyMetadata: PropertyMetadata = properties[key]; + let nestedProperties: Record | undefined; + switch (propertyMetadata.type) { case 'object': { - properties = MetadataUtilities.getModelProperties(propertyMetadata.cls()); + nestedProperties = MetadataUtilities.getModelProperties(propertyMetadata.cls()); break; } case Relation.ONE_TO_ONE: case Relation.MANY_TO_ONE: { - properties = MetadataUtilities.getModelProperties(propertyMetadata.target()); + nestedProperties = MetadataUtilities.getModelProperties(propertyMetadata.target()); break; } case 'string': @@ -83,7 +84,8 @@ function singleWhereFilterToFindOptionsWhere( } res[key] = propertyToFindOperator( prop, - propertyMetadata + propertyMetadata, + nestedProperties ) as typeof key extends 'toString' ? unknown : ToFindOptionsWhereProperty>; } return res; @@ -93,16 +95,18 @@ function singleWhereFilterToFindOptionsWhere( * Transforms a property filter or multiple property filters to a typeorm FindOperator. * @param property - The property filter to transform. * @param propertyMetadata - The metadata of the property. + * @param nestedProperties - Any nested properties of the where filter. * @returns The typeorm FindOperator. */ function propertyToFindOperator( property: WhereFilterProperty | WhereFilterProperty[], - propertyMetadata: PropertyMetadata + propertyMetadata: PropertyMetadata, + nestedProperties: Record | undefined ): FindOperator { if (Array.isArray(property)) { - return Or(...property.map(p => singlePropertyToFindOperator(p, propertyMetadata))); + return Or(...property.map(p => singlePropertyToFindOperator(p, propertyMetadata, nestedProperties))); } - return singlePropertyToFindOperator(property, propertyMetadata); + return singlePropertyToFindOperator(property, propertyMetadata, nestedProperties); } /** @@ -153,20 +157,21 @@ const whereFilterKeysRecord: Record = { includes: 'includes', isIncludedIn: 'isIncludedIn' }; - -const whereFilterKeys: WhereFilterKeys[] = ObjectUtilities.values(whereFilterKeysRecord); +const whereFilterKeySet: Set = new Set(ObjectUtilities.values(whereFilterKeysRecord)); /** * Transforms a single where filter property to a typeorm FindOperator. * @param property - The where filter property to transform. * @param propertyMetadata - The metadata of the where filter property. + * @param nestedProperties - Any nested properties of the where filter. * @returns A typeorm FindOperator. * @throws When the where filter property is invalid. */ // eslint-disable-next-line sonar/cognitive-complexity function singlePropertyToFindOperator( property: WhereFilterProperty, - propertyMetadata: PropertyMetadata + propertyMetadata: PropertyMetadata, + nestedProperties: Record | undefined ): FindOperator { if (property === null) { // eslint-disable-next-line typescript/no-unsafe-return @@ -261,10 +266,11 @@ function singlePropertyToFindOperator( { json: nestedLiteral } ) as FindOperator; } - return whereFilterToFindOptionsWhere( - whereFilter.where, - (propertyMetadata as OneToOnePropertyMetadata | ManyToOnePropertyMetadata) - .target() as unknown as Newable> + // nestedProperties is guaranteed here since ONE_TO_ONE/MANY_TO_ONE always sets it + assert(nestedProperties != undefined); + return singleWhereFilterToFindOptionsWhere( + whereFilter.where as WhereFilter>, + nestedProperties ) as unknown as FindOperator; } case 'includes': { @@ -297,5 +303,5 @@ function singlePropertyToFindOperator( * @param key - The key to check. */ function isWhereFilterKey(key: unknown): key is WhereFilterKeys { - return whereFilterKeys.includes(key as WhereFilterKeys); + return whereFilterKeySet.has(key as WhereFilterKeys); } \ No newline at end of file diff --git a/src/data-source/repository.ts b/src/data-source/repository.ts index 73550d1..008ac0d 100644 --- a/src/data-source/repository.ts +++ b/src/data-source/repository.ts @@ -1,18 +1,10 @@ import { Repository as TORepository, FindOptionsWhere, EntityManager, QueryFailedError as TOQueryFailedError } from 'typeorm'; -import { QueryFailedError } from './query-failed.error'; import { BaseEntity } from '../entity/base-entity.model'; -import { Transaction } from './transaction/transaction.model'; -import { PropertyMetadata } from '../entity/decorators/property.decorator'; -import { ArrayPropertyMetadata } from '../entity/models/array-property-metadata.model'; -import { Relation } from '../entity/models/relation.enum'; import { LoggerInterface } from '../logging/logger.interface'; import { PaginationResult } from '../open-api/pagination-result.model'; import { DeepPartial } from '../types/deep-partial.type'; import { Newable } from '../types/newable.type'; -import { MetadataUtilities } from '../utilities/metadata.utilities'; -import { whereFilterToFindOptionsWhere } from './models/where/where-filter-to-find-options-where.function'; -import { NotFoundError } from '../error-handling/errors/not-found.error'; import { CreateAllOptions } from './models/options/create-all-options.model'; import { CreateOptions } from './models/options/create-options.model'; import { DeleteAllOptions } from './models/options/delete-all-options.model'; @@ -23,7 +15,15 @@ import { FindByIdOptions } from './models/options/find-by-id-options.model'; import { FindOneOptions } from './models/options/find-one-options.model'; import { UpdateAllOptions } from './models/options/update-all-options.model'; import { UpdateByIdOptions } from './models/options/update-by-id-options.model'; +import { whereFilterToFindOptionsWhere } from './models/where/where-filter-to-find-options-where.function'; import { Where } from './models/where/where-filter.model'; +import { QueryFailedError } from './query-failed.error'; +import { Transaction } from './transaction/transaction.model'; +import { NotFoundError } from '../error-handling/errors/not-found.error'; +import { ModelRegistry } from '../global/model-registry/model.registry'; +import { removeExcludeProperties } from '../global/model-registry/remove-exclude-properties.function'; +import { restoreExcludeProperties } from '../global/model-registry/restore-exclude-properties.function'; +import { setDefaultValues } from '../global/model-registry/set-default-values.function'; /** * A repository that handles data source related things for its entity. @@ -41,6 +41,7 @@ export class Repository< protected readonly logger: LoggerInterface ) { this.typeOrmRepository = repo instanceof Repository ? repo.typeOrmRepository : repo; + ModelRegistry.get(this.entityClass); } private getManager(transaction: Transaction | undefined): EntityManager { @@ -54,94 +55,6 @@ export class Repository< return whereFilterToFindOptionsWhere(where, this.entityClass); } - private async setDefaultValues(data: Data, entityClass: Newable): Promise { - const props: Record = MetadataUtilities.getModelProperties(entityClass); - for (const key in props) { - const property: PropertyMetadata = props[key]; - - if (data[key as keyof Data] !== undefined) { - switch (property.type) { - case 'string': - case 'number': - case 'boolean': - case 'date': - case 'file': - case 'unknown': { - break; - } - case Relation.MANY_TO_ONE: - case Relation.ONE_TO_ONE: { - await this.setDefaultValues(data[key as keyof Data], property.target()); - break; - } - case 'object': { - await this.setDefaultValues(data[key as keyof Data], property.cls()); - break; - } - case 'array': { - await this.setDefaultValuesForArray(data[key as keyof Data] as unknown[], property); - break; - } - case Relation.MANY_TO_MANY: - case Relation.ONE_TO_MANY: { - await this.setDefaultValuesForArray(data[key as keyof Data] as unknown[], { - ...property, - type: 'array', - totalMaxSize: '50mb', - items: { - type: 'object', - cls: property.target, - description: undefined, - allowAdditionalProperties: false, - excludeFromChangeSets: property.excludeFromChangeSets, - required: property.required - } - }); - break; - } - } - continue; - } - - if (!('default' in property) || property.default == undefined) { - continue; - } - - if ( - typeof property.default === 'string' - || typeof property.default === 'number' - || typeof property.default === 'boolean' - || property.default instanceof Date - ) { - data[key as keyof Data] = property.default as Data[keyof Data]; - continue; - } - data[key as keyof Data] = await property.default(data) as Data[keyof Data]; - } - } - - private async setDefaultValuesForArray(values: unknown[], property: ArrayPropertyMetadata): Promise { - for (const item of values) { - switch (property.items.type) { - case 'string': - case 'number': - case 'boolean': - case 'date': - case 'file': - case 'unknown': { - break; - } - case 'object': { - await this.setDefaultValues(item, property.items.cls()); - break; - } - case 'array': { - await this.setDefaultValuesForArray(item as unknown[], property.items); - } - } - } - } - /** * Creates a new entity from the given data. * @param data - The create data. @@ -153,10 +66,13 @@ export class Repository< await this.logger.warn('Found an id on the create data, it will be ignored.'); delete data.id; } - await this.setDefaultValues(data, this.entityClass); + await this.beforeSave(data, true); + const manager: EntityManager = this.getManager(options?.transaction); try { - return await manager.save(this.entityClass, data); + const res: T = await manager.save(this.entityClass, data); + await removeExcludeProperties(res, this.entityClass); + return res; } catch (error) { if (error instanceof TOQueryFailedError) { @@ -173,14 +89,17 @@ export class Repository< * @returns All newly created entities. */ async createAll(data: CreateData[], options?: CreateAllOptions): Promise { - let entitiesWithIdCount: number = 0; - for (const d of data) { - if (d.id != undefined && options?.allowId != true) { - delete d.id; - entitiesWithIdCount++; - } - await this.setDefaultValues(d, this.entityClass); - } + const entitiesWithIdCount: number = (await Promise.all( + data.map(async d => { + let hadId: boolean = false; + if (d.id != undefined && options?.allowId != true) { + delete d.id; + hadId = true; + } + await this.beforeSave(d, true); + return hadId; + }) + )).filter(Boolean).length; if (entitiesWithIdCount) { await this.logger.warn( `Found an id on ${entitiesWithIdCount} out of ${data.length} entries of the create data. These ids will be ignored.` @@ -189,7 +108,9 @@ export class Repository< const manager: EntityManager = this.getManager(options?.transaction); try { - return await manager.save(this.entityClass, data); + const res: T[] = await manager.save(this.entityClass, data); + await Promise.all(res.map(r => removeExcludeProperties(r, this.entityClass))); + return res; } catch (error) { if (error instanceof TOQueryFailedError) { @@ -242,7 +163,11 @@ export class Repository< if (!res && required) { throw new NotFoundError(`Could not find ${this.entityClass.name}.`); } - return (res ?? undefined) as B extends false ? T | undefined : T; + if (!res) { + return undefined as B extends false ? T | undefined : T; + } + await removeExcludeProperties(res, this.entityClass); + return res; } /** @@ -254,10 +179,12 @@ export class Repository< const manager: EntityManager = this.getManager(options?.transaction); const where: FindOptionsWhere | FindOptionsWhere[] | undefined = this.resolveFindOptionsWhere(options?.where); try { - return await manager.find( + const res: T[] = await manager.find( this.entityClass, { ...options, where, relations: options?.relations as string[], transaction: undefined } ); + await Promise.all(res.map(r => removeExcludeProperties(r, this.entityClass))); + return res; } catch (error) { if (error instanceof TOQueryFailedError) { @@ -311,10 +238,13 @@ export class Repository< delete data.id; } const manager: EntityManager = this.getManager(options?.transaction); - const dataWithId: DeepPartial = { id, ...data }; + data.id = id; + await this.beforeSave(data, false); try { - return await manager.save(this.entityClass, dataWithId); + const res: T = await manager.save(this.entityClass, data); + await removeExcludeProperties(res, this.entityClass); + return res; } catch (error) { if (error instanceof TOQueryFailedError) { @@ -340,12 +270,15 @@ export class Repository< await this.logger.warn('Found an id on the update data, it will be ignored.'); delete data.id; } + await this.beforeSave(data, false); const toUpdate: DeepPartial[] = (await this.findAll({ where, ...options })).map(t => ({ id: t.id, ...data })); const manager: EntityManager = this.getManager(options?.transaction); try { - return await manager.save(this.entityClass, toUpdate); + const res: T[] = await manager.save(this.entityClass, toUpdate); + await Promise.all(res.map(r => removeExcludeProperties(r, this.entityClass))); + return res; } catch (error) { if (error instanceof TOQueryFailedError) { @@ -359,12 +292,16 @@ export class Repository< * Deletes an entity with the provided id. * @param id - The id of the entity to deleted. * @param options - Additional options, like a transaction. + * @returns The deleted entity. */ - async deleteById(id: T['id'], options?: DeleteByIdOptions): Promise { + async deleteById(id: T['id'], options?: DeleteByIdOptions): Promise { const entityToDelete: T = await this.findById(id, options); + restoreExcludeProperties(entityToDelete, this.entityClass); const manager: EntityManager = this.getManager(options?.transaction); try { - await manager.remove(this.entityClass, entityToDelete); + const res: T = await manager.remove(this.entityClass, entityToDelete); + await removeExcludeProperties(res, this.entityClass); + return res; } catch (error) { if (error instanceof TOQueryFailedError) { @@ -378,16 +315,21 @@ export class Repository< * Deletes all entities that match the provided where filter. * @param where - The where filter to find the entities that should be deleted. * @param options - Additional options, like a transaction. - * @returns An array of all the updated entities. + * @returns An array of all the deleted entities. */ async deleteAll( where: Where, options?: DeleteAllOptions ): Promise { const toDelete: T[] = await this.findAll({ where, ...options }); + for (const element of toDelete) { + restoreExcludeProperties(element, this.entityClass); + } const manager: EntityManager = this.getManager(options?.transaction); try { - return await manager.remove(this.entityClass, toDelete); + const res: T[] = await manager.remove(this.entityClass, toDelete); + await Promise.all(res.map(r => removeExcludeProperties(r, this.entityClass))); + return res; } catch (error) { if (error instanceof TOQueryFailedError) { @@ -396,4 +338,14 @@ export class Repository< throw error; } } + + private async beforeSave( + data: CreateData | UpdateData, + setDefault: boolean + ): Promise { + restoreExcludeProperties(data, this.entityClass); + if (setDefault) { + await setDefaultValues(data, this.entityClass); + } + } } \ No newline at end of file diff --git a/src/di/default/zibri-di-providers.default.ts b/src/di/default/zibri-di-providers.default.ts index 72c1ea5..7f1a1bb 100644 --- a/src/di/default/zibri-di-providers.default.ts +++ b/src/di/default/zibri-di-providers.default.ts @@ -11,10 +11,12 @@ import { TwoFactorService } from '../../auth/2fa/two-factor.service'; import { AuthService } from '../../auth/auth.service'; import { UserService } from '../../auth/user/user.service'; import { BackupService } from '../../backup/backup.service'; +import { AlsUtilities } from '../../context/als.utilities'; import { CronService } from '../../cron/cron.service'; import { DataSourceService } from '../../data-source/data-source.service'; import { EmailService } from '../../email/email.service'; import { errorHandler } from '../../error-handling/error-handler'; +import { EventService } from '../../event/event.service'; import { HttpClient } from '../../http-client/http-client'; import { LocalizeOptionsInput } from '../../localization/models/localize-options.model'; import { LogLevel } from '../../logging/log-level.enum'; @@ -24,7 +26,6 @@ import { PrometheusMetricsService } from '../../metrics/metrics.service'; import { MultithreadingService } from '../../multithreading/services/multithreading.service'; import { OpenApiService } from '../../open-api/open-api.service'; import { Parser } from '../../parsing/parser'; -import { getCurrentRequest } from '../../routing/request.context'; import { Router } from '../../routing/router'; import { FsUtilities } from '../../utilities/fs.utilities'; import { Ms } from '../../utilities/ms'; @@ -71,7 +72,7 @@ export const ZIBRI_DI_PROVIDERS: DiTokenProviderRecord = DATA_SOURCE_SERVICE: { useClass: DataSourceService }, AUTH_SERVICE: { useClass: AuthService }, TWO_FACTOR_SERVICE: { useClass: TwoFactorService }, - OTP_HEADER: { useFactory: () => 'X-Authorization-OTP' }, + OTP_HEADER: { useFactory: () => 'x-authorization-otp' }, OTP_LENGTH: { useFactory: () => 6 }, USER_SERVICE: { useClass: UserService }, JWT_ACCESS_TOKEN_SECRET: { useFactory: () => undefined }, @@ -99,7 +100,6 @@ export const ZIBRI_DI_PROVIDERS: DiTokenProviderRecord = EMAIL_CONFIG: { useFactory: () => undefined }, JWT_PASSWORD_RESET_TOKEN_EXPIRES_IN_MS: { useFactory: () => 300000 }, JWT_CONFIRM_PASSWORD_RESET_URL: { useFactory: () => undefined }, - CURRENT_REQUEST: { useFactory: () => getCurrentRequest() }, MULTITHREADING_OPTIONS: { useFactory: () => ({ maxThreads, @@ -111,5 +111,11 @@ export const ZIBRI_DI_PROVIDERS: DiTokenProviderRecord = MULTITHREADING_SERVICE: { useClass: MultithreadingService }, WEBSOCKET_SERVICE: { useClass: WebsocketService }, WEBSOCKET_OPTIONS: { useFactory: () => ({ timeoutInMs: Ms.SECOND * 5, isAllowedToConnect: () => true }) }, - HTTP_CLIENT: { useClass: HttpClient } + HTTP_CLIENT: { useClass: HttpClient }, + EVENT_SERVICE: { useClass: EventService }, + // dynamic + CURRENT_REQUEST_CONTEXT: { + useFactory: () => AlsUtilities.getCurrentRequestContext(), + cache: false + } }; \ No newline at end of file diff --git a/src/di/default/zibri-di-tokens.default.ts b/src/di/default/zibri-di-tokens.default.ts index dded6b1..edeb4b1 100644 --- a/src/di/default/zibri-di-tokens.default.ts +++ b/src/di/default/zibri-di-tokens.default.ts @@ -4,12 +4,14 @@ import { AuthServiceInterface } from '../../auth/auth-service.interface'; import { PasswordResetEmailTemplate } from '../../auth/strategies/jwt/jwt-auth.controller'; import { UserServiceInterface } from '../../auth/user/user-service.interface'; import { BackupServiceInterface } from '../../backup/backup-service.interface'; +import { HttpRequestContext } from '../../context/request/http-request.context'; +import { WebsocketRequestContext } from '../../context/request/websocket-request.context'; import { CronServiceInterface } from '../../cron/cron-service.interface'; import { DataSourceServiceInterface } from '../../data-source/data-source-service.interface'; import { EmailServiceInterface } from '../../email/email-service.interface'; import { EmailConfigInput } from '../../email/models/email-config.model'; import { GlobalErrorHandler, ErrorPageTemplate } from '../../error-handling/error-handler.model'; -import { HttpRequest } from '../../http/http-request.model'; +import { EventServiceInterface } from '../../event/event-service.interface'; import { HttpClientInterface } from '../../http-client/http-client.interface'; import { FormatDateFn } from '../../localization/formatting/format-date-fn.model'; import { FormatPercentFn } from '../../localization/formatting/format-percent-fn.model'; @@ -41,6 +43,7 @@ function ziToken(k: `zi.${string}`): InjectionToken { */ // eslint-disable-next-line typescript/typedef export const ZIBRI_DI_TOKENS = { + // static/singleton tokens ROUTER: ziToken('zi.router'), LOGGER: ziToken('zi.logger'), LOGGER_TRANSPORTS: ziToken[]>('zi.logger_transports'), @@ -76,11 +79,13 @@ export const ZIBRI_DI_TOKENS = { FORMAT_PERCENT: ziToken('zi.format_percent'), EMAIL_SERVICE: ziToken('zi.email_service'), EMAIL_CONFIG: ziToken('zi.email_config'), - CURRENT_REQUEST: ziToken('zi.current_request'), MULTITHREADING_SERVICE: ziToken('zi.multithreading_service'), MULTITHREADING_OPTIONS: ziToken('zi.multithreading_options'), // eslint-disable-next-line typescript/no-explicit-any WEBSOCKET_SERVICE: ziToken>('zi.websocket_service'), WEBSOCKET_OPTIONS: ziToken('zi.websocket_options'), - HTTP_CLIENT: ziToken('zi.http_client') + HTTP_CLIENT: ziToken('zi.http_client'), + EVENT_SERVICE: ziToken>>('zi.event_service'), + // dynamic/context based tokens + CURRENT_REQUEST_CONTEXT: ziToken('zi.current_request_context') } as const satisfies TokenRecord; \ No newline at end of file diff --git a/src/di/di-container.ts b/src/di/di-container.ts index 795e7a3..1c358fb 100644 --- a/src/di/di-container.ts +++ b/src/di/di-container.ts @@ -111,7 +111,9 @@ export class DiContainer { const instance: T = this.createInstanceFromProvider(provider, resolvingStack); resolvingStack.pop(); - this.instances.set(provider.token, instance); + if (provider.useValue != undefined || ((provider.useFactory || provider.useClass) && provider.cache !== false)) { + this.instances.set(provider.token, instance); + } if (provider.useClass) { this.instances.set(provider.useClass as unknown as DiToken, instance); diff --git a/src/di/models/di-provider.model.ts b/src/di/models/di-provider.model.ts index afbb657..261a265 100644 --- a/src/di/models/di-provider.model.ts +++ b/src/di/models/di-provider.model.ts @@ -39,6 +39,12 @@ type ClassDiProvider = BaseDiProvider & { * A class to register for the token. */ useClass: Newable, + /** + * Whether or not the newly created class instance should be cached or recreated on every injection. + * + * Defaults to true. + */ + cache?: boolean, // eslint-disable-next-line jsdoc/require-jsdoc useFactory?: never, // eslint-disable-next-line jsdoc/require-jsdoc @@ -51,6 +57,12 @@ type FactoryDiProvider = BaseDiProvider & { * A factory function that resolves the value to register for the token. */ useFactory: (...deps: unknown[]) => T, + /** + * Whether or not the result of the function should be cached or recomputed on every injection. + * + * Defaults to true. + */ + cache?: boolean, // eslint-disable-next-line jsdoc/require-jsdoc useClass?: never, // eslint-disable-next-line jsdoc/require-jsdoc diff --git a/src/entity/decorators/property.decorator.ts b/src/entity/decorators/property.decorator.ts index e5760b3..556caec 100644 --- a/src/entity/decorators/property.decorator.ts +++ b/src/entity/decorators/property.decorator.ts @@ -83,7 +83,8 @@ export namespace Property { regex: undefined, enum: undefined, default: undefined, - excludeFromChangeSets: false, + excludeFromChangeSets: typeof data?.exclude === 'boolean' ? data.exclude : false, + exclude: false, ...data }; return applyData(fullMetadata, data); @@ -103,7 +104,8 @@ export namespace Property { min: undefined, max: undefined, default: undefined, - excludeFromChangeSets: false, + excludeFromChangeSets: typeof data?.exclude === 'boolean' ? data.exclude : false, + exclude: false, enum: undefined, ...data }; @@ -120,7 +122,8 @@ export namespace Property { type: 'boolean', description: undefined, default: undefined, - excludeFromChangeSets: false, + excludeFromChangeSets: typeof data?.exclude === 'boolean' ? data.exclude : false, + exclude: false, ...data }; return applyData(fullMetadata, data); @@ -138,7 +141,8 @@ export namespace Property { after: undefined, before: undefined, default: undefined, - excludeFromChangeSets: false, + exclude: false, + excludeFromChangeSets: typeof data?.exclude === 'boolean' ? data.exclude : false, ...data }; return applyData(fullMetadata, data); @@ -153,7 +157,8 @@ export namespace Property { required: true, type: 'object', description: undefined, - excludeFromChangeSets: false, + exclude: false, + excludeFromChangeSets: typeof data?.exclude === 'boolean' ? data.exclude : false, allowAdditionalProperties: false, ...data }; @@ -178,7 +183,8 @@ export namespace Property { description: undefined, allowedMimeTypes: 'all', maxSize: '5mb', - excludeFromChangeSets: false, + exclude: false, + excludeFromChangeSets: typeof data?.exclude === 'boolean' ? data.exclude : false, ...data }; const ctor: Newable = target.constructor as Newable; @@ -201,7 +207,8 @@ export namespace Property { required: true, type: 'array', description: undefined, - excludeFromChangeSets: false, + exclude: false, + excludeFromChangeSets: typeof data?.exclude === 'boolean' ? data.exclude : false, totalMaxSize: '50mb', ...data, items: createArrayItemPropertyMetadata(data.items, `${target.constructor.name}.${key.toString()}`) @@ -225,7 +232,8 @@ export namespace Property { required: true, type: 'unknown', description: undefined, - excludeFromChangeSets: false, + exclude: false, + excludeFromChangeSets: typeof data?.exclude === 'boolean' ? data.exclude : false, ...data }; return applyData(fullMetadata, data); @@ -241,7 +249,8 @@ export namespace Property { type: Relation.MANY_TO_ONE, cascade: [], description: undefined, - excludeFromChangeSets: false, + exclude: false, + excludeFromChangeSets: typeof metadata?.exclude === 'boolean' ? metadata.exclude : false, ...metadata }; return applyData(fullMetadata as PropertyMetadata, metadata); @@ -257,7 +266,8 @@ export namespace Property { type: Relation.ONE_TO_MANY, cascade: ['remove', 'insert', 'update'], description: undefined, - excludeFromChangeSets: false, + exclude: false, + excludeFromChangeSets: typeof metadata?.exclude === 'boolean' ? metadata.exclude : false, ...metadata }; return applyData(fullMetadata as PropertyMetadata, metadata); @@ -274,7 +284,8 @@ export namespace Property { cascade: ['remove', 'insert', 'update'], joinColumn: false, description: undefined, - excludeFromChangeSets: false, + exclude: false, + excludeFromChangeSets: typeof metadata?.exclude === 'boolean' ? metadata.exclude : false, ...metadata }; return applyData(fullMetadata as PropertyMetadata, metadata); @@ -291,7 +302,8 @@ export namespace Property { cascade: [], joinColumn: true, description: undefined, - excludeFromChangeSets: false, + exclude: false, + excludeFromChangeSets: typeof metadata?.exclude === 'boolean' ? metadata.exclude : false, ...metadata }; return applyData(fullMetadata as PropertyMetadata, metadata); @@ -308,7 +320,8 @@ export namespace Property { cascade: [], description: undefined, persistence: true, - excludeFromChangeSets: false, + exclude: false, + excludeFromChangeSets: typeof metadata?.exclude === 'boolean' ? metadata.exclude : false, ...metadata }; return applyData(fullMetadata as PropertyMetadata, metadata); @@ -319,8 +332,10 @@ export namespace Property { function applyData(data: PropertyMetadata, inputData: PropertyMetadataInput | undefined): PropertyDecorator { return (target, key) => { if (inputData?.required != undefined && (inputData as WithDefaultMetadata).default != undefined) { - // eslint-disable-next-line stylistic/max-len - warn(`setting "required" on ${target.constructor.name}.${key.toString()} won't have any effect, because "default" is also set.`); + warn(`${target.constructor.name}.${key.toString()}: setting "required" won't have any effect, because "default" is also set.`); + } + if ('primary' in data && data.primary && data.exclude !== false) { + throw new Error(`${target.constructor.name}.${key.toString()}: Cannot mark a primary key with "exclude."`); } const ctor: Newable = target.constructor as Newable; // eslint-disable-next-line unicorn/error-message @@ -338,6 +353,7 @@ function applyData(data: PropertyMetadata, inputData: PropertyMetadataInput | un * @param fullPropertyKey - The full key of the property. * @returns The full metadata. */ +// eslint-disable-next-line sonar/cognitive-complexity export function createArrayItemPropertyMetadata( data: ArrayPropertyItemMetadataInput, fullPropertyKey: string @@ -352,7 +368,8 @@ export function createArrayItemPropertyMetadata( min: undefined, max: undefined, default: undefined, - excludeFromChangeSets: false, + exclude: false, + excludeFromChangeSets: typeof data?.exclude === 'boolean' ? data.exclude : false, enum: undefined, ...data }; @@ -369,7 +386,8 @@ export function createArrayItemPropertyMetadata( regex: undefined, enum: undefined, default: undefined, - excludeFromChangeSets: false, + exclude: false, + excludeFromChangeSets: typeof data?.exclude === 'boolean' ? data.exclude : false, ...data }; } @@ -377,7 +395,8 @@ export function createArrayItemPropertyMetadata( return { required: true, description: undefined, - excludeFromChangeSets: false, + exclude: false, + excludeFromChangeSets: typeof data?.exclude === 'boolean' ? data.exclude : false, ...data }; } @@ -385,7 +404,8 @@ export function createArrayItemPropertyMetadata( return { required: true, description: undefined, - excludeFromChangeSets: false, + exclude: false, + excludeFromChangeSets: typeof data?.exclude === 'boolean' ? data.exclude : false, allowAdditionalProperties: false, ...data }; @@ -395,7 +415,8 @@ export function createArrayItemPropertyMetadata( required: true, description: undefined, default: undefined, - excludeFromChangeSets: false, + exclude: false, + excludeFromChangeSets: typeof data?.exclude === 'boolean' ? data.exclude : false, ...data }; } @@ -406,7 +427,8 @@ export function createArrayItemPropertyMetadata( after: undefined, before: undefined, default: undefined, - excludeFromChangeSets: false, + exclude: false, + excludeFromChangeSets: typeof data?.exclude === 'boolean' ? data.exclude : false, ...data }; } @@ -414,7 +436,8 @@ export function createArrayItemPropertyMetadata( const metadata: ArrayPropertyMetadata = { required: true, description: undefined, - excludeFromChangeSets: false, + exclude: false, + excludeFromChangeSets: typeof data?.exclude === 'boolean' ? data.exclude : false, totalMaxSize: '50mb', ...data, items: createArrayItemPropertyMetadata(data.items, fullPropertyKey) @@ -433,7 +456,8 @@ export function createArrayItemPropertyMetadata( description: undefined, allowedMimeTypes: 'all', maxSize: '5mb', - excludeFromChangeSets: false, + exclude: false, + excludeFromChangeSets: typeof data?.exclude === 'boolean' ? data.exclude : false, ...data }; } diff --git a/src/entity/models/base-property-metadata.model.ts b/src/entity/models/base-property-metadata.model.ts index f648ecf..549a354 100644 --- a/src/entity/models/base-property-metadata.model.ts +++ b/src/entity/models/base-property-metadata.model.ts @@ -1,24 +1,53 @@ -import { BaseEntity } from '../base-entity.model'; +/** + * Value for the exclude setting on a property. + */ + +import { HttpRequestContext } from '../../context/request/http-request.context'; +import { WebsocketRequestContext } from '../../context/request/websocket-request.context'; + +/** + * Value for the exclude setting on a property. + */ +export type ExcludePropertyValue = boolean + // eslint-disable-next-line typescript/no-explicit-any + | ((data: any, ctx: HttpRequestContext | WebsocketRequestContext | undefined) => boolean | Promise); +/** + * Value for the required setting on a property. + */ +export type RequiredPropertyValue = boolean + // eslint-disable-next-line typescript/no-explicit-any + | ((data: any, ctx: HttpRequestContext | WebsocketRequestContext | undefined) => boolean | Promise); /** * Metadata shared by all properties. */ -// eslint-disable-next-line typescript/no-explicit-any -export type BasePropertyMetadata = { +export type BasePropertyMetadata = { /** * Whether or not the property is required. */ - required: boolean | ((data: T) => boolean), + required: RequiredPropertyValue, /** * A description of the property. */ description: string | undefined, + /** + * Whether this property should be excluded from external-facing outputs. + */ + exclude: ExcludePropertyValue, /** * Whether or not this property should be excluded when generating change sets. */ excludeFromChangeSets: boolean }; +/** + * Value for the default setting on a property. + */ +export type DefaultPropertyValue = T + | undefined + // eslint-disable-next-line typescript/no-explicit-any + | ((createData: any, ctx: HttpRequestContext | WebsocketRequestContext | undefined) => T | Promise); + /** * Adds a default property which can be used to fill empty properties with default values. */ @@ -26,6 +55,5 @@ export type WithDefaultMetadata = { /** * The default value to set an empty property to when defined. */ - // eslint-disable-next-line typescript/no-explicit-any - default: T | ((createData: any) => T | Promise) | undefined + default: DefaultPropertyValue }; \ No newline at end of file diff --git a/src/entity/models/file-property-metadata.model.ts b/src/entity/models/file-property-metadata.model.ts index 5ef64a3..01a2891 100644 --- a/src/entity/models/file-property-metadata.model.ts +++ b/src/entity/models/file-property-metadata.model.ts @@ -1,7 +1,7 @@ import { BasePropertyMetadata } from './base-property-metadata.model'; import { MimeType } from '../../http/mime-type.enum'; import { OmitStrict } from '../../types/omit-strict.type'; -import { BigNumberUtilities } from '../../utilities/big-number.utilities'; +import { BigNumber, BigNumberUtilities } from '../../utilities/big-number.utilities'; /** * Possible file size values. diff --git a/src/error-handling/errors/validation.error.ts b/src/error-handling/errors/validation.error.ts index fd0abd6..d39c45e 100644 --- a/src/error-handling/errors/validation.error.ts +++ b/src/error-handling/errors/validation.error.ts @@ -16,8 +16,9 @@ const startMessage: Record = { * An error with validation. */ export class ValidationError extends BadRequestError { - constructor(type: ValidationErrorType, problems: ValidationProblem[], options?: ErrorOptions) { - const paragraphs: string[] = [startMessage[type]]; + constructor(readonly type: ValidationErrorType, paramName: string | undefined, problems: ValidationProblem[], options?: ErrorOptions) { + const paramNameSuffix: string = paramName ? ` "${paramName}"` : ''; + const paragraphs: string[] = [`${startMessage[type]}${paramNameSuffix}`]; for (const problem of problems) { paragraphs.push(`- ${problem.key}: ${problem.message}`); } diff --git a/src/event/event-cleanup.cron-job.ts b/src/event/event-cleanup.cron-job.ts new file mode 100644 index 0000000..fcba9ed --- /dev/null +++ b/src/event/event-cleanup.cron-job.ts @@ -0,0 +1,42 @@ + +import { Event, EventStatus } from './event.model'; +import { CronJob, InitialCronConfig } from '../cron/cron-job.model'; +import { Repository } from '../data-source/repository'; +import { InjectRepository } from '../di/decorators/inject-repository.decorator'; + +/** + * CronJob that cleans up past events. + */ +export class EventCleanupCronJob extends CronJob { + // eslint-disable-next-line jsdoc/require-jsdoc + initialConfig: InitialCronConfig = { + name: 'Event Cleanup', + cron: '0 0 * * *', + runOnInit: false + }; + + constructor( + @InjectRepository(Event) + private readonly repository: Repository> + ) { + super(); + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async onTick(): Promise { + await this.logger.info('cleans up past events'); + + try { + // const yesterday: Date = new Date(Date.now() - Ms.DAY); + const events: Event[] = (await this.repository.findAll({ + where: { status: EventStatus.FINISHED }, + relations: ['eventSubscriberRuns'] + })).filter(e => Date.now() > new Date(e.cleanupAt).getTime()); + const res: Event[] = await this.repository.deleteAll({ id: { oneOf: events.map(e => e.id) } }); + await this.logger.info(`removed ${res.length} events`); + } + catch { + // Do nothing + } + } +} \ No newline at end of file diff --git a/src/event/event-processing.error.ts b/src/event/event-processing.error.ts new file mode 100644 index 0000000..8c29412 --- /dev/null +++ b/src/event/event-processing.error.ts @@ -0,0 +1,15 @@ +import { Event } from './event.model'; + +/** + * An error that gets logged when the processing of an event failed. + */ +export class EventProcessingError extends Error { + constructor(event: Event, subscriberId: string, cause: Error) { + const message: string = [ + `Error processing event "${event.type}" for subscriber "${subscriberId}" with data:`, + JSON.stringify(event.data, undefined, 2) + ].join('\n'); + super(message, { cause }); + this.name = 'EventProcessingError'; + } +} \ No newline at end of file diff --git a/src/event/event-service.interface.ts b/src/event/event-service.interface.ts new file mode 100644 index 0000000..e4099c8 --- /dev/null +++ b/src/event/event-service.interface.ts @@ -0,0 +1,62 @@ +import { Event } from './event.model'; + +/** + * Options for subscribing to events. + */ +export type EventSubscribeOptions = { + /** + * The id of the subscriber that wants to subscribe. + * + * Needs to be unique and should stay consistent to survive power cycles. + */ + subscriberId: string, + /** + * The amount of attempts. Defaults to 1. + */ + attempts?: number, + /** + * The timeout in which the hook should finish. Defaults to 30 seconds. + */ + timeout?: number +}; + +/** + * The result of subscribing to an event. + */ +export type EventSubscriptionInterface = { + /** + * Unsubscribes from the event. + */ + unsubscribe: () => void +}; + +/** + * Interface for an event service. + */ +export interface EventServiceInterface> { + /** + * Emits an event of the given type and data. + */ + emit: (type: K, data: TEvents[K], cleanupAfterMs?: number) => void | Promise, + /** + * Subscribes to events of the given type with the given hook. + * + * Additional options can be provided, like eg. Retries or the id of the subscriber. + * This needs to be unique. + */ + subscribe: ( + type: K, + hook: (value: Event) => void | Promise, + options: EventSubscribeOptions + ) => EventSubscriptionInterface | Promise, + /** + * Subscribes to ALL events with the given hook. + * + * Additional options can be provided, like eg. Retries or the id of the subscriber. + * This needs to be unique. + */ + subscribeAll: ( + hook: (value: Event) => void | Promise, + options: EventSubscribeOptions + ) => EventSubscriptionInterface | Promise +} \ No newline at end of file diff --git a/src/event/event-subscriber-run.model.ts b/src/event/event-subscriber-run.model.ts new file mode 100644 index 0000000..b59cd8a --- /dev/null +++ b/src/event/event-subscriber-run.model.ts @@ -0,0 +1,43 @@ +import { Event } from './event.model'; +import { BaseEntity } from '../entity/base-entity.model'; +import { Entity } from '../entity/decorators/entity.decorator'; +import { Property } from '../entity/decorators/property.decorator'; +import { OmitClass } from '../entity/omit-class.model'; + +/** + * Data of a event subscriber run. + */ +@Entity({ allowOrphan: true }) +export class EventSubscriberRun extends BaseEntity { + /** + * The timestamp at which the event run has been created. + */ + @Property.date({ default: () => new Date() }) + createdAt!: Date; + /** + * The event that triggered this run. + */ + @Property.manyToOne({ target: () => Event, inverseSide: 'eventSubscriberRuns' }) + event!: Event; + /** + * The id of the subscriber. + */ + @Property.string() + subscriberId!: string; + /** + * The error property if the run failed. + */ + @Property.unknown({ required: false }) + error?: Error; +} + +/** + * The data needed to create a new event subscriber run. + */ +export class EventSubscriberRunCreateData extends OmitClass(EventSubscriberRun, ['id', 'event', 'createdAt']) { + /** + * The event that triggered this run. + */ + @Property.manyToOne({ target: () => Event, inverseSide: 'eventSubscriberRuns' }) + event!: Event; +} \ No newline at end of file diff --git a/src/event/event.model.ts b/src/event/event.model.ts new file mode 100644 index 0000000..bed8e60 --- /dev/null +++ b/src/event/event.model.ts @@ -0,0 +1,66 @@ +import { EventSubscriberRun } from './event-subscriber-run.model'; +import { BaseEntity } from '../entity/base-entity.model'; +import { Entity } from '../entity/decorators/entity.decorator'; +import { Property } from '../entity/decorators/property.decorator'; +import { OmitClass } from '../entity/omit-class.model'; + +/** + * The status a event can have. + */ +export enum EventStatus { + CREATED = 'CREATED', + FINISHED = 'FINISHED' +} + +/** + * Definition for an event. + */ +@Entity({ allowOrphan: true }) +export class Event extends BaseEntity { + /** + * The timestamp at which the event has been created. + */ + @Property.date({ default: () => new Date() }) + createdAt!: Date; + /** + * The timestamp after which the event can be cleaned up. + */ + @Property.date() + cleanupAt!: Date; + /** + * The type of the event. + */ + @Property.string() + type!: string; + /** + * The data of the event. + */ + @Property.unknown() + data!: T; + /** + * The status of the event. + */ + @Property.string({ enum: EventStatus, default: EventStatus.CREATED }) + status!: EventStatus; + /** + * All ids of eg. Classes that are subscribed to this event. + */ + @Property.array({ items: { type: 'string' } }) + subscriberIds!: string[]; + /** + * All runs of subscribers that have already happened. + */ + @Property.oneToMany({ target: () => EventSubscriberRun, inverseSide: 'event' }) + eventSubscriberRuns!: EventSubscriberRun[]; +} + +/** + * The data needed to create a new event. + */ +export class EventCreateData extends OmitClass(Event, ['id', 'data', 'createdAt', 'eventSubscriberRuns', 'status']) { + /** + * The data of the event. + */ + @Property.unknown() + data!: T; +} \ No newline at end of file diff --git a/src/event/event.service.ts b/src/event/event.service.ts new file mode 100644 index 0000000..7e43dde --- /dev/null +++ b/src/event/event.service.ts @@ -0,0 +1,249 @@ +import { filter, Subject, Subscription } from 'rxjs'; + +import { EventServiceInterface, EventSubscribeOptions, EventSubscriptionInterface } from './event-service.interface'; +import { EventSubscriberRun, EventSubscriberRunCreateData } from './event-subscriber-run.model'; +import { Event, EventCreateData, EventStatus } from './event.model'; +import { ZibriApplication } from '../application'; +import { EventCleanupCronJob } from './event-cleanup.cron-job'; +import { EventProcessingError } from './event-processing.error'; +import { Repository } from '../data-source/repository'; +import { InjectRepository } from '../di/decorators/inject-repository.decorator'; +import { Inject } from '../di/decorators/inject.decorator'; +import { Injectable } from '../di/decorators/injectable.decorator'; +import { ZIBRI_DI_TOKENS } from '../di/default/zibri-di-tokens.default'; +import { AfterAppShutdown } from '../global/after-app-shutdown.interface'; +import { OnAppInit } from '../global/on-app-init.interface'; +import { OnAppStart } from '../global/on-app-start.interface'; +import { type LoggerInterface } from '../logging/logger.interface'; +import { Ms } from '../utilities/ms'; +import { ObjectUtilities } from '../utilities/object.utilities'; +import { PromiseUtilities } from '../utilities/promise.utilities'; +import { validateEntitiesRegistered } from '../utilities/validate-entities-registered.function'; + +/** + * The result for subscribing to an event. + */ +export class EventSubscription implements EventSubscriptionInterface { + constructor( + readonly id: string, + readonly rxSub: Subscription, + readonly unsubscribe: () => void + ) {} +} + +/** + * Default implementation of the event service. + */ +@Injectable({ register: 'onUse' }) +export class EventService> +implements EventServiceInterface, OnAppInit, OnAppStart, AfterAppShutdown { + /** + * The rxjs subject of the event. + */ + protected readonly eventSubject: Subject> = new Subject(); + /** + * The subscribers for each event type. + */ + protected readonly subscriptionForEvent: Record< + keyof TEvents, + Set | undefined + > = {} as Record | undefined>; + /** + * The subscribers that listen to all events. + */ + protected readonly subscriptionsForAll: Set = new Set(); + + constructor( + @InjectRepository(Event) + protected readonly eventRepository: Repository, EventCreateData>, + @InjectRepository(EventSubscriberRun) + protected readonly eventSubscriberRunRepository: Repository< + EventSubscriberRun, + EventSubscriberRunCreateData + >, + @Inject(ZIBRI_DI_TOKENS.LOGGER) + protected readonly logger: LoggerInterface + ) { } + + // eslint-disable-next-line jsdoc/require-jsdoc + onAppInit(app: ZibriApplication): void { + validateEntitiesRegistered('EventService', app, Event, EventSubscriberRun); + app.options.cronJobs.push(EventCleanupCronJob); + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async onAppStart(): Promise { + const events: Event[] = await this.eventRepository.findAll({ + where: { status: { not: EventStatus.FINISHED } }, + relations: ['eventSubscriberRuns'], + order: { createdAt: 'ASC' } + }); + + for (const event of events) { + if (await this.eventHasUnfinishedSubscriptions(event)) { + this.eventSubject.next(event); + continue; + } + + await this.eventRepository.updateById(event.id, { status: EventStatus.FINISHED }); + } + } + + // eslint-disable-next-line jsdoc/require-jsdoc + afterAppShutdown(): void { + // We unsubscribe on the internal rxjs subscription here + // => emit calls happening now will create the correct events + for (const subscriber of this.subscriptionsForAll) { + subscriber.rxSub.unsubscribe(); + } + for (const subscriber of ObjectUtilities.values(this.subscriptionForEvent).flatMap(s => [...s ?? []])) { + subscriber.rxSub.unsubscribe(); + } + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async emit(type: K, data: TEvents[K], cleanupAfterMs: number = Ms.DAY): Promise { + const event: Event = await this.eventRepository.create({ + type: String(type), + subscriberIds: [...this.subscriptionForEvent[type] ?? [], ...this.subscriptionsForAll].map(d => d.id), + cleanupAt: new Date(Date.now() + cleanupAfterMs), + data + }); + this.eventSubject.next({ ...event, eventSubscriberRuns: [] }); + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async subscribe( + type: K, + hook: (value: Event, signal: AbortSignal) => void | Promise, + options: EventSubscribeOptions + ): Promise { + const existingAllEventSubscription: EventSubscription | undefined = this.findSubscriptionById(options.subscriberId); + if (existingAllEventSubscription) { + throw new Error(`Can't subscribe to event: The subscriberId ${options.subscriberId} is already subscribed to all events`); + } + const existingEventSubscription: EventSubscription | undefined = this.findSubscriptionForEventById(type, options.subscriberId); + if (existingEventSubscription) { + await this.logger.warn( + `The subscriberId ${options.subscriberId} is already subscribed to this event, returning the existing subscription` + ); + return existingEventSubscription; + } + + const rxSub: Subscription = this.eventSubject + .pipe( + filter(e => e.type === type && !e.eventSubscriberRuns.some(r => r.subscriberId === options.subscriberId)) + ) + .subscribe(e => void this.runHook(hook, e as Event, options)); + const subscriber: EventSubscription = new EventSubscription( + options.subscriberId, + rxSub, + () => { + this.subscriptionForEvent[type]?.delete(subscriber); + rxSub.unsubscribe(); + } + ); + + this.subscriptionForEvent[type] ??= new Set(); + this.subscriptionForEvent[type].add(subscriber); + return subscriber; + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async subscribeAll( + hook: (value: Event, signal: AbortSignal) => void | Promise, + options: EventSubscribeOptions + ): Promise { + const existingEventSubscriptionTypes: string[] = []; + for (const type of ObjectUtilities.keys(this.subscriptionForEvent)) { + if (this.findSubscriptionForEventById(type, options.subscriberId)) { + existingEventSubscriptionTypes.push(type); + } + } + if (existingEventSubscriptionTypes.length) { + throw new Error( + [ + `Can't subscribe to all events: The subscriberId ${options.subscriberId} is already subscribed to the events:`, + ...existingEventSubscriptionTypes.map(t => ` - ${t}`) + ].join('\n') + ); + } + + const existingAllEventSubscription: EventSubscription | undefined = this.findSubscriptionById(options.subscriberId); + if (existingAllEventSubscription) { + await this.logger.warn( + `The subscriberId ${options.subscriberId} is already subscribed to all events, returning the existing subscription` + ); + return existingAllEventSubscription; + } + + const rxSub: Subscription = this.eventSubject + .pipe(filter(e => !e.eventSubscriberRuns.some(r => r.subscriberId === options.subscriberId))) + .subscribe(e => void this.runHook(hook, e, options)); + const subscription: EventSubscription = new EventSubscription( + options.subscriberId, + rxSub, + () => { + this.subscriptionsForAll.delete(subscription); + rxSub.unsubscribe(); + } + ); + this.subscriptionsForAll.add(subscription); + return subscription; + } + + /** + * Runs the given hook with the given event and options. + * @param hook - The hook to run. + * @param event - The event to run the hook on. + * @param options - Additional options like the subscriberId, attempts etc. + */ + protected async runHook( + hook: (value: Event, signal: AbortSignal) => void | Promise, + event: Event, + options: EventSubscribeOptions + ): Promise { + let error: Error | undefined = undefined; + for (let i: number = 0; i < (options.attempts ?? 1); i++) { + try { + await PromiseUtilities.withTimeout((signal) => hook(event, signal), options.timeout ?? Ms.SECOND * 30); + error = undefined; + break; + } + catch (_error) { + error = _error instanceof Error ? _error : new Error(JSON.stringify(_error)); + } + } + + if (error) { + await this.logger.error(new EventProcessingError(event, options.subscriberId, error)); + } + + await this.eventSubscriberRunRepository.create({ event, subscriberId: options.subscriberId, error }); + if (await this.eventHasUnfinishedSubscriptions(event)) { + return; + } + await this.eventRepository.updateById(event.id, { status: EventStatus.FINISHED }); + } + + private async eventHasUnfinishedSubscriptions(event: Event): Promise { + const runs: EventSubscriberRun[] = await this.eventSubscriberRunRepository.findAll({ + where: { + event: { where: { id: event.id } } + } + }); + const ranSubscriberIds: string[] = runs.map(r => r.subscriberId); + const allSubscriberIds: string[] = [...this.subscriptionForEvent[event.type] ?? [], ...this.subscriptionsForAll].map(d => d.id); + return event.subscriberIds.some(id => allSubscriberIds.includes(id) && !ranSubscriberIds.includes(id)); + } + + private findSubscriptionForEventById(event: keyof TEvents, subscriberId: string): EventSubscription | undefined { + const subscribers: EventSubscription[] = [...this.subscriptionForEvent[event] ?? []]; + return subscribers.find(s => s.id === subscriberId); + } + + private findSubscriptionById(subscriberId: string): EventSubscription | undefined { + const subscribers: EventSubscription[] = [...this.subscriptionsForAll]; + return subscribers.find(s => s.id === subscriberId); + } +} \ No newline at end of file diff --git a/src/global/model-registry/default-descriptor.ts b/src/global/model-registry/default-descriptor.ts new file mode 100644 index 0000000..863f37a --- /dev/null +++ b/src/global/model-registry/default-descriptor.ts @@ -0,0 +1,104 @@ +import { ModelRegistry } from './model.registry'; +import { PropertyMetadata } from '../../entity/decorators/property.decorator'; +import { ArrayPropertyItemMetadata } from '../../entity/models/array-property-metadata.model'; +import { DefaultPropertyValue } from '../../entity/models/base-property-metadata.model'; +import { Relation } from '../../entity/models/relation.enum'; +import { Newable } from '../../types/newable.type'; +import { MetadataUtilities } from '../../utilities/metadata.utilities'; + +// eslint-disable-next-line jsdoc/require-jsdoc +type DefaultValue = DefaultPropertyValue; + +/** + * A descriptor that keeps track of the default properties of an entity. + */ +export class DefaultDescriptor { + /** + * Keys that have a default value — static or function. + */ + readonly keys: Map = new Map(); + /** + * Keys with no default but containing nested entities that might have defaults. + */ + readonly nestedKeys: Map = new Map(); + + /** + * Updates this descriptor by the properties of the given class. + * @param entityClass - The class to get the default properties from. + */ + update(entityClass: Newable): void { + this.keys.clear(); + this.nestedKeys.clear(); + + const properties: Record = MetadataUtilities.getModelProperties(entityClass); + + for (const [key, metadata] of Object.entries(properties)) { + if ('default' in metadata && metadata.default != undefined) { + this.keys.set(key, metadata.default); + continue; + } + + switch (metadata.type) { + case Relation.MANY_TO_MANY: + case Relation.ONE_TO_MANY: + case Relation.MANY_TO_ONE: + case Relation.ONE_TO_ONE: { + const nested: DefaultDescriptor = ModelRegistry.get(metadata.target()).defaultDescriptor; + if (nested.hasAnything()) { + this.nestedKeys.set(key, nested); + } + break; + } + case 'object': { + const nested: DefaultDescriptor = ModelRegistry.get(metadata.cls()).defaultDescriptor; + if (nested.hasAnything()) { + this.nestedKeys.set(key, nested); + } + break; + } + case 'array': { + const nested: DefaultDescriptor | undefined = this.buildForArrayItems(metadata.items); + if (nested?.hasAnything() === true) { + this.nestedKeys.set(key, nested); + } + break; + } + case 'string': + case 'number': + case 'boolean': + case 'date': + case 'file': + case 'unknown': { + break; + } + } + } + } + + // eslint-disable-next-line jsdoc/require-returns + /** + * Whether or not there are even properties somewhere that have a default property. + */ + hasAnything(): boolean { + return this.keys.size > 0 || this.nestedKeys.size > 0; + } + + private buildForArrayItems(items: ArrayPropertyItemMetadata): DefaultDescriptor | undefined { + switch (items.type) { + case 'object': { + return ModelRegistry.get(items.cls()).defaultDescriptor; + } + case 'array': { + return this.buildForArrayItems(items.items); + } + case 'string': + case 'number': + case 'boolean': + case 'date': + case 'file': + case 'unknown': { + return undefined; + } + } + } +} \ No newline at end of file diff --git a/src/global/model-registry/exclude-descriptor.ts b/src/global/model-registry/exclude-descriptor.ts new file mode 100644 index 0000000..766b5f4 --- /dev/null +++ b/src/global/model-registry/exclude-descriptor.ts @@ -0,0 +1,106 @@ +import { ModelRegistry } from './model.registry'; +import { PropertyMetadata } from '../../entity/decorators/property.decorator'; +import { ArrayPropertyItemMetadata } from '../../entity/models/array-property-metadata.model'; +import { ExcludePropertyValue } from '../../entity/models/base-property-metadata.model'; +import { Relation } from '../../entity/models/relation.enum'; +import { Newable } from '../../types/newable.type'; +import { MetadataUtilities } from '../../utilities/metadata.utilities'; + +/** + * A descriptor that keeps track of the exclude properties of an entity. + */ +export class ExcludeDescriptor { + /** + * Keys that have a exclude value of true or function. + */ + readonly keys: Map = new Map(); + /** + * Keys with no exclude option but containing nested entities that might have exclude properties. + */ + readonly nestedKeys: Map> = new Map(); + + /** + * Updates this descriptor by the properties of the given class. + * @param entityClass - The class to get the exclude properties from. + */ + update(entityClass: Newable): void { + this.keys.clear(); + this.nestedKeys.clear(); + + const properties: Record = MetadataUtilities.getModelProperties(entityClass); + + for (const [key, metadata] of Object.entries(properties)) { + if (metadata.exclude !== undefined && metadata.exclude !== false) { + this.keys.set(key, metadata.exclude); + continue; + } + + switch (metadata.type) { + case Relation.MANY_TO_ONE: + case Relation.ONE_TO_ONE: + case Relation.MANY_TO_MANY: + case Relation.ONE_TO_MANY: { + const nested: ExcludeDescriptor = ModelRegistry.get(metadata.target()).excludeDescriptor; + if (nested.hasAnything()) { + this.nestedKeys.set(key, nested); + } + break; + } + + case 'object': { + const nested: ExcludeDescriptor = ModelRegistry.get(metadata.cls()).excludeDescriptor; + if (nested.hasAnything()) { + this.nestedKeys.set(key, nested); + } + break; + } + + case 'array': { + const nested: ExcludeDescriptor | undefined = this.buildExcludeDescriptorForArrayItems(metadata.items); + if (nested?.hasAnything() === true) { + this.nestedKeys.set(key, nested); + } + break; + } + + case 'string': + case 'number': + case 'boolean': + case 'date': + case 'file': + case 'unknown': { + break; + } + } + } + } + + // eslint-disable-next-line jsdoc/require-returns + /** + * Whether or not there are even properties somewhere that have a exclude property. + */ + hasAnything(): boolean { + return this.keys.size > 0 || this.nestedKeys.size > 0; + } + + private buildExcludeDescriptorForArrayItems( + items: ArrayPropertyItemMetadata + ): ExcludeDescriptor | undefined { + switch (items.type) { + case 'object': { + return ModelRegistry.get(items.cls()).excludeDescriptor; + } + case 'array': { + return this.buildExcludeDescriptorForArrayItems(items.items); + } + case 'string': + case 'number': + case 'boolean': + case 'date': + case 'file': + case 'unknown': { + return undefined; + } + } + } +} \ No newline at end of file diff --git a/src/global/model-registry/model.registry.ts b/src/global/model-registry/model.registry.ts new file mode 100644 index 0000000..bbd236d --- /dev/null +++ b/src/global/model-registry/model.registry.ts @@ -0,0 +1,65 @@ +import { DefaultDescriptor } from './default-descriptor'; +import { ExcludeDescriptor } from './exclude-descriptor'; +import { Newable } from '../../types/newable.type'; + +/** + * Data stored per entity class in the registry. + */ +export type ModelRegistryData = { + /** + * Pre-built exclude descriptor for this class. + */ + readonly excludeDescriptor: ExcludeDescriptor, + /** + * Pre-built default descriptor for this class. + */ + readonly defaultDescriptor: DefaultDescriptor +}; + +/** + * Centralized registry for per-class model data. + * Acts as the single source of truth for model properties and derived descriptors. + * Keeps all data consistent when properties are updated via setModelProperties. + */ +export abstract class ModelRegistry { + /** + * Stores fully resolved (inheritance-merged) model data per class. + * Placeholder instances are mutated in place to keep cross-references valid. + */ + private static readonly cache: Map, ModelRegistryData> = new Map(); + + /** + * Returns the cached model data for the given class, building it if necessary. + * @param entityClass - The entity class to get model data for. + * @returns The model data for the given class. + */ + static get(entityClass: Newable): ModelRegistryData { + return this.cache.get(entityClass) ?? this.buildInPlace(entityClass); + } + + /** + * Builds or rebuilds the model data for the given class in place. + * Reuses the existing placeholder if present so that any nestedKey references + * held by other descriptors remain valid after a rebuild. + * @param entityClass - The class to build data for. + * @returns The model data for the given class. + */ + private static buildInPlace(entityClass: Newable): ModelRegistryData { + // Reuse existing placeholder if present — keeps external nestedKey references valid + let modelRegistryData: ModelRegistryData | undefined = this.cache.get(entityClass); + if (!modelRegistryData) { + modelRegistryData = { + excludeDescriptor: new ExcludeDescriptor(), + defaultDescriptor: new DefaultDescriptor() + }; + this.cache.set(entityClass, modelRegistryData); + } + + // Resolve inherited properties first, so descriptors see the full picture + // modelRegistryData.properties = ; + modelRegistryData.excludeDescriptor.update(entityClass); + modelRegistryData.defaultDescriptor.update(entityClass); + + return modelRegistryData; + } +} \ No newline at end of file diff --git a/src/global/model-registry/remove-exclude-properties.function.ts b/src/global/model-registry/remove-exclude-properties.function.ts new file mode 100644 index 0000000..5a3c655 --- /dev/null +++ b/src/global/model-registry/remove-exclude-properties.function.ts @@ -0,0 +1,76 @@ +import { ExcludeDescriptor } from './exclude-descriptor'; +import { ModelRegistry } from './model.registry'; +import { AlsUtilities } from '../../context/als.utilities'; +import { AnyObject } from '../../entity/any-object.model'; +import { Newable } from '../../types/newable.type'; +import { MetadataInjectionKeys } from '../../utilities/metadata-injection-keys.enum'; + +/** + * Replaces all properties defined with "exclude: true" (or a function returning true) + * with non-enumerable getters, based on a pre-built ExcludeDescriptor. + * @param data - The entity instance to process. + * @param entityClass - The entity class to get the exclude properties from. + */ +export async function removeExcludeProperties( + data: Data, + entityClass: Newable +): Promise { + if (data == undefined || typeof data !== 'object') { + return; + } + + const descriptor: ExcludeDescriptor = ModelRegistry.get(entityClass).excludeDescriptor; + await removeExcludePropertiesRecursive(data, descriptor); +} + +// eslint-disable-next-line jsdoc/require-jsdoc +async function removeExcludePropertiesRecursive( + data: Data, + descriptor: ExcludeDescriptor +): Promise { + for (const [key, excludeValue] of descriptor.keys) { + const shouldExclude: boolean = typeof excludeValue === 'function' + ? await excludeValue(data, AlsUtilities.getCurrentRequestContext()) + : excludeValue; + if (shouldExclude) { + hideProperty(data as AnyObject, key); + } + } + + for (const [key, nested] of descriptor.nestedKeys) { + const value: unknown = (data as AnyObject)[key]; + if (value == undefined) { + continue; + } + if (Array.isArray(value)) { + for (const item of value) { + await removeExcludePropertiesRecursive(item, nested); + } + } + else { + await removeExcludePropertiesRecursive(value, nested); + } + } +} + +// eslint-disable-next-line jsdoc/require-jsdoc +function hideProperty(data: AnyObject, key: string): void { + const currentDescriptor: PropertyDescriptor | undefined = Object.getOwnPropertyDescriptor(data, key); + if (currentDescriptor && currentDescriptor.enumerable !== true) { + return; + } + const value: unknown = data[key]; + // eslint-disable-next-line typescript/no-dynamic-delete + delete data[key]; + Object.defineProperty(data, key, { + enumerable: false, + configurable: true, + get(): unknown { + return Reflect.getMetadata(MetadataInjectionKeys.EXCLUDED_PROPERTY_VALUE, data, key); + }, + set(v: unknown) { + Reflect.defineMetadata(MetadataInjectionKeys.EXCLUDED_PROPERTY_VALUE, v, data, key); + } + }); + data[key] = value; +} \ No newline at end of file diff --git a/src/global/model-registry/restore-exclude-properties.function.ts b/src/global/model-registry/restore-exclude-properties.function.ts new file mode 100644 index 0000000..a9106d2 --- /dev/null +++ b/src/global/model-registry/restore-exclude-properties.function.ts @@ -0,0 +1,54 @@ +import { ExcludeDescriptor } from './exclude-descriptor'; +import { ModelRegistry } from './model.registry'; +import { AnyObject } from '../../entity/any-object.model'; +import { Newable } from '../../types/newable.type'; + +/** + * Restores all excluded properties back to enumerable own properties before persisting. + * @param data - The data to restore properties on. + * @param entityClass - The entity class to get the exclude properties from. + */ +export function restoreExcludeProperties( + data: Data, + entityClass: Newable +): void { + if (data == undefined || typeof data !== 'object') { + return; + } + const descriptor: ExcludeDescriptor = ModelRegistry.get(entityClass).excludeDescriptor; + restoreExcludePropertiesRecursive(data, descriptor); +} + +// eslint-disable-next-line jsdoc/require-jsdoc +function restoreExcludePropertiesRecursive( + data: Data, + descriptor: ExcludeDescriptor +): void { + for (const [key] of descriptor.keys) { + const currentDescriptor: PropertyDescriptor | undefined = Object.getOwnPropertyDescriptor(data, key); + if (currentDescriptor && currentDescriptor.enumerable !== true && currentDescriptor.get) { + const value: unknown = (data as AnyObject)[key]; // triggers the getter + Object.defineProperty(data, key, { + value, + enumerable: true, + writable: true, + configurable: true + }); + } + } + + for (const [key, nested] of descriptor.nestedKeys) { + const value: unknown = (data as AnyObject)[key]; + if (value == undefined) { + continue; + } + if (Array.isArray(value)) { + for (const item of value) { + restoreExcludePropertiesRecursive(item, nested); + } + } + else { + restoreExcludePropertiesRecursive(value, nested); + } + } +} \ No newline at end of file diff --git a/src/global/model-registry/set-default-values.function.ts b/src/global/model-registry/set-default-values.function.ts new file mode 100644 index 0000000..ee32e95 --- /dev/null +++ b/src/global/model-registry/set-default-values.function.ts @@ -0,0 +1,56 @@ +import { DefaultDescriptor } from './default-descriptor'; +import { ModelRegistry } from './model.registry'; +import { AlsUtilities } from '../../context/als.utilities'; +import { HttpRequestContext } from '../../context/request/http-request.context'; +import { WebsocketRequestContext } from '../../context/request/websocket-request.context'; +import { AnyObject } from '../../entity/any-object.model'; +import { Newable } from '../../types/newable.type'; + +/** + * Sets the default value resolved from the given entityClass on the given data. + * @param data - The data to set the default values on. + * @param entityClass - The entity class to get the default properties from. + */ +export async function setDefaultValues( + data: Data, + entityClass: Newable +): Promise { + const context: HttpRequestContext | WebsocketRequestContext | undefined = AlsUtilities.getCurrentRequestContext(); + const descriptor: DefaultDescriptor = ModelRegistry.get(entityClass).defaultDescriptor; + await setDefaultValuesRecursive(context, data, descriptor); +} + +// eslint-disable-next-line jsdoc/require-jsdoc +async function setDefaultValuesRecursive( + context: HttpRequestContext | WebsocketRequestContext | undefined, + data: Data, + descriptor: DefaultDescriptor +): Promise { + if (data == undefined || typeof data !== 'object') { + return; + } + + for (const [key, defaultValue] of descriptor.keys) { + if ((data as AnyObject)[key] !== undefined) { + continue; + } + (data as AnyObject)[key] = typeof defaultValue === 'function' + ? await defaultValue(data, context) + : defaultValue; + } + + for (const [key, nested] of descriptor.nestedKeys) { + const value: unknown = (data as AnyObject)[key]; + if (value == undefined) { + continue; + } + if (Array.isArray(value)) { + for (const item of value) { + await setDefaultValuesRecursive(context, item, nested); + } + } + else { + await setDefaultValuesRecursive(context, value, nested); + } + } +} \ No newline at end of file diff --git a/src/http-client/http-client.interface.ts b/src/http-client/http-client.interface.ts index 8c15ebe..dbe1d71 100644 --- a/src/http-client/http-client.interface.ts +++ b/src/http-client/http-client.interface.ts @@ -37,9 +37,10 @@ type HttpOptions< */ timeoutMs: number, /** - * The amount of times that the request should be retried before finally failing. + * The amount of times that the request should be attempted before finally failing. + * Defaults to 1. */ - retries: number, + attempts: number, /** * Definition on how the response body should look like. Can either be a class that defines the structure of the body or a full body metadata definition. * diff --git a/src/http-client/http-client.ts b/src/http-client/http-client.ts index ad5e8d9..82c8e3f 100644 --- a/src/http-client/http-client.ts +++ b/src/http-client/http-client.ts @@ -12,11 +12,12 @@ import { KnownHeader } from '../http/known-header.enum'; import { MimeType } from '../http/mime-type.enum'; import { type ParserInterface } from '../parsing/parser.interface'; import { BodyMetadata, resolveMaxBodySize } from '../routing/decorators/body.decorator'; -import { HeaderParamMetadataInput, HeaderParamMetadata } from '../routing/decorators/param.decorator'; +import { HeaderParamMetadata, HeaderParamMetadataInput } from '../routing/decorators/param.decorator'; import { createHeaderParamMetadata } from '../routing/param-metdata.helpers'; import { HeaderMetaObjectToParamsObject, HeaderMetaInputObjectToMetaObject } from '../routing/route-configuration.model'; import { Newable } from '../types/newable.type'; import { Ms } from '../utilities/ms'; +import { ObjectUtilities } from '../utilities/object.utilities'; import { type ValidationServiceInterface } from '../validation/validation-service.interface'; const responseTypeForMimeType: Record = { @@ -201,7 +202,7 @@ export class HttpClient implements HttpClientInterface { let axiosResponse: AxiosResponse | undefined = undefined; let error: unknown = undefined; - for (let i: number = 0; i < (options?.retries ?? 1); i++) { + for (let i: number = 0; i < (options?.attempts ?? 1); i++) { if (axiosResponse != undefined) { continue; } @@ -234,8 +235,8 @@ export class HttpClient implements HttpClientInterface { } } } - catch (error_) { - error = error_; + catch (_error) { + error = _error; } } @@ -308,31 +309,34 @@ export class HttpClient implements HttpClientInterface { } try { - this.validationService.validateBody(responseBody, metadata); + await this.validationService.validateBody(responseBody, metadata); } catch (error) { throw new Error('Could not validate response body', { cause: error }); } - for (const key in options.responseHeaders) { - const headerMetadata: HeaderParamMetadata = createHeaderParamMetadata(key, options.responseHeaders[key]); - try { - (res.headers[key] as unknown) = this.parser.parseHeaderParam( - res as unknown as HttpClientResponse, - headerMetadata - ); - } - catch (error) { - throw new Error(`Could not parse response header "${headerMetadata.name}"`, { cause: error }); - } + await Promise.all( + ObjectUtilities.keys(options.responseHeaders ?? {}).map(async (key) => { + // eslint-disable-next-line typescript/no-non-null-assertion + const headerMetadata: HeaderParamMetadata = createHeaderParamMetadata(key, options.responseHeaders![key]); + try { + (res.headers[key] as unknown) = this.parser.parseHeaderParam( + res as unknown as HttpClientResponse, + headerMetadata + ); + } + catch (error) { + throw new Error(`Could not parse response header "${headerMetadata.name}"`, { cause: error }); + } - try { - this.validationService.validateHeaderParam(res.headers[key], headerMetadata); - } - catch (error) { - throw new Error(`Could not validate response header "${headerMetadata.name}"`, { cause: error }); - } - } + try { + await this.validationService.validateHeaderParam(res.headers[key], headerMetadata); + } + catch (error) { + throw new Error(`Could not validate response header "${headerMetadata.name}"`, { cause: error }); + } + }) + ); return { ...res, body: responseBody }; } diff --git a/src/http/known-header.enum.ts b/src/http/known-header.enum.ts index 8fe6bdf..9b7711b 100644 --- a/src/http/known-header.enum.ts +++ b/src/http/known-header.enum.ts @@ -4,31 +4,31 @@ import { ObjectUtilities } from '../utilities/object.utilities'; * Known http headers. */ export enum KnownHeader { - ACCEPT = 'Accept', - ACCEPT_ENCODING = 'Accept-Encoding', - AUTHORIZATION = 'Authorization', - CACHE_CONTROL = 'Cache-Control', - CONTENT_LENGTH = 'Content-Length', - CONTENT_TYPE = 'Content-Type', - CONTENT_DISPOSITION = 'Content-Disposition', - COOKIE = 'Cookie', - HOST = 'Host', - ORIGIN = 'Origin', - REFERER = 'Referer', - USER_AGENT = 'User-Agent', - X_REQUESTED_WITH = 'X-Requested-With', - X_FORWARDED_FOR = 'X-Forwarded-For', - X_FORWARDED_HOST = 'X-Forwarded-Host', - X_FORWARDED_PROTO = 'X-Forwarded-Proto', - X_REAL_IP = 'X-Real-IP', - X_CORRELATION_ID = 'X-Correlation-ID', - IF_NONE_MATCH = 'If-None-Match', - IIF_MODIFIED_SINCE = 'If-Modified-Since', - CONNECTION = 'Connection', - DNT = 'DNT', - SEC_FETCH_MODE = 'Sec-Fetch-Mode', - SEC_FETCH_SITE = 'Sec-Fetch-Site', - TE = 'TE' + ACCEPT = 'accept', + ACCEPT_ENCODING = 'accept-encoding', + AUTHORIZATION = 'authorization', + CACHE_CONTROL = 'cache-control', + CONTENT_LENGTH = 'content-length', + CONTENT_TYPE = 'content-type', + CONTENT_DISPOSITION = 'content-disposition', + COOKIE = 'cookie', + HOST = 'host', + ORIGIN = 'origin', + REFERER = 'referer', + USER_AGENT = 'user-agent', + X_REQUESTED_WITH = 'x-requested-with', + X_FORWARDED_FOR = 'x-forwarded-for', + X_FORWARDED_HOST = 'x-forwarded-host', + X_FORWARDED_PROTO = 'x-forwarded-proto', + X_REAL_IP = 'x-real-ip', + X_CORRELATION_ID = 'x-correlation-id', + IF_NONE_MATCH = 'if-none-match', + IIF_MODIFIED_SINCE = 'if-modified-since', + CONNECTION = 'connection', + DNT = 'dnt', + SEC_FETCH_MODE = 'sec-fetch-mode', + SEC_FETCH_SITE = 'sec-fetch-site', + TE = 'te' } /** @@ -37,5 +37,5 @@ export enum KnownHeader { * @returns True when the KnownHeader enum values include the given value, false otherwise. */ export function isKnownHeader(value: string): value is KnownHeader { - return ObjectUtilities.values(KnownHeader).includes(value as KnownHeader); + return ObjectUtilities.values(KnownHeader).includes(value.toLowerCase() as KnownHeader); } \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 819f556..fe48401 100644 --- a/src/index.ts +++ b/src/index.ts @@ -71,6 +71,14 @@ export * from './di/get-all-registered-tokens.function'; export * from './di/errors/get-dependency-stack-trace.function'; export * from './di/errors/no-provider.error'; +// event +export * from './event/event-service.interface'; +export * from './event/event.service'; +export * from './event/event-subscriber-run.model'; +export * from './event/event.model'; +export * from './event/event-cleanup.cron-job'; +export * from './event/event-processing.error'; + // routing export * from './routing/router'; export * from './routing/router.interface'; @@ -93,7 +101,12 @@ export * from './routing/models/object-param-metadata.model'; export * from './routing/models/array-param-metadata.model'; export * from './routing/models/crud-controller.model'; -export * from './routing/request.context'; +// context +export * from './context/als.utilities'; +export * from './context/base-context'; +export * from './context/request/http-request.context'; +export * from './context/request/websocket-request.context'; +export * from './context/request/request-context-token.model'; // error handling export * from './error-handling/error-handler'; @@ -129,6 +142,10 @@ export * from './global/on-app-shutdown.interface'; export * from './global/after-app-shutdown.interface'; export * from './global/global-registry'; +export * from './global/model-registry/remove-exclude-properties.function'; +export * from './global/model-registry/restore-exclude-properties.function'; +export * from './global/model-registry/set-default-values.function'; + // logging export * from './logging/logger.interface'; export * from './logging/logger'; diff --git a/src/jest.setup.ts b/src/jest.setup.ts index 4ea1d14..9f244cd 100644 --- a/src/jest.setup.ts +++ b/src/jest.setup.ts @@ -1,2 +1,6 @@ +import console from 'console'; + // eslint-disable-next-line eslintImport/no-unassigned-import -import 'reflect-metadata'; \ No newline at end of file +import 'reflect-metadata'; + +global.console = console; \ No newline at end of file diff --git a/src/localization/formatting/format-price.function.ts b/src/localization/formatting/format-price.function.ts index 3c75b2e..ff78971 100644 --- a/src/localization/formatting/format-price.function.ts +++ b/src/localization/formatting/format-price.function.ts @@ -1,6 +1,7 @@ import { FormatPriceFn } from './format-price-fn.model'; import { ZIBRI_DI_TOKENS } from '../../di/default/zibri-di-tokens.default'; import { inject } from '../../di/inject.function'; +import { BigNumber } from '../../utilities/big-number.utilities'; import { CurrencyCode } from '../models/currency-code.model'; import { LanguageCode } from '../models/language-code.model'; import { LocalizeOptions } from '../models/localize-options.model'; diff --git a/src/logging/logger.ts b/src/logging/logger.ts index f034678..e0b7f14 100644 --- a/src/logging/logger.ts +++ b/src/logging/logger.ts @@ -1,16 +1,21 @@ import { errorToLoggedError } from './error-to-logged-error.function'; import { LogCleanupCronJob } from './log-cleanup.cron-job'; -import { LogContextInput } from './log-context.model'; +import { LogContextInput, LogRequestContext } from './log-context.model'; import { LogLevel } from './log-level.enum'; import { Log } from './log.model'; import { LoggerInterface } from './logger.interface'; import { ZibriApplication } from '../application'; import { BaseLoggerTransportConfig, LoggerTransport } from './transport/logger-transport.model'; +import { HttpRequestContext } from '../context/request/http-request.context'; +import { WebsocketRequestContext } from '../context/request/websocket-request.context'; import { Inject } from '../di/decorators/inject.decorator'; import { Injectable } from '../di/decorators/injectable.decorator'; import { ZIBRI_DI_TOKENS } from '../di/default/zibri-di-tokens.default'; +import { inject } from '../di/inject.function'; import { GlobalRegistry } from '../global/global-registry'; import { OnAppInit } from '../global/on-app-init.interface'; +import { HttpMethod } from '../http/http-method.enum'; +import { KnownHeader } from '../http/known-header.enum'; import { UUIDUtilities } from '../utilities/uuid.utilities'; /** @@ -65,13 +70,26 @@ export class Logger implements LoggerInterface, OnAppInit { const line: string = (new Error().stack ?? '').split('\n')[3]; const matches: RegExpMatchArray | null = line.match(/\((.*):\d+:\d+\)/); const origin: string = matches?.[0].split('(')[1].split(')')[0] ?? 'unknown'; + let request: LogRequestContext | undefined = context?.request; + const requestContext: HttpRequestContext | WebsocketRequestContext | undefined = inject(ZIBRI_DI_TOKENS.CURRENT_REQUEST_CONTEXT); + if (!request && requestContext?.type === 'http-request') { + request = { + status: requestContext.request.res?.statusCode, + // TODO + // durationInMs: currentRequest.res?.app, + method: requestContext.request.method as HttpMethod, + url: requestContext.request.originalUrl, + userAgent: requestContext.request.headers[KnownHeader.USER_AGENT] ?? '', + clientIp: requestContext.request.ip ?? requestContext.request.socket?.remoteAddress ?? '' + }; + } const log: Log = { id: UUIDUtilities.generate(), createdAt: new Date(), cleanupAt: new Date(Date.now() + this.cleanupAfterMs[level]), message, error: error ? errorToLoggedError(error) : undefined, - context: { ...context, origin }, + context: { origin, request }, level }; diff --git a/src/multithreading/services/multithreading.service.test.ts b/src/multithreading/services/multithreading.service.test.ts index 3e87158..884b4bb 100644 --- a/src/multithreading/services/multithreading.service.test.ts +++ b/src/multithreading/services/multithreading.service.test.ts @@ -3,46 +3,14 @@ import { performance } from 'perf_hooks'; import { afterAll, beforeAll, describe, expect, it } from '@jest/globals'; +import { MultithreadingServiceInterface } from './multithreading-service.interface'; import { MultithreadingService } from './multithreading.service'; -import { AssetService } from '../../assets/asset.service'; -import { Repository } from '../../data-source/repository'; -import { repositoryTokenFor } from '../../di/decorators/inject-repository.decorator'; -import { register } from '../../di/register.function'; -import { LogLevel } from '../../logging/log-level.enum'; -import { Logger } from '../../logging/logger'; -import { LoggerInterface } from '../../logging/logger.interface'; -import { LoggerTransport } from '../../logging/transport/logger-transport.model'; -import { OmitStrict } from '../../types/omit-strict.type'; -import { FsUtilities } from '../../utilities/fs.utilities'; +import { defaultTestServerProviders } from '../../__testing__/test-server/providers'; +import { StartedTestServer, startTestServer } from '../../__testing__/test-server/start-test-server.function'; +import { ZIBRI_DI_TOKENS } from '../../di/default/zibri-di-tokens.default'; +import { inject } from '../../di/inject.function'; +import { defineProvider } from '../../di/models/di-provider.model'; import { Ms } from '../../utilities/ms'; -import { UUIDUtilities } from '../../utilities/uuid.utilities'; -import { BaseThreadJobWorkerData } from '../models/base-thread-job-worker-data.model'; -import { MultithreadingOptions } from '../models/multithreading-options.model'; -import { ThreadJobEntity } from '../models/thread-job-entity.model'; - -// minimal in-memory repository used by the service in tests -class InMemoryThreadJobRepository { - private readonly store: Map> = new Map>(); - create(data: OmitStrict, 'id'>): ThreadJobEntity { - const id: string = UUIDUtilities.generate(); - const entity: ThreadJobEntity = { ...data, id }; - this.store.set(id, entity); - return entity; - } - updateById(id: string, data: Partial>): ThreadJobEntity { - const found: ThreadJobEntity = this.findById(id); - const updated: ThreadJobEntity = { ...found, ...data }; - this.store.set(id, updated); - return updated; - } - findById(id: string): ThreadJobEntity { - const found: ThreadJobEntity | undefined = this.store.get(id); - if (!found) { - throw new Error('Not found'); - } - return found; - } -} const allThreads: number = os.availableParallelism(); const reserveThreadsMain: number = 1; @@ -52,27 +20,6 @@ const availableThreads: number = allThreads - reserveThreadsLibUv - reserveThrea const maxThreads: number = Math.max(2, availableThreads - 1); const maxPriorityThreads: number = availableThreads <= 1 ? 0 : 1; -const options: MultithreadingOptions = { - maxThreads: maxThreads, - maxPriorityThreads: maxPriorityThreads, - defaultTimeoutMs: Ms.HOUR, - defaultTimeoutPriorityMs: Ms.MINUTE * 5 -}; - -const repo: Repository> = new InMemoryThreadJobRepository() as unknown as Repository>; -const logger: LoggerInterface = new Logger( - [LoggerTransport.console(LogLevel.INFO)], - { - [LogLevel.INFO]: 1, - [LogLevel.DEBUG]: 1, - [LogLevel.WARN]: 1, - [LogLevel.ERROR]: 1, - [LogLevel.CRITICAL]: 1 - } -); -const assetService: AssetService = new AssetService(logger); -(assetService.assetsPath as unknown as string) = FsUtilities.getPath(__dirname, '../../../sandbox/assets'); - function fib(n: number): number { if (n < 2) { return n; @@ -86,7 +33,8 @@ const warmStart: number = performance.now(); fib(n); const tSingle: number = performance.now() - warmStart; -let multithreadingService: MultithreadingService; +let multithreadingService: MultithreadingServiceInterface; +let server: StartedTestServer; describe('MultithreadingService - performance vs main event loop', () => { beforeAll(async () => { @@ -95,15 +43,31 @@ describe('MultithreadingService - performance vs main event loop', () => { } // eslint-disable-next-line no-console console.debug('allThreads', allThreads); - register({ token: repositoryTokenFor(ThreadJobEntity), useFactory: () => repo }); - multithreadingService = new MultithreadingService(options, assetService, logger); - await multithreadingService.onAppInit(); + server = await startTestServer({ + providers: [ + ...defaultTestServerProviders, + defineProvider({ + token: ZIBRI_DI_TOKENS.MULTITHREADING_OPTIONS, + useValue: { + maxThreads: maxThreads, + maxPriorityThreads: maxPriorityThreads, + defaultTimeoutMs: Ms.HOUR, + defaultTimeoutPriorityMs: Ms.MINUTE * 5 + } + }), + defineProvider({ + token: ZIBRI_DI_TOKENS.MULTITHREADING_SERVICE, + useClass: MultithreadingService + }) + ] + }); + multithreadingService = inject(ZIBRI_DI_TOKENS.MULTITHREADING_SERVICE); }, 30000); afterAll(async () => { if (allThreads <= 2) { return; } - await multithreadingService.onAppShutdown(); + await server.shutdown(); }); it('runs CPU heavy tasks significantly faster via worker threads', async () => { @@ -113,14 +77,14 @@ describe('MultithreadingService - performance vs main event loop', () => { // measure sequential main-thread execution const startMain: number = performance.now(); const mainResults: number[] = []; - for (let i: number = 0; i < options.maxThreads; i++) { + for (let i: number = 0; i < maxThreads; i++) { mainResults.push(fib(n)); } const mainMs: number = performance.now() - startMain; // measure worker-thread execution (parallel) const startWorkers: number = performance.now(); - const workerPromises: Promise[] = Array.from({ length: options.maxThreads }, () => multithreadingService.run(fib, n)); + const workerPromises: Promise[] = Array.from({ length: maxThreads }, async () => await multithreadingService.run(fib, n)); const workerResults: number[] = await Promise.all(workerPromises); const workersMs: number = performance.now() - startWorkers; @@ -128,13 +92,13 @@ describe('MultithreadingService - performance vs main event loop', () => { expect(workerResults).toEqual(mainResults); // assert worker run is significantly faster than main-thread sequential run - const thresholdFactor: number = computeAdaptiveThresholdFactor(options.maxThreads, options.maxThreads); + const thresholdFactor: number = computeAdaptiveThresholdFactor(maxThreads, maxThreads); // eslint-disable-next-line no-console console.debug('threshold factor:', thresholdFactor, 'multithreading should be below:', mainMs * thresholdFactor); // eslint-disable-next-line no-console console.debug(`main: ${Math.round(mainMs)} ms, workers: ${Math.round(workersMs)} ms`); expect(workersMs).toBeLessThan(mainMs * thresholdFactor); - }, (options.maxThreads * tSingle) * 2); + }, (maxThreads * tSingle) * 2); }); export function computeAdaptiveThresholdFactor( diff --git a/src/multithreading/services/multithreading.service.ts b/src/multithreading/services/multithreading.service.ts index 8ae5551..8a83326 100644 --- a/src/multithreading/services/multithreading.service.ts +++ b/src/multithreading/services/multithreading.service.ts @@ -88,7 +88,7 @@ export class MultithreadingService implements MultithreadingServiceInterface, On worker.on('message', m => void this.handleWorkerMessage(m, threadJobWorker.threadId)); worker.on('exit', code => void this.handleWorkerExit(code, threadJobWorker.threadId)); - worker.on('error', error => void this.handleWorkerError(error, threadJobWorker.threadId)); + worker.on('error', (error: Error) => void this.handleWorkerError(error, threadJobWorker.threadId)); this.idleWorkers.push(threadJobWorker); } diff --git a/src/open-api/open-api.service.ts b/src/open-api/open-api.service.ts index 7ba66a6..8e9dcd6 100644 --- a/src/open-api/open-api.service.ts +++ b/src/open-api/open-api.service.ts @@ -448,7 +448,7 @@ export class OpenApiService implements OpenApiServiceInterface, OnAppInit { return undefined; } const propMeta: Record = MetadataUtilities.getModelProperties(response.cls); - const schema: OpenApiSchemaObject = this.buildOpenApiSchemaForProperties(propMeta, response.cls); + const schema: OpenApiSchemaObject = this.buildOpenApiSchemaForProperties(propMeta, response.cls, 'response'); if (response.isArray === true) { return { [MimeType.JSON]: { schema: { type: 'array', items: schema } } }; @@ -490,7 +490,7 @@ export class OpenApiService implements OpenApiServiceInterface, OnAppInit { continue; } const propMeta: Record = MetadataUtilities.getModelProperties(response.cls); - const schema: OpenApiSchemaObject = this.buildOpenApiSchemaForProperties(propMeta, response.cls); + const schema: OpenApiSchemaObject = this.buildOpenApiSchemaForProperties(propMeta, response.cls, 'response'); if (response.isArray === true) { schemas.push({ type: 'array', items: schema }); continue; @@ -543,7 +543,7 @@ export class OpenApiService implements OpenApiServiceInterface, OnAppInit { return undefined; } const propMeta: Record = MetadataUtilities.getModelProperties(metadata.modelClass); - const schema: OpenApiSchemaObject = this.buildOpenApiSchemaForProperties(propMeta, metadata.modelClass); + const schema: OpenApiSchemaObject = this.buildOpenApiSchemaForProperties(propMeta, metadata.modelClass, 'request'); return { required: typeof metadata.required === 'boolean' ? metadata.required : undefined, description: metadata.description, @@ -552,17 +552,23 @@ export class OpenApiService implements OpenApiServiceInterface, OnAppInit { } // eslint-disable-next-line sonar/cognitive-complexity - private buildOpenApiSchemaForProperties(propMeta: Record, entity: Newable): OpenApiSchemaObject { + private buildOpenApiSchemaForProperties( + propMeta: Record, + entity: Newable, + context: 'request' | 'response' + ): OpenApiSchemaObject { const properties: Record = {}; const required: string[] = []; for (const [key, meta] of ObjectUtilities.entries(propMeta)) { + if (meta.exclude === true && context === 'response') { + continue; + } // mark required - if (( - typeof meta.required === 'boolean' - ? meta.required - : false) + if ( + (typeof meta.required === 'boolean' ? meta.required : false) && (!('default' in meta) || meta.default == undefined) + && (meta.exclude === false || context === 'response') ) { required.push(key); } @@ -605,14 +611,20 @@ export class OpenApiService implements OpenApiServiceInterface, OnAppInit { } case 'object': { const objectPropMeta: Record = MetadataUtilities.getModelProperties(meta.cls()); - properties[key] = { ...this.buildOpenApiSchemaForProperties(objectPropMeta, entity), description: meta.description }; + properties[key] = { + ...this.buildOpenApiSchemaForProperties(objectPropMeta, entity, context), + description: meta.description + }; continue; } case Relation.ONE_TO_ONE: case Relation.MANY_TO_ONE: { const targetClass: Newable = this.getTargetClassForRelation(meta, entity); const objectPropMeta: Record = MetadataUtilities.getModelProperties(targetClass); - properties[key] = { ...this.buildOpenApiSchemaForProperties(objectPropMeta, entity), description: meta.description }; + properties[key] = { + ...this.buildOpenApiSchemaForProperties(objectPropMeta, entity, context), + description: meta.description + }; continue; } case Relation.MANY_TO_MANY: @@ -627,10 +639,12 @@ export class OpenApiService implements OpenApiServiceInterface, OnAppInit { required: true, description: undefined, excludeFromChangeSets: false, + exclude: false, allowAdditionalProperties: false } }, - entity + entity, + context ); properties[key] = { type: 'array', @@ -643,7 +657,7 @@ export class OpenApiService implements OpenApiServiceInterface, OnAppInit { if (meta.items.type === 'object') { entity = meta.items.cls(); } - const items: OpenApiSchemaObject = this.buildOpenApiSchemaForProperties({ items: meta.items }, entity); + const items: OpenApiSchemaObject = this.buildOpenApiSchemaForProperties({ items: meta.items }, entity, context); properties[key] = { type: 'array', description: meta.description, @@ -756,7 +770,7 @@ export class OpenApiService implements OpenApiServiceInterface, OnAppInit { const propMeta: Record = MetadataUtilities.getModelProperties(meta.cls()); return { description: meta.description, - ...this.buildOpenApiSchemaForProperties(propMeta, meta.cls()) + ...this.buildOpenApiSchemaForProperties(propMeta, meta.cls(), 'request') }; } case 'array': { diff --git a/src/parsing/form-data/form-data.body-parser.ts b/src/parsing/form-data/form-data.body-parser.ts index 7d9f471..41873c2 100644 --- a/src/parsing/form-data/form-data.body-parser.ts +++ b/src/parsing/form-data/form-data.body-parser.ts @@ -21,7 +21,7 @@ import { ContentTooLargeError } from '../../error-handling/errors/content-too-la import { KnownHeader } from '../../http/known-header.enum'; import { FileExtension, resolveFileExtension } from '../../http/mime-type.helpers'; import { HttpClientResponse } from '../../http-client/http-client-response.model'; -import { BigNumberUtilities } from '../../utilities/big-number.utilities'; +import { BigNumber, BigNumberUtilities } from '../../utilities/big-number.utilities'; import { FsUtilities, FsPath } from '../../utilities/fs.utilities'; import { MetadataUtilities } from '../../utilities/metadata.utilities'; import { UUIDUtilities } from '../../utilities/uuid.utilities'; @@ -133,10 +133,10 @@ export class FormDataBodyParser implements BodyParserInterface, OnAppInit { private requestToDataObject(request: ParsedForm, metadata: BodyMetadata): T { const multiPartMap: Map = new Map(); + const properties: Record = MetadataUtilities.getModelProperties(metadata.modelClass); this.addStringValuesToMap(request, multiPartMap); - this.addFilesToMap(request, multiPartMap, metadata); + this.addFilesToMap(request, multiPartMap, properties); - const properties: Record = MetadataUtilities.getModelProperties(metadata.modelClass); const res: Partial> = {}; for (const [key, value] of multiPartMap) { if (typeof value !== 'string') { @@ -187,11 +187,10 @@ export class FormDataBodyParser implements BodyParserInterface, OnAppInit { private addFilesToMap( request: ParsedForm, values: Map, - metadata: BodyMetadata + properties: Record ): void { for (const key in request.filesMap) { - const formDataProperties: Record = MetadataUtilities.getModelProperties(metadata.modelClass); - const property: PropertyMetadata = formDataProperties[key]; + const property: PropertyMetadata = properties[key]; this.addFileArrayToMap(request.filesMap[key], values, property); } } diff --git a/src/parsing/json/json.body-parser.ts b/src/parsing/json/json.body-parser.ts index 71cf1da..9789300 100644 --- a/src/parsing/json/json.body-parser.ts +++ b/src/parsing/json/json.body-parser.ts @@ -5,7 +5,7 @@ import { KnownHeader } from '../../http/known-header.enum'; import { MimeType } from '../../http/mime-type.enum'; import { HttpClientResponse } from '../../http-client/http-client-response.model'; import { BodyMetadata } from '../../routing/decorators/body.decorator'; -import { BigNumberUtilities } from '../../utilities/big-number.utilities'; +import { BigNumber, BigNumberUtilities } from '../../utilities/big-number.utilities'; import { WebsocketRequest } from '../../websocket/models/websocket-request.model'; import { BodyParserInterface } from '../body-parser.interface'; import { BodyParser } from '../decorators/body-parser.decorator'; diff --git a/src/plugin/invoicing/services/invoice-calc.service.ts b/src/plugin/invoicing/services/invoice-calc.service.ts index d0e9c0b..d06a220 100644 --- a/src/plugin/invoicing/services/invoice-calc.service.ts +++ b/src/plugin/invoicing/services/invoice-calc.service.ts @@ -1,7 +1,7 @@ import { InvoiceCalcServiceInterface } from './invoice-calc-service.interface'; import { Injectable } from '../../../di/decorators/injectable.decorator'; -import { BigNumberUtilities } from '../../../utilities/big-number.utilities'; +import { BigNumber, BigNumberUtilities } from '../../../utilities/big-number.utilities'; import { InvoiceItem } from '../models/invoice-item.model'; import { Invoice as BaseInvoice, Invoice } from '../models/invoice.model'; import { Vat } from '../models/vat.model'; diff --git a/src/plugin/invoicing/services/invoice-pdf.service.ts b/src/plugin/invoicing/services/invoice-pdf.service.ts index 1979238..d31564b 100644 --- a/src/plugin/invoicing/services/invoice-pdf.service.ts +++ b/src/plugin/invoicing/services/invoice-pdf.service.ts @@ -8,6 +8,7 @@ import { PdfContentDefinition, PdfColumnDefinition, PdfDocument, PdfDocumentDefi import { type FormatDateFn } from '../../../localization/formatting/format-date-fn.model'; import { type FormatPercentFn } from '../../../localization/formatting/format-percent-fn.model'; import { type FormatPriceFn } from '../../../localization/formatting/format-price-fn.model'; +import { BigNumber } from '../../../utilities/big-number.utilities'; import { ZIBRI_INVOICING_PLUGIN_DI_TOKENS } from '../invoicing.tokens'; import { Invoice } from '../models/invoice.model'; import { type InvoicingOptions } from '../models/invoicing-options.model'; diff --git a/src/routing/decorators/body.decorator.ts b/src/routing/decorators/body.decorator.ts index 0ba818b..6804871 100644 --- a/src/routing/decorators/body.decorator.ts +++ b/src/routing/decorators/body.decorator.ts @@ -5,13 +5,14 @@ import { Relation } from '../../entity/models/relation.enum'; import { MimeType } from '../../http/mime-type.enum'; import { Newable } from '../../types/newable.type'; import { OmitStrict } from '../../types/omit-strict.type'; +import { BigNumber } from '../../utilities/big-number.utilities'; import { MetadataUtilities } from '../../utilities/metadata.utilities'; import { Ms } from '../../utilities/ms'; /** * Base metadata shared by all possible http request body properties. */ -type BaseBodyMetadata = OmitStrict & { +type BaseBodyMetadata = OmitStrict & { /** * The class that defines the structure of the body. */ diff --git a/src/routing/models/array-param-metadata.model.ts b/src/routing/models/array-param-metadata.model.ts index 3c07b0a..9e0b421 100644 --- a/src/routing/models/array-param-metadata.model.ts +++ b/src/routing/models/array-param-metadata.model.ts @@ -30,7 +30,7 @@ export type ArrayParamItemMetadataInput = StringParamMetadataInput /** * Metadata for array parameters. */ -export type ArrayParamMetadata = BaseParamMetadata & OmitStrict & { +export type ArrayParamMetadata = BaseParamMetadata & OmitStrict & { /** * Metadata for the items inside this array parameter. */ diff --git a/src/routing/models/boolean-param-metadata.model.ts b/src/routing/models/boolean-param-metadata.model.ts index ad25807..85b6b5c 100644 --- a/src/routing/models/boolean-param-metadata.model.ts +++ b/src/routing/models/boolean-param-metadata.model.ts @@ -5,7 +5,7 @@ import { OmitStrict } from '../../types/omit-strict.type'; /** * Metadata for boolean parameters. */ -export type BooleanParamMetadata = BaseParamMetadata & OmitStrict; +export type BooleanParamMetadata = BaseParamMetadata & OmitStrict; /** * Metadata Input for boolean parameters. diff --git a/src/routing/models/date-param-metadata.model.ts b/src/routing/models/date-param-metadata.model.ts index f747d96..80f27c3 100644 --- a/src/routing/models/date-param-metadata.model.ts +++ b/src/routing/models/date-param-metadata.model.ts @@ -5,7 +5,7 @@ import { OmitStrict } from '../../types/omit-strict.type'; /** * Metadata for Date parameters. */ -export type DateParamMetadata = BaseParamMetadata & OmitStrict; +export type DateParamMetadata = BaseParamMetadata & OmitStrict; /** * Metadata Input for Date parameters. diff --git a/src/routing/models/number-param-metadata.model.ts b/src/routing/models/number-param-metadata.model.ts index c6c3125..0ce8158 100644 --- a/src/routing/models/number-param-metadata.model.ts +++ b/src/routing/models/number-param-metadata.model.ts @@ -5,7 +5,10 @@ import { OmitStrict } from '../../types/omit-strict.type'; /** * Metadata for number parameters. */ -export type NumberParamMetadata = BaseParamMetadata & OmitStrict; +export type NumberParamMetadata = BaseParamMetadata & OmitStrict< + NumberPropertyMetadata, + 'primary' | 'default' | 'exclude' | 'excludeFromChangeSets' +>; /** * Metadata Input for number parameters. diff --git a/src/routing/models/object-param-metadata.model.ts b/src/routing/models/object-param-metadata.model.ts index ca9caa5..a05f07a 100644 --- a/src/routing/models/object-param-metadata.model.ts +++ b/src/routing/models/object-param-metadata.model.ts @@ -5,7 +5,10 @@ import { OmitStrict } from '../../types/omit-strict.type'; /** * Metadata for object parameters. */ -export type ObjectParamMetadata = BaseParamMetadata & OmitStrict; +export type ObjectParamMetadata = BaseParamMetadata & OmitStrict< + ObjectPropertyMetadata, + 'exclude' | 'excludeFromChangeSets' +>; /** * Metadata Input for object parameters. diff --git a/src/routing/models/string-param-metadata.model.ts b/src/routing/models/string-param-metadata.model.ts index b4d0af3..88ede0d 100644 --- a/src/routing/models/string-param-metadata.model.ts +++ b/src/routing/models/string-param-metadata.model.ts @@ -5,7 +5,10 @@ import { OmitStrict } from '../../types/omit-strict.type'; /** * Metadata for string parameters. */ -export type StringParamMetadata = BaseParamMetadata & OmitStrict; +export type StringParamMetadata = BaseParamMetadata & OmitStrict< + StringPropertyMetadata, + 'primary' | 'default' | 'exclude' | 'excludeFromChangeSets' +>; /** * Metadata Input for string parameters. diff --git a/src/routing/request.context.ts b/src/routing/request.context.ts deleted file mode 100644 index c2f7267..0000000 --- a/src/routing/request.context.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { AsyncLocalStorage } from 'async_hooks'; - -import { HttpRequest } from '../http/http-request.model'; - -/** - * The data stored in async local storage. - */ -type AslData = { - /** - * The current http request. - */ - request: HttpRequest -}; - -const als: AsyncLocalStorage = new AsyncLocalStorage(); - -/** - * Runs the given function with the request saved in async local storage. - * @param req - The request. - * @param fn - The function to run. - * @returns The result of the function. - */ -export function runWithRequest(req: HttpRequest, fn: () => T): T { - return als.run({ request: req }, fn); -} - -/** - * Resolves the currently active request from the async local storage. - * @returns The currently active http request. - * @throws When the async local storage store has not been initialized yet. - */ -export function getCurrentRequest(): HttpRequest | undefined { - const store: AslData | undefined = als.getStore(); - return store?.request; -} \ No newline at end of file diff --git a/src/routing/resolve-route-params.function.ts b/src/routing/resolve-route-params.function.ts index 6526c57..595af4c 100644 --- a/src/routing/resolve-route-params.function.ts +++ b/src/routing/resolve-route-params.function.ts @@ -1,29 +1,64 @@ -import { AuthServiceInterface } from '../auth/auth-service.interface'; +import { BodyMetadata } from './decorators/body.decorator'; +import { PathParamMetadata, QueryParamMetadata, HeaderParamMetadata } from './decorators/param.decorator'; import { CurrentUserMetadata } from '../auth/decorators/current-user.decorator'; -import { HttpRequest } from '../http/http-request.model'; +import { BaseUser } from '../auth/models/base-user.model'; +import { HttpRequestContext } from '../context/request/http-request.context'; +import { ZIBRI_REQUEST_CONTEXT_TOKENS } from '../context/request/request-context-token.model'; +import { WebsocketRequestContext } from '../context/request/websocket-request.context'; +import { ZIBRI_DI_TOKENS } from '../di/default/zibri-di-tokens.default'; +import { inject } from '../di/inject.function'; +import { KnownHeader } from '../http/known-header.enum'; import { ParserInterface } from '../parsing/parser.interface'; import { Newable } from '../types/newable.type'; import { MetadataUtilities } from '../utilities/metadata.utilities'; import { ObjectUtilities } from '../utilities/object.utilities'; import { ValidationServiceInterface } from '../validation/validation-service.interface'; -import { BodyMetadata } from './decorators/body.decorator'; -import { PathParamMetadata, QueryParamMetadata, HeaderParamMetadata } from './decorators/param.decorator'; import { CurrentWebsocketConnectionMetadata } from '../websocket/decorators/current-websocket-connection.decorator'; -import { BaseWebsocketConnection } from '../websocket/models/connection/base-websocket-connection.model'; -import { WebsocketRequest } from '../websocket/models/websocket-request.model'; // eslint-disable-next-line jsdoc/require-jsdoc export async function resolveRouteParams( controllerClass: Newable, controllerMethod: string, totalParamCount: number, - req: HttpRequest | WebsocketRequest, - parser: ParserInterface, - validationService: ValidationServiceInterface, - authService: AuthServiceInterface, - currentWebsocketConnection: BaseWebsocketConnection | undefined + context: HttpRequestContext | WebsocketRequestContext +): Promise { + const params: unknown[] = await parseRouteParams(controllerClass, controllerMethod, totalParamCount, context); + + const validationService: ValidationServiceInterface = inject(ZIBRI_DI_TOKENS.VALIDATION_SERVICE); + + // validate + const pathParams: Record = MetadataUtilities.getRoutePathParams(controllerClass, controllerMethod); + const queryParams: Record = MetadataUtilities.getRouteQueryParams(controllerClass, controllerMethod); + const headerParams: Record = MetadataUtilities.getRouteHeaderParams(controllerClass, controllerMethod); + const requestBody: BodyMetadata | undefined = MetadataUtilities.getRouteBody(controllerClass, controllerMethod); + + await Promise.all([ + ...ObjectUtilities.entries(pathParams).map(async ([indexStr, metadata]) => { + const idx: number = Number(indexStr); + await validationService.validatePathParam(params[idx], metadata); + }), + ...ObjectUtilities.entries(queryParams).map(async ([indexStr, metadata]) => { + const idx: number = Number(indexStr); + await validationService.validateQueryParam(params[idx], metadata); + }), + ...ObjectUtilities.entries(headerParams).map(async ([indexStr, metadata]) => { + const idx: number = Number(indexStr); + await validationService.validateHeaderParam(params[idx], metadata); + }), + ...requestBody ? [validationService.validateBody(params[requestBody.index], requestBody)] : [] + ]); + + return params; +} + +// eslint-disable-next-line jsdoc/require-jsdoc +async function parseRouteParams( + controllerClass: Newable, + controllerMethod: string, + totalParamCount: number, + context: HttpRequestContext | WebsocketRequestContext ): Promise { - // TODO: validate that no additional parameters have been provided that are unused in the controller. + const parser: ParserInterface = inject(ZIBRI_DI_TOKENS.PARSER); let resolvedParamCount: number = 0; const params: unknown[] = new Array(totalParamCount).fill(undefined); @@ -32,25 +67,19 @@ export async function resolveRouteParams( const pathParams: Record = MetadataUtilities.getRoutePathParams(controllerClass, controllerMethod); for (const [indexStr, metadata] of ObjectUtilities.entries(pathParams)) { const idx: number = Number(indexStr); - params[idx] = parser.parsePathParam(req, metadata); - validationService.validatePathParam(params[idx], metadata); + context.request.params ??= {}; + context.request.params[metadata.name] = parser.parsePathParam(context.request, metadata) as string | undefined; + params[idx] = context.request.params[metadata.name]; } resolvedParamCount += ObjectUtilities.keys(pathParams).length; - // 2) Body decorator - const requestBody: BodyMetadata | undefined = MetadataUtilities.getRouteBody(controllerClass, controllerMethod); - if (requestBody) { - resolvedParamCount++; - params[requestBody.index] = await parser.parseBody(req, requestBody); - validationService.validateBody(params[requestBody.index], requestBody); - } - - // 3) Query decorators + // 2) Query decorators const queryParams: Record = MetadataUtilities.getRouteQueryParams(controllerClass, controllerMethod); for (const [indexStr, metadata] of ObjectUtilities.entries(queryParams)) { const idx: number = Number(indexStr); - params[idx] = parser.parseQueryParam(req, metadata); - validationService.validateQueryParam(params[idx], metadata); + context.request.query ??= {}; + context.request.query[metadata.name] = parser.parseQueryParam(context.request, metadata) as string | undefined; + params[idx] = context.request.query[metadata.name]; } resolvedParamCount += ObjectUtilities.keys(queryParams).length; @@ -58,28 +87,41 @@ export async function resolveRouteParams( const headerParams: Record = MetadataUtilities.getRouteHeaderParams(controllerClass, controllerMethod); for (const [indexStr, metadata] of ObjectUtilities.entries(headerParams)) { const idx: number = Number(indexStr); - params[idx] = parser.parseHeaderParam(req, metadata); - validationService.validateHeaderParam(params[idx], metadata); + context.request.headers[metadata.name as KnownHeader] = parser.parseHeaderParam(context.request, metadata) as string | undefined; + params[idx] = parser.parseHeaderParam(context.request, metadata); } resolvedParamCount += ObjectUtilities.keys(headerParams).length; - // 4) CurrentUser decorator - const currentUser: CurrentUserMetadata | undefined = MetadataUtilities.getRouteCurrentUser(controllerClass, controllerMethod); - if (currentUser) { + // 4) Body decorator + const requestBody: BodyMetadata | undefined = MetadataUtilities.getRouteBody(controllerClass, controllerMethod); + if (requestBody) { + context.request.body = await parser.parseBody(context.request, requestBody); + params[requestBody.index] = context.request.body; + resolvedParamCount++; + } + + // 5) CurrentUser decorator + const currentUserMetadata: CurrentUserMetadata | undefined = MetadataUtilities.getRouteCurrentUser(controllerClass, controllerMethod); + if (currentUserMetadata) { + const currentUser: BaseUser | undefined = await context.get(ZIBRI_REQUEST_CONTEXT_TOKENS.CURRENT_USER); + params[currentUserMetadata.index] = currentUser; resolvedParamCount++; - params[currentUser.index] = await authService.getCurrentUser( - req, - currentUser.allowedStrategies ?? authService.strategies, - currentUser.required - ); } - // 4) CurrentWebsocketConnection decorator + // 6) CurrentWebsocketConnection decorator // eslint-disable-next-line stylistic/max-len const currentWebsocketConnectionMetadata: CurrentWebsocketConnectionMetadata | undefined = MetadataUtilities.getRouteCurrentWebsocketConnection(controllerClass, controllerMethod); if (currentWebsocketConnectionMetadata) { + switch (context.type) { + case 'http-request': { + throw new Error('Tried to inject a websocket connection on a http request.'); + } + case 'websocket-request': { + params[currentWebsocketConnectionMetadata.index] = context.connection; + break; + } + } resolvedParamCount++; - params[currentWebsocketConnectionMetadata.index] = currentWebsocketConnection; } if (resolvedParamCount < totalParamCount) { diff --git a/src/routing/router.ts b/src/routing/router.ts index 837d55d..af90927 100644 --- a/src/routing/router.ts +++ b/src/routing/router.ts @@ -9,7 +9,6 @@ import { MissingBaseRouteError } from './missing-base-route.error'; import { createHeaderParamMetadata, createPathParamMetadata, createQueryParamMetadata } from './param-metdata.helpers'; import { RouterInterface } from './router.interface'; import { ZibriApplication } from '../application'; -import { runWithRequest } from './request.context'; import { resolveRouteParams } from './resolve-route-params.function'; import { OpenApiRouteConfiguration, RouteConfiguration, RouteConfigurationInput } from './route-configuration.model'; import type { AuthServiceInterface } from '../auth/auth-service.interface'; @@ -35,6 +34,9 @@ import { MetadataUtilities } from '../utilities/metadata.utilities'; import { Ms } from '../utilities/ms'; import type { ValidationServiceInterface } from '../validation/validation-service.interface'; import { ControllerData } from './decorators/controller.decorator'; +import { AlsUtilities } from '../context/als.utilities'; +import { HttpRequestContext } from '../context/request/http-request.context'; +import { ObjectUtilities } from '../utilities/object.utilities'; /** * Default router implementation of Zibri. @@ -77,7 +79,6 @@ export class Router implements RouterInterface, OnAppInit, OnAppStart { // eslint-disable-next-line jsdoc/require-jsdoc onAppStart(app: ZibriApplication): void { app.use(this.expressRouter); - app.use((req, _res, next) => runWithRequest(req as HttpRequest, () => next())); } private checkForOrphanedControllers(controllers: Newable[]): void { @@ -226,40 +227,54 @@ export class Router implements RouterInterface, OnAppInit, OnAppStart { HeaderMetaObject extends Record >(route: RouteConfiguration): RequestHandler { const handler: RequestHandler = (async ( - req: HttpRequest, + request: HttpRequest, res: HttpResponse, next: NextFunction ) => { - try { - if (route.bodyMetadata) { - req.body = await this.parser.parseBody(req, route.bodyMetadata); - this.validationService.validateBody(req.body, route.bodyMetadata); - } - for (const key in route.pathParams) { - (req.params[key] as unknown) = this.parser.parsePathParam(req, route.pathParams[key]); - this.validationService.validatePathParam(req.params[key], route.pathParams[key]); - } - for (const key in route.queryParams) { - (req.query[key] as unknown) = this.parser.parseQueryParam( - req, - route.queryParams[key] - ); - this.validationService.validateQueryParam(req.query[key], route.queryParams[key]); + const context: HttpRequestContext = new HttpRequestContext(request, undefined, undefined); + await AlsUtilities.runWithHttpRequestContext(context, async () => { + try { + // parse + for (const key of ObjectUtilities.keys(route.pathParams)) { + (context.request.params[key] as unknown) = this.parser.parsePathParam(context.request, route.pathParams[key]); + } + for (const key of ObjectUtilities.keys(route.queryParams)) { + (context.request.query[key] as unknown) = this.parser.parseQueryParam( + context.request, + route.queryParams[key] + ); + } + for (const key of ObjectUtilities.keys(route.headerParams)) { + (context.request.headers[key] as unknown) = this.parser.parseHeaderParam( + context.request, + route.headerParams[key] + ); + } + if (route.bodyMetadata) { + context.request.body = await this.parser.parseBody(context.request, route.bodyMetadata); + } + // validate + await Promise.all([ + ...ObjectUtilities.keys(route.pathParams).map(async key => { + await this.validationService.validatePathParam(context.request.params[key], route.pathParams[key]); + }), + ...ObjectUtilities.keys(route.queryParams).map(async key => { + await this.validationService.validateQueryParam(context.request.query[key], route.queryParams[key]); + }), + ...ObjectUtilities.keys(route.headerParams).map(async key => { + await this.validationService.validateHeaderParam(context.request.headers[key], route.headerParams[key]); + }), + ...route.bodyMetadata ? [this.validationService.validateBody(context.request.body, route.bodyMetadata)] : [] + ]); + + // eslint-disable-next-line typescript/no-explicit-any + const result: unknown = await route.handler(context.request as HttpRequest, res, next); + this.returnResult(res, result, next); } - for (const key in route.headerParams) { - (req.headers[key] as unknown) = this.parser.parseHeaderParam( - req, - route.headerParams[key] - ); - this.validationService.validateHeaderParam(req.headers[key], route.headerParams[key]); + catch (error) { + next(error); } - // eslint-disable-next-line typescript/no-explicit-any - const result: unknown = await route.handler(req as HttpRequest, res, next); - this.returnResult(res, result, next); - } - catch (error) { - next(error); - } + }); }) as RequestHandler; return handler; } @@ -273,24 +288,27 @@ export class Router implements RouterInterface, OnAppInit, OnAppStart { await this.logger.warn(`No responses defined on route ${controllerClass.name}.${route.controllerMethod}`); } const handler: RequestHandler = (async (req: HttpRequest, res: HttpResponse, next: NextFunction) => { - try { - await this.authService.checkAccess(controllerClass, route.controllerMethod, req); - const controller: unknown = inject(controllerClass); - const params: unknown[] = await this.resolveRouteParams( - controllerClass, - route.controllerMethod, - // eslint-disable-next-line typescript/no-unsafe-member-access, typescript/no-explicit-any - ((controller as any)[route.controllerMethod] as Function).length, - req - ); + const context: HttpRequestContext = new HttpRequestContext(req, controllerClass, route.controllerMethod); + await AlsUtilities.runWithHttpRequestContext(context, async () => { + try { + await this.authService.checkAccess(controllerClass, route.controllerMethod, context); + const controller: unknown = inject(controllerClass); + const params: unknown[] = await resolveRouteParams( + controllerClass, + route.controllerMethod, + // eslint-disable-next-line typescript/no-unsafe-member-access, typescript/no-explicit-any + ((controller as any)[route.controllerMethod] as Function).length, + context + ); - // eslint-disable-next-line typescript/no-unsafe-call, typescript/no-explicit-any, typescript/no-unsafe-member-access - const result: unknown = await ((controller as any)[route.controllerMethod] as Function)(...params); - this.returnResult(res, result, next); - } - catch (error) { - next(error); - } + // eslint-disable-next-line typescript/no-unsafe-call, typescript/no-explicit-any, typescript/no-unsafe-member-access + const result: unknown = await ((controller as any)[route.controllerMethod] as Function)(...params); + this.returnResult(res, result, next); + } + catch (error) { + next(error); + } + }); }) as RequestHandler; return handler; } @@ -370,22 +388,4 @@ export class Router implements RouterInterface, OnAppInit, OnAppStart { res.json(result); } - - private async resolveRouteParams( - controllerClass: Newable, - controllerMethod: string, - totalParamCount: number, - req: HttpRequest - ): Promise { - return await resolveRouteParams( - controllerClass, - controllerMethod, - totalParamCount, - req, - this.parser, - this.validationService, - this.authService, - undefined - ); - } } \ No newline at end of file diff --git a/src/utilities/metadata-injection-keys.enum.ts b/src/utilities/metadata-injection-keys.enum.ts index 252e807..24359fd 100644 --- a/src/utilities/metadata-injection-keys.enum.ts +++ b/src/utilities/metadata-injection-keys.enum.ts @@ -43,5 +43,6 @@ export enum MetadataInjectionKeys { BACKUP_RESOURCE_METADATA = 'backup_resource:metadata', WEBSOCKET_CONTROLLER_DATA = 'websocket_controller:data', WEBSOCKET_CONTROLLER_ROUTES = 'websocket_controller:routes', - ROUTE_CURRENT_WEBSOCKET_CONNECTION = 'route:current_websocket_connection' + ROUTE_CURRENT_WEBSOCKET_CONNECTION = 'route:current_websocket_connection', + EXCLUDED_PROPERTY_VALUE = 'entity:excluded_property_value' } \ No newline at end of file diff --git a/src/utilities/promise.utilities.ts b/src/utilities/promise.utilities.ts index a28e4a0..e76ac04 100644 --- a/src/utilities/promise.utilities.ts +++ b/src/utilities/promise.utilities.ts @@ -77,10 +77,10 @@ export abstract class PromiseUtilities { * @param timeoutInMs - The timeout after which an error should be thrown. * @returns The result of the function if finished in time. */ - static async withTimeout( - promise: Res | Promise, + static async withTimeout( + promise: (signal: AbortSignal) => T | Promise, timeoutInMs: number - ): Promise { + ): Promise { const ac: AbortController = new AbortController(); const timeoutFn: () => Promise = async () => { await setTimeout(timeoutInMs, undefined, { signal: ac.signal }); @@ -88,8 +88,8 @@ export abstract class PromiseUtilities { }; try { - const res: Res = await Promise.race([ - promise, + const res: T = await Promise.race([ + Promise.resolve().then(() => promise(ac.signal)), timeoutFn() ]); diff --git a/src/validation/functions/validate-boolean.function.ts b/src/validation/functions/validate-boolean.function.ts index aedf4eb..1c8ac30 100644 --- a/src/validation/functions/validate-boolean.function.ts +++ b/src/validation/functions/validate-boolean.function.ts @@ -1,3 +1,7 @@ +import { HttpRequestContext } from '../../context/request/http-request.context'; +import { WebsocketRequestContext } from '../../context/request/websocket-request.context'; +import { ZIBRI_DI_TOKENS } from '../../di/default/zibri-di-tokens.default'; +import { inject } from '../../di/inject.function'; import { PropertyMetadata } from '../../entity/decorators/property.decorator'; import { BooleanPropertyMetadata } from '../../entity/models/boolean-property-metadata.model'; import { QueryParamMetadata, HeaderParamMetadata, PathParamMetadata } from '../../routing/decorators/param.decorator'; @@ -13,30 +17,24 @@ import { IsRequiredValidationProblem, TypeMismatchValidationProblem, ValidationP * @param entity - The entity that the value belongs to. * @returns All validation problems found. */ -export function validateBoolean( +export async function validateBoolean( key: string, property: unknown, metadata: PropertyMetadata | QueryParamMetadata | HeaderParamMetadata | PathParamMetadata, parentKey: string | undefined, entity: unknown | undefined -): ValidationProblem[] { +): Promise { const meta: BooleanPropertyMetadata | BooleanParamMetadata = metadata as BooleanPropertyMetadata | BooleanParamMetadata; const fullKey: string = parentKey ? `${parentKey}.${key}` : key; - if ( - property == undefined - && (meta as BooleanPropertyMetadata).default == undefined - && (typeof metadata.required === 'boolean' ? metadata.required : metadata.required(entity)) - ) { - return [new IsRequiredValidationProblem(fullKey)]; - } - if ( - property == undefined - && ( - !(typeof metadata.required === 'boolean' ? metadata.required : metadata.required(entity)) - || (meta as BooleanPropertyMetadata).default != undefined - ) - ) { - return []; + if (property == undefined) { + const context: HttpRequestContext | WebsocketRequestContext | undefined = inject(ZIBRI_DI_TOKENS.CURRENT_REQUEST_CONTEXT); + const isRequired: boolean = typeof metadata.required === 'boolean' ? metadata.required : await metadata.required(entity, context); + if ((meta as BooleanPropertyMetadata).default == undefined && isRequired) { + return [new IsRequiredValidationProblem(fullKey)]; + } + if (!isRequired || (meta as BooleanPropertyMetadata).default != undefined) { + return []; + } } if (typeof property !== 'boolean') { return [new TypeMismatchValidationProblem(fullKey, 'boolean')]; diff --git a/src/validation/functions/validate-date.function.ts b/src/validation/functions/validate-date.function.ts index dc9e342..6d6231e 100644 --- a/src/validation/functions/validate-date.function.ts +++ b/src/validation/functions/validate-date.function.ts @@ -1,3 +1,5 @@ +import { HttpRequestContext } from '../../context/request/http-request.context'; +import { WebsocketRequestContext } from '../../context/request/websocket-request.context'; import { ZIBRI_DI_TOKENS } from '../../di/default/zibri-di-tokens.default'; import { inject } from '../../di/inject.function'; import { PropertyMetadata } from '../../entity/decorators/property.decorator'; @@ -16,30 +18,24 @@ import { IsRequiredValidationProblem, TypeMismatchValidationProblem, ValidationP * @param entity - The entity that this value belongs to. * @returns All validation problems found. */ -export function validateDate( +export async function validateDate( key: string, property: unknown, metadata: PropertyMetadata | QueryParamMetadata | HeaderParamMetadata | PathParamMetadata, parentKey: string | undefined, entity: unknown | undefined -): ValidationProblem[] { +): Promise { const meta: DatePropertyMetadata | DateParamMetadata = metadata as DatePropertyMetadata | DateParamMetadata; const fullKey: string = parentKey ? `${parentKey}.${key}` : key; - if ( - property == undefined - && (meta as DatePropertyMetadata).default == undefined - && (typeof metadata.required === 'boolean' ? metadata.required : metadata.required(entity)) - ) { - return [new IsRequiredValidationProblem(fullKey)]; - } - if ( - property == undefined - && ( - !(typeof metadata.required === 'boolean' ? metadata.required : metadata.required(entity)) - || (meta as DatePropertyMetadata).default != undefined - ) - ) { - return []; + if (property == undefined) { + const context: HttpRequestContext | WebsocketRequestContext | undefined = inject(ZIBRI_DI_TOKENS.CURRENT_REQUEST_CONTEXT); + const isRequired: boolean = typeof metadata.required === 'boolean' ? metadata.required : await metadata.required(entity, context); + if ((meta as DatePropertyMetadata).default == undefined && isRequired) { + return [new IsRequiredValidationProblem(fullKey)]; + } + if (!isRequired || (meta as DatePropertyMetadata).default != undefined) { + return []; + } } if (!(property instanceof Date)) { return [new TypeMismatchValidationProblem(fullKey, 'date')]; diff --git a/src/validation/functions/validate-file.function.ts b/src/validation/functions/validate-file.function.ts index 7f624ab..e14ac7b 100644 --- a/src/validation/functions/validate-file.function.ts +++ b/src/validation/functions/validate-file.function.ts @@ -1,3 +1,7 @@ +import { HttpRequestContext } from '../../context/request/http-request.context'; +import { WebsocketRequestContext } from '../../context/request/websocket-request.context'; +import { ZIBRI_DI_TOKENS } from '../../di/default/zibri-di-tokens.default'; +import { inject } from '../../di/inject.function'; import { PropertyMetadata } from '../../entity/decorators/property.decorator'; import { fileSizeToBytes } from '../../entity/models/file-property-metadata.model'; import { MimeType } from '../../http/mime-type.enum'; @@ -15,30 +19,21 @@ import { MaxFileSizeValidationProblem, IsRequiredValidationProblem, TypeMismatch * @returns All validation problems found. * @throws When the property is not a file property. */ -export function validateFile( +export async function validateFile( key: string, property: unknown, metadata: PropertyMetadata, parentKey: string | undefined, entity: unknown | undefined -): ValidationProblem[] { +): Promise { if (metadata.type !== 'file') { throw new Error(`Tried to validate a file but received metadata of type "${metadata.type}"`); } const fullKey: string = parentKey ? `${parentKey}.${key}` : key; - if ( - property == undefined - && 'required' in metadata - && (typeof metadata.required === 'boolean' ? metadata.required : metadata.required(entity)) - ) { - return [new IsRequiredValidationProblem(fullKey)]; - } - if ( - property == undefined - && 'required' in metadata - && !(typeof metadata.required === 'boolean' ? metadata.required : metadata.required(entity)) - ) { - return []; + if (property == undefined && 'required' in metadata) { + const context: HttpRequestContext | WebsocketRequestContext | undefined = inject(ZIBRI_DI_TOKENS.CURRENT_REQUEST_CONTEXT); + const isRequired: boolean = typeof metadata.required === 'boolean' ? metadata.required : await metadata.required(entity, context); + return isRequired ? [new IsRequiredValidationProblem(fullKey)] : []; } if (!(property instanceof File)) { return [new TypeMismatchValidationProblem(fullKey, 'file')]; diff --git a/src/validation/functions/validate-number.function.ts b/src/validation/functions/validate-number.function.ts index c2a5e92..e15fe5e 100644 --- a/src/validation/functions/validate-number.function.ts +++ b/src/validation/functions/validate-number.function.ts @@ -1,3 +1,7 @@ +import { HttpRequestContext } from '../../context/request/http-request.context'; +import { WebsocketRequestContext } from '../../context/request/websocket-request.context'; +import { ZIBRI_DI_TOKENS } from '../../di/default/zibri-di-tokens.default'; +import { inject } from '../../di/inject.function'; import { PropertyMetadata } from '../../entity/decorators/property.decorator'; import { NumberPropertyMetadata } from '../../entity/models/number-property-metadata.model'; import { QueryParamMetadata, HeaderParamMetadata, PathParamMetadata } from '../../routing/decorators/param.decorator'; @@ -14,30 +18,25 @@ import { IsRequiredValidationProblem, TypeMismatchValidationProblem, ValidationP * @param entity - The entity that this value belongs to. * @returns All validation problems found. */ -export function validateNumber( +// eslint-disable-next-line sonar/cognitive-complexity +export async function validateNumber( key: string, property: unknown, metadata: PropertyMetadata | QueryParamMetadata | HeaderParamMetadata | PathParamMetadata, parentKey: string | undefined, entity: unknown | undefined -): ValidationProblem[] { +): Promise { const meta: NumberPropertyMetadata | NumberParamMetadata = metadata as NumberPropertyMetadata | NumberParamMetadata; const fullKey: string = parentKey ? `${parentKey}.${key}` : key; - if ( - property == undefined - && (meta as NumberPropertyMetadata).default == undefined - && (typeof metadata.required === 'boolean' ? metadata.required : metadata.required(entity)) - ) { - return [new IsRequiredValidationProblem(fullKey)]; - } - if ( - property == undefined - && ( - !(typeof metadata.required === 'boolean' ? metadata.required : metadata.required(entity)) - || (meta as NumberPropertyMetadata).default != undefined - ) - ) { - return []; + if (property == undefined) { + const context: HttpRequestContext | WebsocketRequestContext | undefined = inject(ZIBRI_DI_TOKENS.CURRENT_REQUEST_CONTEXT); + const isRequired: boolean = typeof metadata.required === 'boolean' ? metadata.required : await metadata.required(entity, context); + if ((meta as NumberPropertyMetadata).default == undefined && isRequired) { + return [new IsRequiredValidationProblem(fullKey)]; + } + if (!isRequired || (meta as NumberPropertyMetadata).default != undefined) { + return []; + } } if (typeof property !== 'number') { return [new TypeMismatchValidationProblem(fullKey, 'number')]; diff --git a/src/validation/functions/validate-string.function.ts b/src/validation/functions/validate-string.function.ts index 02c9b93..b07efa8 100644 --- a/src/validation/functions/validate-string.function.ts +++ b/src/validation/functions/validate-string.function.ts @@ -1,3 +1,7 @@ +import { HttpRequestContext } from '../../context/request/http-request.context'; +import { WebsocketRequestContext } from '../../context/request/websocket-request.context'; +import { ZIBRI_DI_TOKENS } from '../../di/default/zibri-di-tokens.default'; +import { inject } from '../../di/inject.function'; import { PropertyMetadata } from '../../entity/decorators/property.decorator'; import { StringPropertyMetadata, StringFormat } from '../../entity/models/string-property-metadata.model'; import { QueryParamMetadata, HeaderParamMetadata, PathParamMetadata } from '../../routing/decorators/param.decorator'; @@ -18,26 +22,28 @@ const EMAIL_REGEX: RegExp = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; * @returns All validation problems found. */ // eslint-disable-next-line sonar/cognitive-complexity -export function validateString( +export async function validateString( key: string, property: unknown, metadata: PropertyMetadata | QueryParamMetadata | HeaderParamMetadata | PathParamMetadata, parentKey: string | undefined, entity: unknown | undefined -): ValidationProblem[] { +): Promise { const meta: StringPropertyMetadata | StringParamMetadata = metadata as StringPropertyMetadata | StringParamMetadata; const fullKey: string = parentKey ? `${parentKey}.${key}` : key; + const context: HttpRequestContext | WebsocketRequestContext | undefined = inject(ZIBRI_DI_TOKENS.CURRENT_REQUEST_CONTEXT); + const isRequired: boolean = typeof metadata.required === 'boolean' ? metadata.required : await metadata.required(entity, context); if ( property == undefined && (meta as StringPropertyMetadata).default == undefined - && (typeof metadata.required === 'boolean' ? metadata.required : metadata.required(entity)) + && isRequired ) { return [new IsRequiredValidationProblem(fullKey)]; } if ( property == undefined && ( - !(typeof metadata.required === 'boolean' ? metadata.required : metadata.required(entity)) + !isRequired || (meta as StringPropertyMetadata).default != undefined ) ) { diff --git a/src/validation/validation-service.interface.ts b/src/validation/validation-service.interface.ts index 186a97d..d072bbf 100644 --- a/src/validation/validation-service.interface.ts +++ b/src/validation/validation-service.interface.ts @@ -8,22 +8,22 @@ export interface ValidationServiceInterface { /** * Validate a request/response body. */ - validateBody: (body: unknown, meta: BodyMetadata) => void, + validateBody: (body: unknown, meta: BodyMetadata) => void | Promise, /** * Validate a header param. */ - validateHeaderParam: (param: unknown, meta: HeaderParamMetadata) => void, + validateHeaderParam: (param: unknown, meta: HeaderParamMetadata) => void | Promise, /** * Validate a path parameter. */ - validatePathParam: (param: unknown, meta: PathParamMetadata) => void, + validatePathParam: (param: unknown, meta: PathParamMetadata) => void | Promise, /** * Validate a query parameter. */ - validateQueryParam: (param: unknown, meta: QueryParamMetadata) => void, + validateQueryParam: (param: unknown, meta: QueryParamMetadata) => void | Promise, /** * Checks if the given value is a valid websocket request. * This does NOT check its content like the body or params, but only the base structure. */ - validateWebsocketRequest: (req: unknown) => void + validateWebsocketRequest: (req: unknown) => void | Promise } \ No newline at end of file diff --git a/src/validation/validation.service.ts b/src/validation/validation.service.ts index dd63943..62e62f6 100644 --- a/src/validation/validation.service.ts +++ b/src/validation/validation.service.ts @@ -7,6 +7,8 @@ import { validateFile } from './functions/validate-file.function'; import { validateNumber } from './functions/validate-number.function'; import { validateString } from './functions/validate-string.function'; import { Injectable } from '../di/decorators/injectable.decorator'; +import { ZIBRI_DI_TOKENS } from '../di/default/zibri-di-tokens.default'; +import { inject } from '../di/inject.function'; import { PropertyMetadata, Property, RelationMetadata } from '../entity/decorators/property.decorator'; import { ValidationError } from '../error-handling/errors/validation.error'; import { MimeType } from '../http/mime-type.enum'; @@ -29,7 +31,7 @@ type PathParamValidationFunction = ( meta: PathParamMetadata, parentKey: string | undefined, entity: unknown | undefined -) => ValidationProblem[]; +) => ValidationProblem[] | Promise; /** * Function for validating a query parameter. @@ -39,7 +41,7 @@ type QueryParamValidationFunction = ( meta: QueryParamMetadata, parentKey: string | undefined, entity: unknown | undefined -) => ValidationProblem[]; +) => ValidationProblem[] | Promise; /** * Function for validating a header parameter. @@ -49,7 +51,7 @@ type HeaderParamValidationFunction = ( meta: HeaderParamMetadata, parentKey: string | undefined, entity: unknown | undefined -) => ValidationProblem[]; +) => ValidationProblem[] | Promise; /** * Function for validating a single property. @@ -60,7 +62,7 @@ type PropertyValidationFunction = ( metadata: PropertyMetadata, parentKey: string | undefined, entity: unknown | undefined -) => ValidationProblem[]; +) => ValidationProblem[] | Promise; /** * The default validation service implementation of Zibri. @@ -106,43 +108,51 @@ export class ValidationService implements ValidationServiceInterface { }; // eslint-disable-next-line jsdoc/require-jsdoc - validateHeaderParam(param: unknown, meta: HeaderParamMetadata): void { + async validateHeaderParam(param: unknown, meta: HeaderParamMetadata): Promise { const validate: HeaderParamValidationFunction | undefined = this.headerParamValidationFunctions[meta.type]; if (validate == undefined) { throw new Error(`Unknown type for header parameter "${meta.name}": ${meta.type}`); } - const res: ValidationProblem[] = validate(param, meta, undefined, param); + const res: ValidationProblem[] = await validate(param, meta, undefined, param); if (res.length) { - throw new ValidationError('header', res); + throw new ValidationError('header', meta.name, res); } } // eslint-disable-next-line jsdoc/require-jsdoc - validatePathParam(param: unknown, meta: PathParamMetadata): void { + async validatePathParam(param: unknown, meta: PathParamMetadata): Promise { const validate: PathParamValidationFunction | undefined = this.pathParamValidationFunctions[meta.type]; if (validate == undefined) { throw new Error(`Unknown type for path parameter "${meta.name}": ${meta.type}`); } - const res: ValidationProblem[] = validate(param, meta, undefined, param); + const res: ValidationProblem[] = await validate(param, meta, undefined, param); if (res.length) { - throw new ValidationError('path', res); + throw new ValidationError('path', meta.name, res); } } // eslint-disable-next-line jsdoc/require-jsdoc - validateQueryParam(param: unknown, meta: QueryParamMetadata): void { + async validateQueryParam(param: unknown, meta: QueryParamMetadata): Promise { const validate: QueryParamValidationFunction | undefined = this.queryParamValidationFunctions[meta.type]; if (validate == undefined) { throw new Error(`Unknown type for query parameter "${meta.name}": ${meta.type}`); } - const res: ValidationProblem[] = validate(param, meta, undefined, param); + const res: ValidationProblem[] = await validate(param, meta, undefined, param); if (res.length) { - throw new ValidationError('query', res); + throw new ValidationError('query', meta.name, res); } } // eslint-disable-next-line jsdoc/require-jsdoc - validateBody(body: unknown, meta: BodyMetadata): void { + async validateBody(body: unknown, meta: BodyMetadata): Promise { + const res: ValidationProblem[] = await this.getBodyValidationProblems(body, meta); + if (res.length) { + throw new ValidationError('body', undefined, res); + } + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async getBodyValidationProblems(body: unknown, meta: BodyMetadata): Promise { // eslint-disable-next-line jsdoc/require-jsdoc class Temp implements OmitStrict, 'cleanup'> { // eslint-disable-next-line jsdoc/require-jsdoc @@ -154,91 +164,63 @@ export class ValidationService implements ValidationServiceInterface { } const cls: Newable = meta.type === MimeType.FORM_DATA ? Temp : meta.modelClass; - let res: ValidationProblem[]; if (meta.isArray) { if (!Array.isArray(body)) { - throw new ValidationError('body', [new TypeMismatchValidationProblem('body', 'array')]); + return [new TypeMismatchValidationProblem('body', 'array')]; } - res = body.reduce((prev, curr, i) => [ - ...prev, - ...this.validateModel(curr, cls, `[${i}]`, meta.allowAdditionalProperties) - ], []); - } - else { - res = this.validateModel(body, cls, undefined, meta.allowAdditionalProperties); - } - if (res.length) { - throw new ValidationError('body', res); + const res: ValidationProblem[] = []; + await Promise.all(body.map(async (v, i) => { + res.push(...await this.validateModel(v, cls, `[${i}]`, meta.allowAdditionalProperties)); + })); + return res; } + + return await this.validateModel(body, cls, undefined, meta.allowAdditionalProperties); } // eslint-disable-next-line jsdoc/require-jsdoc - validateWebsocketRequest(req: unknown): void { - const res: ValidationProblem[] = this.validateModel(req, WebsocketRequest, undefined, false); + async validateWebsocketRequest(req: unknown): Promise { + const res: ValidationProblem[] = await this.validateModel(req, WebsocketRequest, undefined, false); if (res.length) { - throw new ValidationError('websocketRequest', res); + throw new ValidationError('websocketRequest', undefined, res); } - - // // validate query - // for (const key in req.query) { - // if (typeof req.query[key] != 'string' || typeof req.query[key] != 'undefined') { - // res.push({ key, message: 'needs to be a string or undefined' }); - // } - // } - // // validate headers - // for (const key in req.headers) { - // if (!isKnownHeader(key)) { - // res.push({ key, message: 'this key is not a known header' }); - // } - // else if (typeof req.headers[key] != 'string' || typeof req.headers[key] != 'undefined') { - // res.push({ key, message: 'needs to be a string or undefined' }); - // } - // } - // // validate params - // for (const key in req.params) { - // if (typeof req.params[key] != 'string' || typeof req.params[key] != 'undefined') { - // res.push({ key, message: 'needs to be a string or undefined' }); - // } - // } - // if (res.length) { - // throw new ValidationError('websocketRequest', res); - // } } - private validateModel( + private async validateModel( body: unknown, cls: Newable, parentKey: string | undefined, allowAdditionalProperties: boolean - ): ValidationProblem[] { + ): Promise { const modelProperties: Record = MetadataUtilities.getModelProperties(cls); const keysOfBody: string[] = ObjectUtilities.keys(body as Record); const keysOfModel: string[] = ObjectUtilities.keys(modelProperties); const unknownKeys: string[] = keysOfBody.filter(k => !keysOfModel.includes(k)); const res: ValidationProblem[] = []; - for (const key of unknownKeys) { - if (allowAdditionalProperties) { - continue; + if (!allowAdditionalProperties) { + for (const key of unknownKeys) { + const fullKey: string = parentKey ? `${parentKey}.${key}` : key; + res.push({ key: fullKey, message: 'this key is unknown' }); } - const fullKey: string = parentKey ? `${parentKey}.${key}` : key; - res.push({ key: fullKey, message: 'this key is unknown' }); - } - for (const [propertyKey, metadata] of ObjectUtilities.entries(modelProperties)) { - const property: unknown = (body as Record)[propertyKey]; - const errors: ValidationProblem[] = this.validateProperty(propertyKey, property, metadata, parentKey, body); - res.push(...errors); } + await Promise.all( + keysOfModel.map(async k => { + const property: unknown = (body as Record)[k]; + const errors: ValidationProblem[] = await this.validateProperty(k, property, modelProperties[k], parentKey, body); + res.push(...errors); + }) + ); return res; } - private validateProperty( + private async validateProperty( key: string, property: unknown, metadata: PropertyMetadata, parentKey: string | undefined, entity: unknown | undefined - ): ValidationProblem[] { + ): Promise { const fullKey: string = parentKey ? `${parentKey}.${key}` : key; if ( @@ -254,23 +236,25 @@ export class ValidationService implements ValidationServiceInterface { if (validate == undefined) { throw new Error(`Unknown type for property "${fullKey}": ${metadata.type}`); } - const res: ValidationProblem[] = validate(key, property, metadata, parentKey, entity); + const res: ValidationProblem[] = await validate(key, property, metadata, parentKey, entity); return res; } - private validateArrayProperty( + private async validateArrayProperty( key: string, property: unknown, metadata: PropertyMetadata | QueryParamMetadata | HeaderParamMetadata | PathParamMetadata, parentKey: string | undefined, entity: unknown | undefined - ): ValidationProblem[] { + ): Promise { if (metadata.type !== 'array') { throw new Error('Tried to do array based validation on a non array value.'); } const fullKey: string = parentKey ? `${parentKey}.${key}` : key; - const required: boolean = typeof metadata.required === 'boolean' ? metadata.required : metadata.required(entity); + const required: boolean = typeof metadata.required === 'boolean' + ? metadata.required + : await metadata.required(entity, inject(ZIBRI_DI_TOKENS.CURRENT_REQUEST_CONTEXT)); if (property == undefined && required) { return [new IsRequiredValidationProblem(fullKey)]; } @@ -282,27 +266,35 @@ export class ValidationService implements ValidationServiceInterface { } const res: ValidationProblem[] = []; - for (let i: number = 0; i < property.length; i++) { + await Promise.all(property.map(async (v, i) => { const item: unknown = property[i]; - const errors: ValidationProblem[] = this.validateProperty(String(i), item, metadata.items as PropertyMetadata, key, entity); + const errors: ValidationProblem[] = await this.validateProperty( + String(i), + item, + metadata.items as PropertyMetadata, + key, + entity + ); res.push(...errors); - } + })); return res; } - private validateObjectProperty( + private async validateObjectProperty( key: string, property: unknown, metadata: PropertyMetadata | QueryParamMetadata | HeaderParamMetadata | PathParamMetadata, parentKey: string | undefined, entity: unknown | undefined - ): ValidationProblem[] { + ): Promise { if (metadata.type !== 'object') { throw new Error('Tried to do object based validation on a non object value.'); } const fullKey: string = parentKey ? `${parentKey}.${key}` : key; - const required: boolean = typeof metadata.required === 'boolean' ? metadata.required : metadata.required(entity); + const required: boolean = typeof metadata.required === 'boolean' + ? metadata.required + : await metadata.required(entity, inject(ZIBRI_DI_TOKENS.CURRENT_REQUEST_CONTEXT)); if (property == undefined && required) { return [new IsRequiredValidationProblem(fullKey)]; } @@ -326,15 +318,17 @@ export class ValidationService implements ValidationServiceInterface { res.push({ key: k, message: 'this key is unknown' }); } if (res.length) { - throw new ValidationError('body', res); + throw new ValidationError('body', undefined, res); } } - for (const [propertyKey, m] of ObjectUtilities.entries(objectProperties)) { - const childProperty: unknown = (property as Record)[propertyKey]; - const errors: ValidationProblem[] = this.validateProperty(propertyKey, childProperty, m, key, entity); - res.push(...errors); - } + await Promise.all( + ObjectUtilities.entries(objectProperties).map(async ([propertyKey, m]) => { + const childProperty: unknown = (property as Record)[propertyKey]; + const errors: ValidationProblem[] = await this.validateProperty(propertyKey, childProperty, m, key, entity); + res.push(...errors); + }) + ); return res; } } \ No newline at end of file diff --git a/src/websocket/services/websocket.service.ts b/src/websocket/services/websocket.service.ts index 060d1e2..32ce44b 100644 --- a/src/websocket/services/websocket.service.ts +++ b/src/websocket/services/websocket.service.ts @@ -4,6 +4,8 @@ import { WebsocketSendData, WebsocketSendDataMessage, WebsocketSendToAllData, We import { ZibriApplication } from '../../application'; import { type AuthServiceInterface } from '../../auth/auth-service.interface'; import { BaseUser } from '../../auth/models/base-user.model'; +import { AlsUtilities } from '../../context/als.utilities'; +import { WebsocketRequestContext } from '../../context/request/websocket-request.context'; import { WhereFilter } from '../../data-source/models/where/where-filter.model'; import { Repository } from '../../data-source/repository'; import { InjectRepository } from '../../di/decorators/inject-repository.decorator'; @@ -29,7 +31,6 @@ import { MetadataUtilities } from '../../utilities/metadata.utilities'; import { UUIDUtilities } from '../../utilities/uuid.utilities'; import { type ValidationServiceInterface } from '../../validation/validation-service.interface'; import { WebsocketControllerData } from '../decorators/websocket-controller.decorator'; -import { BaseWebsocketConnection } from '../models/connection/base-websocket-connection.model'; import { SocketIOWebsocketConnection } from '../models/connection/socket-io-websocket-connection.model'; import { WebsocketChannel } from '../models/websocket-channel.model'; import { WebsocketControllerRouteConfiguration } from '../models/websocket-controller-route-configuration.model'; @@ -99,14 +100,17 @@ export class WebsocketService implements WebsocketServiceInterface { - const websocketRequest: WebsocketRequest = { + const request: WebsocketRequest = { headers: socket.handshake.headers as Partial>, body: undefined, query: socket.handshake.query as Partial>, params: {} }; + let connection: SocketIOWebsocketConnection | undefined = this.connections.find(c => c.id === socket.id); + + const context: WebsocketRequestContext = new WebsocketRequestContext(request, connection, undefined, undefined); const currentUser: BaseUser | undefined = await this.authService.getCurrentUser( - websocketRequest, + context, this.authService.strategies, false ); @@ -117,7 +121,6 @@ export class WebsocketService implements WebsocketServiceInterface c.id === socket.id); if (connection) { connection.offset = socket.handshake.auth.offset as number; await this.recoverConnection(connection, currentUser); @@ -181,7 +184,7 @@ export class WebsocketService implements WebsocketServiceInterface { - try { - await this.authService.checkAccess(controllerClass, route.controllerMethod, req); - const controller: unknown = inject(controllerClass); - const params: unknown[] = await this.resolveRouteParams( - controllerClass, - route.controllerMethod, - // eslint-disable-next-line typescript/no-unsafe-member-access, typescript/no-explicit-any - ((controller as any)[route.controllerMethod] as Function).length, - req, - connection - ); - - // eslint-disable-next-line typescript/no-unsafe-call, typescript/no-explicit-any, typescript/no-unsafe-member-access - const res: unknown = await ((controller as any)[route.controllerMethod] as Function)(...params) as unknown; - await this.send({ - connection, - event: WebsocketEvent.RESPONSE, - expectResponse: true, - message: { - ok: true, - data: res, - senderConnectionId: undefined, - senderUserId: undefined - }, - persist: false, - responseHandler: ack - }); - } - catch (error) { - const err: HttpError = toHttpError(error); - await this.send({ - connection, - event: WebsocketEvent.RESPONSE, - message: { - ok: false, - error: err, - status: err.status, - senderUserId: undefined, - senderConnectionId: undefined - }, - responseHandler: ack, - expectResponse: true, - persist: !isHttpError(error) || error.status >= 500 - }); - if (err.status === HttpStatus.UNAUTHORIZED) { - this.disconnect(connection, true); + const context: WebsocketRequestContext = new WebsocketRequestContext(req, connection, controllerClass, route.controllerMethod); + await AlsUtilities.runWithWebsocketRequestContext(context, async () => { + try { + await this.authService.checkAccess(controllerClass, route.controllerMethod, context); + const controller: unknown = inject(controllerClass); + const params: unknown[] = await this.resolveRouteParams( + controllerClass, + route.controllerMethod, + // eslint-disable-next-line typescript/no-unsafe-member-access, typescript/no-explicit-any + ((controller as any)[route.controllerMethod] as Function).length, + context + ); + + // eslint-disable-next-line typescript/no-unsafe-call, typescript/no-explicit-any, typescript/no-unsafe-member-access + const res: unknown = await ((controller as any)[route.controllerMethod] as Function)(...params) as unknown; + await this.send({ + connection, + event: WebsocketEvent.RESPONSE, + expectResponse: true, + message: { + ok: true, + data: res, + senderConnectionId: undefined, + senderUserId: undefined + }, + persist: false, + responseHandler: ack + }); } - if (err.status >= 500) { - const globalError: Error = new Error('Global Error', { cause: error }); - globalError.stack = undefined; - await this.logger.error(globalError); + catch (error) { + const err: HttpError = toHttpError(error); + await this.send({ + connection, + event: WebsocketEvent.RESPONSE, + message: { + ok: false, + error: err, + status: err.status, + senderUserId: undefined, + senderConnectionId: undefined + }, + responseHandler: ack, + expectResponse: true, + persist: !isHttpError(error) || error.status >= 500 + }); + if (err.status === HttpStatus.UNAUTHORIZED) { + this.disconnect(connection, true); + } + if (err.status >= 500) { + const globalError: Error = new Error('Global Error', { cause: error }); + globalError.stack = undefined; + await this.logger.error(globalError); + } } - } + }); }; return handler; } @@ -639,18 +644,13 @@ export class WebsocketService implements WebsocketServiceInterface, controllerMethod: string, totalParamCount: number, - req: WebsocketRequest, - connection: BaseWebsocketConnection + context: WebsocketRequestContext ): Promise { return await resolveRouteParams( controllerClass, controllerMethod, totalParamCount, - req, - this.parser, - this.validationService, - this.authService, - connection + context ); } } \ No newline at end of file diff --git a/tsconfig.cjs.json b/tsconfig.cjs.json index 6f1b0ec..7315eeb 100644 --- a/tsconfig.cjs.json +++ b/tsconfig.cjs.json @@ -1,7 +1,7 @@ { "compilerOptions": { "module": "commonjs", - "moduleResolution": "node", + // "moduleResolution": "", "outDir": "dist/cjs" }, "extends": "./tsconfig.base.json" diff --git a/tsconfig.esm.json b/tsconfig.esm.json index 918cb26..71f1e20 100644 --- a/tsconfig.esm.json +++ b/tsconfig.esm.json @@ -2,8 +2,8 @@ "compilerOptions": { "declaration": false, "declarationMap": false, - "module": "esnext", - "moduleResolution": "node", + "module": "nodenext", + "moduleResolution": "nodenext", "outDir": "dist/esm" }, "extends": "./tsconfig.base.json" From a07e38dfebebbd905280af0e9ec7998301023291 Mon Sep 17 00:00:00 2001 From: Tim Fabian Date: Mon, 20 Apr 2026 01:41:29 +0200 Subject: [PATCH 2/6] added some global default settings, added cookie auth strategy --- cspell.words.txt | 5 +- package-lock.json | 38 +- package.json | 4 +- sandbox/src/controllers/metrics.controller.ts | 3 +- sandbox/src/controllers/page.controller.ts | 6 +- .../src/controllers/template.controller.ts | 3 +- sandbox/src/cron/status.cron-job.ts | 4 +- sandbox/src/providers.ts | 4 +- sandbox/src/repositories/user.repository.ts | 2 +- src/__testing__/test-server/providers.ts | 4 +- .../test-server/user-repository.ts | 2 +- src/application-options.model.ts | 39 ++ src/application.ts | 62 +- src/assets/asset.service.ts | 6 - src/auth/auth.service.ts | 5 +- .../strategies/auth-strategy.interface.ts | 5 + ...-auth-confirm-password-reset-data.model.ts | 23 + .../cookie/cookie-auth-credentials.model.ts | 50 ++ .../cookie/cookie-auth-data.model.ts | 32 + .../cookie/cookie-auth-logout-data.model.ts | 22 + .../cookie-auth-refresh-login-data.model.ts | 11 + .../cookie-auth-refresh-session.model.ts | 46 ++ ...-auth-request-password-reset-data.model.ts | 28 + .../cookie-auth-session-cleanup.cron-job.ts | 35 ++ .../cookie/cookie-auth-session.model.ts | 34 ++ .../cookie/cookie-auth.auth-strategy.ts | 561 ++++++++++++++++++ .../cookie/cookie-auth.controller.ts | 182 ++++++ .../strategies/jwt/jwt-auth.controller.ts | 52 +- .../jwt-confirm-password-reset-data.model.ts | 5 + .../jwt/jwt-refresh-login-data.model.ts | 5 + .../jwt/jwt-refresh-token-cleanup.cron-job.ts | 31 + .../jwt-request-password-reset-data.model.ts | 7 +- src/auth/strategies/jwt/jwt.auth-strategy.ts | 105 ++-- src/change-sets/change-set-repository.ts | 5 +- src/change-sets/soft-delete-repository.ts | 5 +- src/context/base-context.ts | 2 + src/context/request/http-request.context.ts | 7 +- .../request/request-context-token.model.ts | 17 +- .../request/websocket-request.context.ts | 4 +- src/cron/cron-expression.utilities.ts | 238 ++++++++ src/cron/cron-job-entity.model.ts | 3 +- src/cron/cron-job.model.ts | 21 +- src/cron/cron-service.interface.ts | 3 +- src/cron/cron.service.ts | 8 +- .../data-sources/data-source.interface.ts | 12 +- .../postgres-data-source.model.ts | 11 +- src/data-source/repository.ts | 22 +- src/di/default/zibri-di-providers.default.ts | 54 +- src/di/default/zibri-di-tokens.default.ts | 16 +- src/di/di-container.ts | 12 +- src/email/email.service.ts | 4 +- src/email/send-queued-emails.cron-job.ts | 3 +- src/event/event-cleanup.cron-job.ts | 3 +- src/event/event.service.ts | 4 +- src/http/cookie-options.model.ts | 11 + src/http/http-request.model.ts | 9 +- src/http/known-header.enum.ts | 6 +- src/http/mime-type.enum.ts | 57 +- src/http/mime-type.helpers.ts | 119 +++- src/index.ts | 22 + src/logging/log-cleanup.cron-job.ts | 5 +- src/logging/logger.ts | 7 +- ...ron-job.ts => collect-metrics.cron-job.ts} | 11 +- src/metrics/metrics.service.ts | 6 +- src/parsing/form-data/file-response.model.ts | 27 +- .../form-data-body-parser-cleanup.cron-job.ts | 3 +- .../form-data/form-data.body-parser.ts | 4 +- src/parsing/html/csp-options.model.ts | 109 ++++ src/parsing/html/html-response.model.ts | 30 +- .../mailing-list/mailing-list.controller.ts | 15 +- src/preact/collector.ts | 40 +- src/preact/preact.utilities.ts | 36 +- src/routing/router.ts | 161 +++-- src/types/deep-partial.type.ts | 23 +- src/types/percentage.type.ts | 2 +- src/utilities/ms.ts | 8 + 76 files changed, 2265 insertions(+), 321 deletions(-) create mode 100644 src/auth/strategies/cookie/cookie-auth-confirm-password-reset-data.model.ts create mode 100644 src/auth/strategies/cookie/cookie-auth-credentials.model.ts create mode 100644 src/auth/strategies/cookie/cookie-auth-data.model.ts create mode 100644 src/auth/strategies/cookie/cookie-auth-logout-data.model.ts create mode 100644 src/auth/strategies/cookie/cookie-auth-refresh-login-data.model.ts create mode 100644 src/auth/strategies/cookie/cookie-auth-refresh-session.model.ts create mode 100644 src/auth/strategies/cookie/cookie-auth-request-password-reset-data.model.ts create mode 100644 src/auth/strategies/cookie/cookie-auth-session-cleanup.cron-job.ts create mode 100644 src/auth/strategies/cookie/cookie-auth-session.model.ts create mode 100644 src/auth/strategies/cookie/cookie-auth.auth-strategy.ts create mode 100644 src/auth/strategies/cookie/cookie-auth.controller.ts create mode 100644 src/auth/strategies/jwt/jwt-refresh-token-cleanup.cron-job.ts create mode 100644 src/cron/cron-expression.utilities.ts create mode 100644 src/http/cookie-options.model.ts rename src/metrics/{scrape-metrics.cron-job.ts => collect-metrics.cron-job.ts} (70%) create mode 100644 src/parsing/html/csp-options.model.ts diff --git a/cspell.words.txt b/cspell.words.txt index bcd2c0a..5659b7d 100644 --- a/cspell.words.txt +++ b/cspell.words.txt @@ -12,4 +12,7 @@ pg_dumpall PGPASSWORD psql esnext -SEPA \ No newline at end of file +SEPA +hsts +nosniff +csrf \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index c81d66f..aa1cca5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "@jest/globals": "^30.3.0", "@swc/core": "^1.15.24", "@testcontainers/postgresql": "^11.14.0", + "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/jsonwebtoken": "^9.0.10", @@ -53,6 +54,7 @@ "axios": "^1.15.0", "bcryptjs": "^3.0.3", "bignumber.js": "^10.0.2", + "cookie-parser": "^1.4.7", "handlebars": "^4.7.9", "hi-base32": "^0.5.1", "jsonwebtoken": "^9.0.3", @@ -3247,6 +3249,16 @@ "@types/node": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", + "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -3299,6 +3311,7 @@ "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -5637,6 +5650,25 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, "node_modules/cookie-signature": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", @@ -12624,9 +12656,9 @@ } }, "node_modules/protobufjs": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", - "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz", + "integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==", "dev": true, "hasInstallScript": true, "license": "BSD-3-Clause", diff --git a/package.json b/package.json index a5dfa05..8afd254 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,8 @@ "socket.io": "^4.8.3", "ts-node": "^10.9.2", "uuid": "^11.1.0", - "xmlbuilder2": "^4.0.3" + "xmlbuilder2": "^4.0.3", + "cookie-parser": "^1.4.7" }, "dependencies": { "@fastify/busboy": "^3.2.0", @@ -85,6 +86,7 @@ "@jest/globals": "^30.3.0", "@swc/core": "^1.15.24", "@testcontainers/postgresql": "^11.14.0", + "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/jsonwebtoken": "^9.0.10", diff --git a/sandbox/src/controllers/metrics.controller.ts b/sandbox/src/controllers/metrics.controller.ts index 71fa25a..59747c3 100644 --- a/sandbox/src/controllers/metrics.controller.ts +++ b/sandbox/src/controllers/metrics.controller.ts @@ -19,6 +19,7 @@ export class MetricsController { @Get('/dashboard') async dashboard(): Promise { const version: string = GlobalRegistry.getAppData('version') ?? '-'; - return await PreactUtilities.renderResponse(MetricsPage, { version, primary: '#0e456f', secondary: '#00b4d8' }); + const html: string = await PreactUtilities.renderPage(MetricsPage, { version, primary: '#0e456f', secondary: '#00b4d8' }); + return HtmlResponse.fromString(html); } } \ No newline at end of file diff --git a/sandbox/src/controllers/page.controller.ts b/sandbox/src/controllers/page.controller.ts index a2b4023..230866b 100644 --- a/sandbox/src/controllers/page.controller.ts +++ b/sandbox/src/controllers/page.controller.ts @@ -9,7 +9,8 @@ export class PageController { @Response.html() @Get() async index(): Promise { - return await PreactUtilities.renderResponse(HomePage, { appName: GlobalRegistry.getAppData('name') ?? '' }); + const html: string = await PreactUtilities.renderPage(HomePage, { appName: GlobalRegistry.getAppData('name') ?? '' }); + return HtmlResponse.fromString(html); } @Response.html() @@ -17,6 +18,7 @@ export class PageController { async assets(): Promise { const assetService: AssetServiceInterface = inject(ZIBRI_DI_TOKENS.ASSET_SERVICE); const nodes: TreeNode[] = await assetService.buildFileTree(); - return PreactUtilities.renderResponse(AssetsPage, { nodes }); + const html: string = await PreactUtilities.renderPage(AssetsPage, { nodes }); + return HtmlResponse.fromString(html); } } \ No newline at end of file diff --git a/sandbox/src/controllers/template.controller.ts b/sandbox/src/controllers/template.controller.ts index 400c871..10681d9 100644 --- a/sandbox/src/controllers/template.controller.ts +++ b/sandbox/src/controllers/template.controller.ts @@ -10,7 +10,8 @@ export class TemplateController { @Response.html() @Get('/socket') async socketIo(): Promise { - return await PreactUtilities.renderResponse(SocketIoTestPage, { primary: '#0e456f', secondary: '#00b4d8' }); + const html: string = await PreactUtilities.renderPage(SocketIoTestPage, { primary: '#0e456f', secondary: '#00b4d8' }); + return HtmlResponse.fromString(html); } @Response.html() diff --git a/sandbox/src/cron/status.cron-job.ts b/sandbox/src/cron/status.cron-job.ts index a3d6318..083de9e 100644 --- a/sandbox/src/cron/status.cron-job.ts +++ b/sandbox/src/cron/status.cron-job.ts @@ -1,9 +1,9 @@ -import { CronJob, inject, LoggerInterface, ZIBRI_DI_TOKENS, InitialCronConfig } from 'zibri'; +import { CronJob, inject, LoggerInterface, ZIBRI_DI_TOKENS, InitialCronConfig, CronExpression } from 'zibri'; export class StatusCronJob extends CronJob { readonly initialConfig: InitialCronConfig = { name: 'Status', - cron: '* * * * * *', + cron: CronExpression.every(1, 'seconds').build(), active: false }; diff --git a/sandbox/src/providers.ts b/sandbox/src/providers.ts index 4b6b112..cf11184 100644 --- a/sandbox/src/providers.ts +++ b/sandbox/src/providers.ts @@ -18,7 +18,7 @@ export const providers: DiProvider[] = [ useFactory: () => [LoggerTransport.console(LogLevel.INFO)] }), defineProvider({ - token: ZIBRI_DI_TOKENS.JWT_PASSWORD_RESET_EMAIL_TEMPLATE, + token: ZIBRI_DI_TOKENS.PASSWORD_RESET_EMAIL_TEMPLATE, useFactory: () => PasswordResetEmail }), defineProvider({ @@ -45,7 +45,7 @@ export const providers: DiProvider[] = [ } }), defineProvider({ - token: ZIBRI_DI_TOKENS.JWT_CONFIRM_PASSWORD_RESET_URL, + token: ZIBRI_DI_TOKENS.CONFIRM_PASSWORD_RESET_URL, useFactory: () => 'http://localhost:4200/confirm-password-reset' }), defineProvider({ diff --git a/sandbox/src/repositories/user.repository.ts b/sandbox/src/repositories/user.repository.ts index add057f..1222738 100644 --- a/sandbox/src/repositories/user.repository.ts +++ b/sandbox/src/repositories/user.repository.ts @@ -12,7 +12,7 @@ export class UserRepository extends Repository @Inject(ZIBRI_DI_TOKENS.LOGGER) logger: LoggerInterface ) { - super(User, repo, logger); + super(User, repo, logger, repo.dataSource); } async findByEmail(email: string): Promise { diff --git a/src/__testing__/test-server/providers.ts b/src/__testing__/test-server/providers.ts index b2bc56b..b8f7945 100644 --- a/src/__testing__/test-server/providers.ts +++ b/src/__testing__/test-server/providers.ts @@ -15,11 +15,11 @@ export const defaultTestServerProviders: DiProvider[] = [ useFactory: () => 'test' }), defineProvider({ - token: ZIBRI_DI_TOKENS.JWT_CONFIRM_PASSWORD_RESET_URL, + token: ZIBRI_DI_TOKENS.CONFIRM_PASSWORD_RESET_URL, useFactory: () => 'http://localhost:4200/confirm-password-reset' }), defineProvider({ - token: ZIBRI_DI_TOKENS.JWT_PASSWORD_RESET_EMAIL_TEMPLATE, + token: ZIBRI_DI_TOKENS.PASSWORD_RESET_EMAIL_TEMPLATE, // eslint-disable-next-line typescript/no-explicit-any useValue: (() => 'string') as unknown as PasswordResetEmailTemplate }), diff --git a/src/__testing__/test-server/user-repository.ts b/src/__testing__/test-server/user-repository.ts index b0d6c9f..98851b5 100644 --- a/src/__testing__/test-server/user-repository.ts +++ b/src/__testing__/test-server/user-repository.ts @@ -21,7 +21,7 @@ export class DefaultTestServerUserRepository extends Repository @InjectRepository(JwtUser) private readonly credentialsRepository: Repository ) { - super(JwtUser, repo, logger); + super(JwtUser, repo, logger, repo.dataSource); } async findByEmail(email: string): Promise { diff --git a/src/application-options.model.ts b/src/application-options.model.ts index d495f1b..93f0424 100644 --- a/src/application-options.model.ts +++ b/src/application-options.model.ts @@ -3,11 +3,46 @@ import { AuthStrategies } from './auth/strategies/auth-strategies.model'; import { CronJob } from './cron/cron-job.model'; import { DataSourceInterface } from './data-source/data-sources/data-source.interface'; import { DiProvider } from './di/models/di-provider.model'; +import { KnownHeader } from './http/known-header.enum'; import { BodyParserInterface } from './parsing/body-parser.interface'; import { ZibriPlugin } from './plugin/plugin.model'; +import { DeepPartial } from './types/deep-partial.type'; import { Newable } from './types/newable.type'; import { Version } from './types/version.type'; +/** + * Configuration options for strict transport security / hsts. + */ +export type HstsOptions = { + /** + * The maximum age for which strict transport should be enabled. Defaults to 2 years. + */ + maxAgeSeconds: number, + /** + * Whether or not subdomains should be included. Defaults to true. + */ + includeSubDomains: boolean, + /** + * Whether or not this url should be put on a preload list. Defaults to false. + */ + preload: boolean +}; + +/** + * The global security options for a zibri application. + */ +export type ZibriApplicationSecurityOptions = { + /** + * Some headers to set. + */ + headers: { + /** + * Configuration for the strict transport security / hsts header. + */ + [KnownHeader.STRICT_TRANSPORT_SECURITY]: boolean | HstsOptions + } +}; + /** * All options for a Zibri application. */ @@ -35,6 +70,10 @@ export type ZibriApplicationOptions = { * The websocket controllers to register in the app. */ websocketControllers: Newable[], + /** + * Globals security settings, like eg. Hsts. + */ + security?: DeepPartial, /** * The data sources to register in the app. */ diff --git a/src/application.ts b/src/application.ts index e342843..a693319 100644 --- a/src/application.ts +++ b/src/application.ts @@ -1,10 +1,11 @@ import { createServer, Server } from 'node:http'; import { AddressInfo } from 'node:net'; +import cookieParser from 'cookie-parser'; import cors from 'cors'; import express, { RequestHandler } from 'express'; -import { ZibriApplicationOptions } from './application-options.model'; +import { HstsOptions, ZibriApplicationOptions, ZibriApplicationSecurityOptions } from './application-options.model'; import { OtpTwoFactorMethod } from './auth/2fa/methods/otp/otp.two-factor-method'; import { isTwoFactorMethod } from './auth/2fa/methods/two-factor-method.interface'; import { isAuthStrategy } from './auth/strategies/auth-strategy.interface'; @@ -28,24 +29,37 @@ import { implementsOnAppInit } from './global/on-app-init.interface'; import { implementsOnAppShutdown, OnAppShutdown } from './global/on-app-shutdown.interface'; import { implementsOnAppStart } from './global/on-app-start.interface'; import { HandlebarUtilities } from './handlebars/handlebar.utilities'; +import { KnownHeader } from './http/known-header.enum'; import { LoggerInterface } from './logging/logger.interface'; import { FormDataBodyParser } from './parsing/form-data/form-data.body-parser'; import { JsonBodyParser } from './parsing/json/json.body-parser'; import { ZibriPlugin } from './plugin/plugin.model'; import { Route } from './routing/controller-route-configuration.model'; +import { DeepPartial } from './types/deep-partial.type'; import { OmitStrict } from './types/omit-strict.type'; +import { BigNumberUtilities } from './utilities/big-number.utilities'; import { FsUtilities } from './utilities/fs.utilities'; import { Ms } from './utilities/ms'; import { PromiseUtilities } from './utilities/promise.utilities'; // eslint-disable-next-line jsdoc/require-jsdoc -type FullZibriApplicationOptions = Required>; +type FullZibriApplicationOptions = Required> & { + // eslint-disable-next-line jsdoc/require-jsdoc + security: ZibriApplicationSecurityOptions +}; // eslint-disable-next-line typescript/typedef const SHUTDOWN_SIGNALS = ['SIGTERM', 'SIGINT', 'SIGHUP'] as const; const DEFAULT_SHUTDOWN_TIMEOUT_IN_MS: number = Ms.SECOND * 30; +const defaultHstsOptions: HstsOptions = { + maxAgeSeconds: BigNumberUtilities.multiply(Ms.YEAR, 2).dividedBy(1000) + .toNumber(), + includeSubDomains: true, + preload: false +}; + /** * A os signal that triggers the shutdown of a Zibri application. */ @@ -81,6 +95,29 @@ export class ZibriApplication { constructor(private readonly providedOptions: ZibriApplicationOptions) { this.options = this.createFullOptions(); + this.use((req, res, next) => { + res.setHeader(KnownHeader.X_CONTENT_TYPE_OPTIONS, 'nosniff'); + res.setHeader(KnownHeader.REFERRER_POLICY, 'strict-origin-when-cross-origin'); + + const hsts: boolean | HstsOptions = this.options.security.headers[KnownHeader.STRICT_TRANSPORT_SECURITY]; + const isSecure: boolean = req.secure || req.headers[KnownHeader.X_FORWARDED_PROTO] === 'https'; + if (isSecure && hsts !== false) { + const options: HstsOptions = hsts === true + ? defaultHstsOptions + : hsts; + res.setHeader( + KnownHeader.STRICT_TRANSPORT_SECURITY, + [ + `max-age=${options.maxAgeSeconds}`, + ...options.includeSubDomains ? ['includeSubDomains'] : [], + ...options.preload ? ['preload'] : [] + ].join('; ') + ); + } + + next(); + }); + for (const signal of SHUTDOWN_SIGNALS) { const handler: () => void = () => void this.shutdown(signal); this.signalHandlers.set(signal, handler); @@ -131,6 +168,12 @@ export class ZibriApplication { this.validateInjectables(injectables); await this.onAppInit(injectables); + + const secret: string | undefined = inject(ZIBRI_DI_TOKENS.COOKIE_SIGN_SECRET); + if (secret) { + this.use(cookieParser(secret)); + } + await this.afterAppInit(injectables); for (const controller of this.options.controllers) { @@ -356,6 +399,9 @@ export class ZibriApplication { } private createFullOptions(): FullZibriApplicationOptions { + // eslint-disable-next-line stylistic/max-len + const hsts: DeepPartial | boolean | undefined = this.providedOptions.security?.headers?.[KnownHeader.STRICT_TRANSPORT_SECURITY]; + const res: FullZibriApplicationOptions = { dataSources: [], authStrategies: [], @@ -363,7 +409,17 @@ export class ZibriApplication { bodyParsers: [], providers: [], cronJobs: [], - ...this.providedOptions + ...this.providedOptions, + security: { + headers: { + [KnownHeader.STRICT_TRANSPORT_SECURITY]: typeof hsts === 'boolean' + ? hsts + : { + ...defaultHstsOptions, + ...hsts + } + } + } }; for (const plugin of this.providedOptions.plugins ?? []) { // TODO: handle order of plugin initialization so that everything is available for DI inside the plugin constructor. diff --git a/src/assets/asset.service.ts b/src/assets/asset.service.ts index 18c0f69..0b74951 100644 --- a/src/assets/asset.service.ts +++ b/src/assets/asset.service.ts @@ -61,12 +61,6 @@ export class AssetService implements AssetServiceInterface, OnAppInit { handler: () => FileResponse.fromPath(FsUtilities.getPath(this.publicAssetsPath, 'favicon.png')) }); - // await app.router.registerRoute({ - // httpMethod: HttpMethod.GET, - // route: '/favicon.ico', - // handler: () => FileResponse.fromPath(FsUtilities.getPath(this.publicAssetsPath, 'favicon.png')) - // }); - } // eslint-disable-next-line jsdoc/require-jsdoc diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 4ecedb3..5fb136b 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -42,8 +42,8 @@ export class AuthService implements AuthServiceInterface, OnAppInit { ) {} // eslint-disable-next-line jsdoc/require-jsdoc - async onAppInit({ options }: ZibriApplication): Promise { - const { authStrategies } = options; + async onAppInit(app: ZibriApplication): Promise { + const { authStrategies } = app.options; for (const strategy of authStrategies) { register({ token: strategy, useClass: strategy }); this.strategies.push(strategy); @@ -54,6 +54,7 @@ export class AuthService implements AuthServiceInterface, OnAppInit { ); for (const strategy of authStrategies) { await this.logger.info(` - ${strategy.name}`); + await inject(strategy).init?.(app); } } } diff --git a/src/auth/strategies/auth-strategy.interface.ts b/src/auth/strategies/auth-strategy.interface.ts index 569c25f..f5ca9e0 100644 --- a/src/auth/strategies/auth-strategy.interface.ts +++ b/src/auth/strategies/auth-strategy.interface.ts @@ -1,4 +1,5 @@ import { AuthStrategies } from './auth-strategies.model'; +import { ZibriApplication } from '../../application'; import { HttpRequestContext } from '../../context/request/http-request.context'; import { WebsocketRequestContext } from '../../context/request/websocket-request.context'; import { BaseEntity } from '../../entity/base-entity.model'; @@ -19,6 +20,10 @@ export interface AuthStrategyInterface< RefreshLoginDataType, LogoutData > { + /** + * Initializes the auth strategy. + */ + init?: (app: ZibriApplication) => void | Promise, /** * Resolves the current user. */ diff --git a/src/auth/strategies/cookie/cookie-auth-confirm-password-reset-data.model.ts b/src/auth/strategies/cookie/cookie-auth-confirm-password-reset-data.model.ts new file mode 100644 index 0000000..01d4452 --- /dev/null +++ b/src/auth/strategies/cookie/cookie-auth-confirm-password-reset-data.model.ts @@ -0,0 +1,23 @@ +import { Transaction } from '../../../data-source/transaction/transaction.model'; +import { Property } from '../../../entity/decorators/property.decorator'; + +/** + * The data used to confirm a password reset. + */ +export class CookieAuthConfirmPasswordResetData { + /** + * The reset token value. + */ + @Property.string() + resetToken!: string; + + /** + * The new password that should be used from now on. + */ + @Property.string() + newPassword!: string; + /** + * The transaction that this should run in. + */ + transaction!: Transaction; +} \ No newline at end of file diff --git a/src/auth/strategies/cookie/cookie-auth-credentials.model.ts b/src/auth/strategies/cookie/cookie-auth-credentials.model.ts new file mode 100644 index 0000000..43ea303 --- /dev/null +++ b/src/auth/strategies/cookie/cookie-auth-credentials.model.ts @@ -0,0 +1,50 @@ +import { Transaction } from '../../../data-source/transaction/transaction.model'; +import { BaseEntity } from '../../../entity/base-entity.model'; +import { Entity } from '../../../entity/decorators/entity.decorator'; +import { Property } from '../../../entity/decorators/property.decorator'; +import { OmitClass } from '../../../entity/omit-class.model'; +import { BaseUser } from '../../models/base-user.model'; + +/** + * The credentials used by the cookie-auth auth strategy. + */ +@Entity({ allowOrphan: true }) +export class CookieAuthCredentials extends BaseEntity implements Pick, 'id' | 'email'> { + /** + * The id of the user that this credentials belong to. + */ + @Property.string({ format: 'uuid' }) + userId!: string; + + /** + * The email of the user. + */ + @Property.string({ unique: true, format: 'email' }) + email!: string; + + /** + * The password. + */ + @Property.string() + password!: string; +} + +/** + * + */ +export class CookieAuthCredentialsData extends OmitClass(CookieAuthCredentials, ['id', 'userId']) { + /** + * The transaction that this should run in. + */ + transaction!: Transaction; +} + +/** + * The actual credentials sent over http. + */ +export class CookieAuthCredentialsDto extends OmitClass(CookieAuthCredentialsData, ['transaction']) {} + +/** + * The data for creating new cookie auth credentials. + */ +export class CookieAuthCredentialsCreateData extends OmitClass(CookieAuthCredentials, ['id']) {} \ No newline at end of file diff --git a/src/auth/strategies/cookie/cookie-auth-data.model.ts b/src/auth/strategies/cookie/cookie-auth-data.model.ts new file mode 100644 index 0000000..7d82d18 --- /dev/null +++ b/src/auth/strategies/cookie/cookie-auth-data.model.ts @@ -0,0 +1,32 @@ +import { Property } from '../../../entity/decorators/property.decorator'; + +/** + * The authentication data that gets returned when logging in or refreshing the login. + */ +export class CookieAuthData { + /** + * The id of the user of the cookie. + */ + @Property.string({ format: 'uuid' }) + userId!: string; + /** + * The roles of the user. + */ + @Property.array({ items: { type: 'string' } }) + roles!: Role[]; + /** + * The token to prohibit cross site request forgery. + */ + @Property.string() + csrfToken!: string; + /** + * The expiration date of the session. + */ + @Property.date() + sessionExpirationDate!: Date; + /** + * The expiration date of the refresh session. + */ + @Property.date() + refreshSessionExpirationDate!: Date; +} \ No newline at end of file diff --git a/src/auth/strategies/cookie/cookie-auth-logout-data.model.ts b/src/auth/strategies/cookie/cookie-auth-logout-data.model.ts new file mode 100644 index 0000000..94a6132 --- /dev/null +++ b/src/auth/strategies/cookie/cookie-auth-logout-data.model.ts @@ -0,0 +1,22 @@ +import { Transaction } from '../../../data-source/transaction/transaction.model'; +import { Property } from '../../../entity/decorators/property.decorator'; + +/** + * Data for logging a user out with the cookie auth strategy. + */ +export class CookieAuthLogoutData { + /** + * Whether or not cookies should be cleared. + */ + @Property.boolean() + clearCookies!: boolean; + /** + * The id of the refresh session to logout. + */ + @Property.string({ format: 'uuid' }) + refreshSessionId!: string; + /** + * The transaction that this should run in. + */ + transaction: Transaction | undefined; +} \ No newline at end of file diff --git a/src/auth/strategies/cookie/cookie-auth-refresh-login-data.model.ts b/src/auth/strategies/cookie/cookie-auth-refresh-login-data.model.ts new file mode 100644 index 0000000..1958d56 --- /dev/null +++ b/src/auth/strategies/cookie/cookie-auth-refresh-login-data.model.ts @@ -0,0 +1,11 @@ +import { Transaction } from '../../../data-source/transaction/transaction.model'; + +/** + * Data for refreshing a login with the cookie auth strategy. + */ +export class CookieAuthRefreshLoginData { + /** + * The transaction that this should run in. + */ + transaction!: Transaction; +} \ No newline at end of file diff --git a/src/auth/strategies/cookie/cookie-auth-refresh-session.model.ts b/src/auth/strategies/cookie/cookie-auth-refresh-session.model.ts new file mode 100644 index 0000000..d661812 --- /dev/null +++ b/src/auth/strategies/cookie/cookie-auth-refresh-session.model.ts @@ -0,0 +1,46 @@ +import { BaseEntity } from '../../../entity/base-entity.model'; +import { Entity } from '../../../entity/decorators/entity.decorator'; +import { Property } from '../../../entity/decorators/property.decorator'; +import { OmitClass } from '../../../entity/omit-class.model'; + +/** + * A cookie auth refresh session. + */ +@Entity({ allowOrphan: true }) +export class CookieAuthRefreshSession extends BaseEntity { + /** + * The id of the user that this session belong to. + */ + @Property.string({ format: 'uuid' }) + userId!: string; + /** + * The expiration date of the session. + */ + @Property.date() + expirationDate!: Date; + /** + * The token to prohibit cross site request forgery. + */ + @Property.string() + csrfToken!: string; + /** + * Whether or not this session has been blacklisted. + * + * Is used for automatic reuse detection. + */ + @Property.boolean() + blacklisted!: boolean; + /** + * The id of the "family" this token belongs to. + * All tokens that belong to the same "ancestor" are considered to be in a family. + * + * Is used for automatic reuse detection. + */ + @Property.string({ format: 'uuid' }) + familyId!: string; +} + +/** + * Data for creating a new cookie auth refresh session. + */ +export class CookieAuthRefreshSessionCreateData extends OmitClass(CookieAuthRefreshSession, ['id']) {} \ No newline at end of file diff --git a/src/auth/strategies/cookie/cookie-auth-request-password-reset-data.model.ts b/src/auth/strategies/cookie/cookie-auth-request-password-reset-data.model.ts new file mode 100644 index 0000000..83fa303 --- /dev/null +++ b/src/auth/strategies/cookie/cookie-auth-request-password-reset-data.model.ts @@ -0,0 +1,28 @@ +import { Transaction } from '../../../data-source/transaction/transaction.model'; +import { QueueEmailData } from '../../../email/models/create-email-data.model'; +import { BaseUser } from '../../models/base-user.model'; + +/** + * The data used by the cookie auth strategy to request a password reset. + */ +export type CookieAuthRequestPasswordResetData> = { + /** + * The user which password should be reset. + */ + user: UserType, + /** + * Additional data for the password reset email. + */ + emailData?: Partial< + QueueEmailData & { + /** + * The url where the password reset confirmation happens. + */ + confirmPasswordResetUrl: string + } + >, + /** + * The transaction that this should run in. + */ + transaction: Transaction +}; \ No newline at end of file diff --git a/src/auth/strategies/cookie/cookie-auth-session-cleanup.cron-job.ts b/src/auth/strategies/cookie/cookie-auth-session-cleanup.cron-job.ts new file mode 100644 index 0000000..d9a7269 --- /dev/null +++ b/src/auth/strategies/cookie/cookie-auth-session-cleanup.cron-job.ts @@ -0,0 +1,35 @@ +import { CookieAuthRefreshSession } from './cookie-auth-refresh-session.model'; +import { CookieAuthSession } from './cookie-auth-session.model'; +import { CronExpression } from '../../../cron/cron-expression.utilities'; +import { CronJob, InitialCronConfig } from '../../../cron/cron-job.model'; +import { Repository } from '../../../data-source/repository'; +import { InjectRepository } from '../../../di/decorators/inject-repository.decorator'; + +/** + * A cron job to cleanup expired cookie and cookie refresh sessions. + */ +export class CookieAuthSessionCleanupCronJob extends CronJob { + // eslint-disable-next-line jsdoc/require-jsdoc + initialConfig: InitialCronConfig = { + name: 'Cleanup cookie auth sessions', + cron: CronExpression.daily().build(), + runOnInit: false + }; + + constructor( + @InjectRepository(CookieAuthSession) + private readonly sessionRepository: Repository, + @InjectRepository(CookieAuthRefreshSession) + private readonly refreshSessionRepository: Repository + ) { + super(); + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async onTick(): Promise { + await this.sessionRepository.deleteAll({ expirationDate: { before: new Date() } }); + // blacklisted refresh sessions need to stay, + // to find out if someone tries to use them a second time. + await this.refreshSessionRepository.deleteAll({ expirationDate: { before: new Date() } }); + } +} \ No newline at end of file diff --git a/src/auth/strategies/cookie/cookie-auth-session.model.ts b/src/auth/strategies/cookie/cookie-auth-session.model.ts new file mode 100644 index 0000000..0d07ecd --- /dev/null +++ b/src/auth/strategies/cookie/cookie-auth-session.model.ts @@ -0,0 +1,34 @@ +import { BaseEntity } from '../../../entity/base-entity.model'; +import { Entity } from '../../../entity/decorators/entity.decorator'; +import { Property } from '../../../entity/decorators/property.decorator'; +import { OmitClass } from '../../../entity/omit-class.model'; + +/** + * A cookie auth session. + */ +@Entity({ allowOrphan: true }) +export class CookieAuthSession extends BaseEntity { + /** + * The id of the user that this session belong to. + */ + @Property.string({ format: 'uuid' }) + userId!: string; + /** + * The expiration date of the session. + */ + @Property.date() + expirationDate!: Date; + /** + * The id of the "family" this token belongs to. + * All tokens that belong to the same "ancestor" are considered to be in a family. + * + * Is used for automatic reuse detection. + */ + @Property.string({ format: 'uuid' }) + familyId!: string; +} + +/** + * Data for creating a new cookie auth session. + */ +export class CookieAuthSessionCreateData extends OmitClass(CookieAuthSession, ['id']) {} \ No newline at end of file diff --git a/src/auth/strategies/cookie/cookie-auth.auth-strategy.ts b/src/auth/strategies/cookie/cookie-auth.auth-strategy.ts new file mode 100644 index 0000000..6263ad9 --- /dev/null +++ b/src/auth/strategies/cookie/cookie-auth.auth-strategy.ts @@ -0,0 +1,561 @@ +import { randomBytes } from 'node:crypto'; + +import { CookieAuthConfirmPasswordResetData } from './cookie-auth-confirm-password-reset-data.model'; +import { CookieAuthRefreshLoginData } from './cookie-auth-refresh-login-data.model'; +import { CookieAuthRefreshSession, CookieAuthRefreshSessionCreateData } from './cookie-auth-refresh-session.model'; +import { CookieAuthRequestPasswordResetData } from './cookie-auth-request-password-reset-data.model'; +import { CookieAuthSessionCleanupCronJob } from './cookie-auth-session-cleanup.cron-job'; +import { CookieAuthSession, CookieAuthSessionCreateData } from './cookie-auth-session.model'; +import { ZibriApplication } from '../../../application'; +import { HttpRequestContext } from '../../../context/request/http-request.context'; +import { WebsocketRequestContext } from '../../../context/request/websocket-request.context'; +import { Repository } from '../../../data-source/repository'; +import { Transaction } from '../../../data-source/transaction/transaction.model'; +import { InjectRepository, repositoryTokenFor } from '../../../di/decorators/inject-repository.decorator'; +import { Inject } from '../../../di/decorators/inject.decorator'; +import { ZIBRI_DI_TOKENS } from '../../../di/default/zibri-di-tokens.default'; +import { NoProviderError } from '../../../di/errors/no-provider.error'; +import { inject } from '../../../di/inject.function'; +import { type EmailServiceInterface } from '../../../email/email-service.interface'; +import { EmailPriority } from '../../../email/models/email-priority.enum'; +import { BaseEntity } from '../../../entity/base-entity.model'; +import { TooManyRequestsError } from '../../../error-handling/errors/too-many-requests.error'; +import { UnauthorizedError } from '../../../error-handling/errors/unauthorized.error'; +import { HttpMethod } from '../../../http/http-method.enum'; +import { KnownHeader } from '../../../http/known-header.enum'; +import { OpenApiSecuritySchemeObject } from '../../../open-api/open-api.model'; +import { PreactUtilities } from '../../../preact/preact.utilities'; +import { Newable } from '../../../types/newable.type'; +import { type OmitStrict } from '../../../types/omit-strict.type'; +import { Ms } from '../../../utilities/ms'; +import { UUIDUtilities } from '../../../utilities/uuid.utilities'; +import { HashUtilities } from '../../hash.utilities'; +import { BaseUser } from '../../models/base-user.model'; +import { type UserServiceInterface } from '../../user/user-service.interface'; +import { AuthStrategyInterface } from '../auth-strategy.interface'; +import { CookieAuthCredentials, CookieAuthCredentialsData } from './cookie-auth-credentials.model'; +import { CookieAuthData } from './cookie-auth-data.model'; +import { CookieAuthLogoutData } from './cookie-auth-logout-data.model'; +import { type CookieOptions } from '../../../http/cookie-options.model'; +import { PasswordResetToken, PasswordResetTokenCreateData } from '../../models/password-reset-token.model'; +import { PasswordResetEmailTemplate } from '../jwt/jwt-auth.controller'; + +/** + * Options input for any cookie session. + */ +export type CookieAuthSessionOptionsInput = OmitStrict; + +/** + * Full options for any cookie session. + */ +type CookieAuthSessionOptions = OmitStrict + & Required>; + +/** + * Cookie auth strategy implementation of Zibri. + */ +export class CookieAuthStrategy< + RoleType extends string, + UserType extends BaseUser = BaseUser +> implements AuthStrategyInterface< + RoleType, + UserType, + CookieAuthData, + CookieAuthCredentialsData, + CookieAuthRequestPasswordResetData, + CookieAuthConfirmPasswordResetData, + CookieAuthRefreshLoginData, + CookieAuthLogoutData +> { + // eslint-disable-next-line jsdoc/require-jsdoc + readonly name: string = 'cookie'; + // eslint-disable-next-line jsdoc/require-jsdoc + readonly securityScheme: OpenApiSecuritySchemeObject; + + private readonly confirmPasswordResetUrl: string; + private readonly PasswordResetEmail: PasswordResetEmailTemplate; + private readonly sessionOptions: CookieAuthSessionOptions; + private readonly refreshSessionOptions: CookieAuthSessionOptions; + + constructor( + @InjectRepository(CookieAuthSession) + private readonly sessionRepository: Repository, + @InjectRepository(CookieAuthRefreshSession) + private readonly refreshSessionRepository: Repository, + @Inject(ZIBRI_DI_TOKENS.USER_SERVICE) + private readonly userService: UserServiceInterface, + @Inject(ZIBRI_DI_TOKENS.CSRF_TOKEN_HEADER) + private readonly csrfTokenHeader: string, + @Inject(ZIBRI_DI_TOKENS.COOKIE_AUTH_SESSION_OPTIONS) + sessionOptions: OmitStrict, + @Inject(ZIBRI_DI_TOKENS.COOKIE_AUTH_REFRESH_SESSION_OPTIONS) + refreshSessionOptions: OmitStrict, + @Inject(ZIBRI_DI_TOKENS.COOKIE_AUTH_SESSION_EXPIRES_IN_MS) + private readonly sessionExpiresInMs: number, + @Inject(ZIBRI_DI_TOKENS.COOKIE_AUTH_REFRESH_SESSION_EXPIRES_IN_MS) + private readonly refreshSessionExpiresInMs: number, + @Inject(ZIBRI_DI_TOKENS.PASSWORD_RESET_TOKEN_EXPIRES_IN_MS) + private readonly passwordResetTokenExpiresInMs: number, + @InjectRepository(PasswordResetToken) + private readonly passwordResetTokenRepository: Repository, + @InjectRepository(CookieAuthCredentials) + private readonly credentialsRepository: Repository, + @Inject(ZIBRI_DI_TOKENS.EMAIL_SERVICE) + private readonly emailService: EmailServiceInterface, + @Inject(ZIBRI_DI_TOKENS.COOKIE_SIGN_SECRET) + secret: string | undefined + ) { + if (!secret) { + throw new NoProviderError(ZIBRI_DI_TOKENS.COOKIE_SIGN_SECRET, []); + } + const confirmPasswordResetUrl: string | undefined = inject(ZIBRI_DI_TOKENS.CONFIRM_PASSWORD_RESET_URL); + if (!confirmPasswordResetUrl) { + throw new NoProviderError(ZIBRI_DI_TOKENS.CONFIRM_PASSWORD_RESET_URL, []); + } + const PasswordResetEmail: PasswordResetEmailTemplate | undefined = inject( + ZIBRI_DI_TOKENS.PASSWORD_RESET_EMAIL_TEMPLATE + ); + if (!PasswordResetEmail) { + throw new NoProviderError(ZIBRI_DI_TOKENS.PASSWORD_RESET_EMAIL_TEMPLATE, []); + } + + this.confirmPasswordResetUrl = confirmPasswordResetUrl; + this.PasswordResetEmail = PasswordResetEmail; + + this.sessionOptions = { + maxAge: this.sessionExpiresInMs, + signed: true, + httpOnly: true, + sameSite: 'lax', + ...sessionOptions + }; + this.refreshSessionOptions = { + maxAge: this.refreshSessionExpiresInMs, + signed: true, + httpOnly: true, + sameSite: 'lax', + ...refreshSessionOptions + }; + + this.securityScheme = { + type: 'apiKey', + in: 'cookie', + name: this.sessionOptions.name + }; + } + + // eslint-disable-next-line jsdoc/require-jsdoc + init(app: ZibriApplication): void { + if (!app.options.cronJobs.includes(CookieAuthSessionCleanupCronJob)) { + app.options.cronJobs.push(CookieAuthSessionCleanupCronJob); + } + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async resolveUser(context: HttpRequestContext | WebsocketRequestContext): Promise { + const session: CookieAuthSession | undefined = await this.resolveAndValidateSession(context, undefined); + if (!session) { + return undefined; + } + + return await this.userService.findById(session.userId); + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async login(credentials: CookieAuthCredentialsData): Promise> { + try { + const foundUser: UserType = await this.userService.findByEmail(credentials.email); + const credentialsFound: CookieAuthCredentials = await this.userService.resolveCredentialsFor(foundUser); + const passwordMatched: boolean = await HashUtilities.equal(credentials.password, credentialsFound.password); + if (!passwordMatched) { + throw new UnauthorizedError('Invalid email or password.'); + } + + const refreshSession: CookieAuthRefreshSession = await this.createRefreshSession( + foundUser.id, + UUIDUtilities.generate(), + credentials.transaction, + randomBytes(32).toString('base64url') + ); + this.setRefreshSessionCookie(refreshSession); + const session: CookieAuthSession = await this.createSession(refreshSession, credentials.transaction); + this.setSessionCookie(session); + + return { + userId: session.userId, + roles: foundUser.roles, + csrfToken: refreshSession.csrfToken, + sessionExpirationDate: session.expirationDate, + refreshSessionExpirationDate: refreshSession.expirationDate + }; + } + catch { + throw new UnauthorizedError('Invalid email or password.'); + } + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async logout(data: CookieAuthLogoutData): Promise { + try { + const refreshSession: CookieAuthRefreshSession | undefined = await this.refreshSessionRepository.findOne( + { where: { id: data.refreshSessionId }, transaction: data.transaction }, + false + ); + await this.refreshSessionRepository.deleteAll({ id: data.refreshSessionId }, { transaction: data.transaction }); + if (refreshSession) { + await this.sessionRepository.deleteAll({ familyId: refreshSession.familyId }, { transaction: data.transaction }); + await this.refreshSessionRepository.deleteAll({ familyId: refreshSession.familyId }, { transaction: data.transaction }); + } + + if (data.clearCookies) { + this.clearCookies(); + } + } + catch { + // ignore + } + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async refreshLogin(data: CookieAuthRefreshLoginData): Promise> { + const context: HttpRequestContext | WebsocketRequestContext | undefined = inject(ZIBRI_DI_TOKENS.CURRENT_REQUEST_CONTEXT); + if (!(context instanceof HttpRequestContext)) { + throw new UnauthorizedError('No valid context'); + } + + const currentRefreshSession: CookieAuthRefreshSession | undefined = await this.resolveRefreshSession(context, data.transaction); + if (!currentRefreshSession) { + this.clearCookies(); + throw new UnauthorizedError('No valid refresh session'); + } + if (new Date(currentRefreshSession.expirationDate).getTime() <= Date.now() || currentRefreshSession.blacklisted) { + await this.logout({ clearCookies: true, refreshSessionId: currentRefreshSession.id, transaction: data.transaction }); + throw new UnauthorizedError('No valid refresh session'); + } + if (!this.isCsrfTokenValid(currentRefreshSession, context)) { + throw new UnauthorizedError('No valid csrf token'); + } + + const currentSession: CookieAuthSession | undefined = await this.resolveSession(context, data.transaction); + if (currentSession && new Date(currentSession.expirationDate).getTime() > Date.now()) { + // the current session is valid, no need to refresh + const user: UserType = await this.userService.findById(currentSession.userId); + return { + userId: currentSession.userId, + roles: user.roles, + csrfToken: currentRefreshSession.csrfToken, + sessionExpirationDate: currentSession.expirationDate, + refreshSessionExpirationDate: currentRefreshSession.expirationDate + }; + } + + const newSession: CookieAuthSession = await this.rotateSessions(currentRefreshSession, data.transaction); + const user: UserType = await this.userService.findById(newSession.userId); + + return { + userId: newSession.userId, + roles: user.roles, + csrfToken: currentRefreshSession.csrfToken, + sessionExpirationDate: newSession.expirationDate, + refreshSessionExpirationDate: new Date(Date.now() + this.refreshSessionExpiresInMs + (Ms.MINUTE * 5)) + }; + } + + private async rotateSessions( + currentRefreshSession: CookieAuthRefreshSession, + transaction: Transaction | undefined + ): Promise { + if (currentRefreshSession.blacklisted) { + await this.logout({ clearCookies: true, refreshSessionId: currentRefreshSession.id, transaction }); + } + // 1. invalidate old refresh (reuse detection) + await this.refreshSessionRepository.updateById( + currentRefreshSession.id, + { blacklisted: true }, + { transaction } + ); + + // 2. create new refresh (same family + SAME csrf) + const newRefresh: CookieAuthRefreshSession = await this.createRefreshSession( + currentRefreshSession.userId, + currentRefreshSession.familyId, + transaction, + currentRefreshSession.csrfToken + ); + + // 3. create new access session + const newSession: CookieAuthSession = await this.createSession(newRefresh, transaction); + + // 4. set cookies (important: use current context) + this.setSessionCookie(newSession); + this.setRefreshSessionCookie(newRefresh); + + return newSession; + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async isLoggedIn(context: HttpRequestContext | WebsocketRequestContext): Promise { + return await this.resolveUser(context) !== undefined; + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async hasRole( + context: HttpRequestContext | WebsocketRequestContext, + allowedRoles: RoleType[] + ): Promise { + const user: UserType | undefined = await this.resolveUser(context); + if (!user) { + return false; + } + + return user.roles.some(r => allowedRoles.includes(r)); + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async belongsTo>( + context: HttpRequestContext | WebsocketRequestContext, + targetEntity: TargetEntity, + targetUserIdKey: keyof InstanceType, + targetIdParamKey: string + ): Promise { + const session: CookieAuthSession | undefined = await this.resolveAndValidateSession(context, undefined); + if (!session) { + return false; + } + + try { + const repo: Repository> = inject(repositoryTokenFor(targetEntity)); + const targetId: string | undefined = context.request.params?.[targetIdParamKey]; + if (targetId == undefined) { + throw new Error(`Could not find the target id specified as path param "${targetId}"`); + } + const foundTarget: InstanceType = await repo.findById(targetId); + const userIdProperty: unknown = foundTarget[targetUserIdKey]; + if (Array.isArray(userIdProperty)) { + return userIdProperty.includes(session.userId); + } + return userIdProperty === session.userId; + } + catch { + return false; + } + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async requestPasswordReset(data: CookieAuthRequestPasswordResetData): Promise { + if (await this.activePasswordResetTokenAlreadyExists(data.user, data.transaction)) { + throw new TooManyRequestsError('A password reset has already been requested for this account.'); + } + + const resetTokenData: PasswordResetTokenCreateData = { + value: randomBytes(16).toString('hex'), + userId: data.user.id, + expirationDate: new Date(Date.now() + this.passwordResetTokenExpiresInMs) + }; + const resetToken: PasswordResetToken = await this.passwordResetTokenRepository.create( + resetTokenData, + { transaction: data.transaction } + ); + + const html: string = PreactUtilities.renderEmail( + this.PasswordResetEmail, + { + user: data.user, + confirmPasswordResetLink: `${data.emailData?.confirmPasswordResetUrl ?? this.confirmPasswordResetUrl}/${resetToken.value}` + } + ); + + await this.emailService.queue({ + recipients: [data.user.email], + subject: 'Password Reset', + html, + priority: EmailPriority.HIGH, + ...data.emailData + }); + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async confirmPasswordReset(data: CookieAuthConfirmPasswordResetData): Promise { + const resetToken: PasswordResetToken | undefined = await this.passwordResetTokenRepository.findOne( + { + where: { value: data.resetToken }, + transaction: data.transaction + }, + false + ); + if (!resetToken) { + throw new UnauthorizedError('Link invalid'); + } + if (new Date(resetToken.expirationDate).getTime() <= Date.now()) { + await this.passwordResetTokenRepository.deleteById(resetToken.id, { transaction: data.transaction }); + throw new UnauthorizedError('Link expired'); + } + + const user: UserType = await this.userService.findById(resetToken.userId); + const credentials: CookieAuthCredentials = await this.userService.resolveCredentialsFor(user); + const hashedPassword: string = await HashUtilities.hash(data.newPassword); + credentials.password = hashedPassword; + + await this.credentialsRepository.updateById(credentials.id, credentials, { transaction: data.transaction }); + await this.passwordResetTokenRepository.deleteById(resetToken.id, { transaction: data.transaction }); + await this.sessionRepository.deleteAll({ userId: resetToken.userId }, { transaction: data.transaction }); + // TODO: set require password change to false + } + + private async activePasswordResetTokenAlreadyExists(user: BaseUser, transaction: Transaction | undefined): Promise { + const existingToken: PasswordResetToken | undefined = await this.passwordResetTokenRepository.findOne( + { where: { userId: user.id }, transaction }, + false + ); + if (existingToken) { + if (new Date(existingToken.expirationDate).getTime() > Date.now()) { + return true; + } + await this.passwordResetTokenRepository.deleteById(existingToken.id, { transaction }); + } + return false; + } + + private async resolveSession( + context: HttpRequestContext | WebsocketRequestContext, + transaction: Transaction | undefined + ): Promise { + if (!(context instanceof HttpRequestContext)) { + return undefined; + } + // eslint-disable-next-line typescript/no-unsafe-assignment + const currentSessionId: string | undefined = context.request.signedCookies?.[this.sessionOptions.name]; + if (!currentSessionId) { + return undefined; + } + return await this.sessionRepository.findOne({ where: { id: currentSessionId }, transaction }, false); + } + + private async resolveAndValidateSession( + context: HttpRequestContext | WebsocketRequestContext, + transaction: Transaction | undefined + ): Promise { + const refreshSession: CookieAuthRefreshSession | undefined = await this.resolveRefreshSession(context, transaction); + + if (!refreshSession) { + this.clearCookies(); + return undefined; + } + if (new Date(refreshSession.expirationDate).getTime() <= Date.now() || refreshSession.blacklisted) { + await this.logout({ clearCookies: true, refreshSessionId: refreshSession.id, transaction }); + return undefined; + } + if (!this.isCsrfTokenValid(refreshSession, context)) { + return undefined; + } + + const session: CookieAuthSession | undefined = await this.resolveSession(context, transaction); + if (!session) { + return undefined; + } + if (new Date(session.expirationDate).getTime() <= Date.now()) { + return await this.rotateSessions(refreshSession, transaction); + } + + if (session.familyId !== refreshSession.familyId || session.userId !== refreshSession.userId) { + return undefined; + } + + return session; + } + + private isCsrfTokenValid( + refreshSession: CookieAuthRefreshSession, + context: HttpRequestContext | WebsocketRequestContext + ): boolean { + if (!(context instanceof HttpRequestContext)) { + return false; + } + + if ([HttpMethod.GET, HttpMethod.HEAD, HttpMethod.OPTIONS].includes(context.request.method)) { + // don't validate safe methods + return true; + } + const csrfToken: string | undefined = this.resolveCsrfTokenFromHeader(context); + return csrfToken === refreshSession.csrfToken; + } + + private async resolveRefreshSession( + context: HttpRequestContext | WebsocketRequestContext, + transaction: Transaction | undefined + ): Promise { + if (!(context instanceof HttpRequestContext)) { + return undefined; + } + // eslint-disable-next-line typescript/no-unsafe-assignment + const currentRefreshSessionId: string | undefined = context.request.signedCookies?.[this.refreshSessionOptions.name]; + if (!currentRefreshSessionId) { + return undefined; + } + return await this.refreshSessionRepository.findOne( + { where: { id: currentRefreshSessionId }, transaction }, + false + ); + } + + private async createSession( + refreshSession: CookieAuthRefreshSession, + transaction: Transaction | undefined + ): Promise { + const sessionCreateData: CookieAuthSessionCreateData = { + userId: refreshSession.userId, + familyId: refreshSession.familyId, + expirationDate: new Date(Date.now() + this.sessionExpiresInMs + (Ms.MINUTE * 5)) + }; + return await this.sessionRepository.create(sessionCreateData, { transaction }); + } + + private async createRefreshSession( + userId: string, + familyId: string, + transaction: Transaction | undefined, + csrfToken: string + ): Promise { + return await this.refreshSessionRepository.create({ + userId, + blacklisted: false, + familyId, + expirationDate: new Date(Date.now() + this.refreshSessionExpiresInMs + (Ms.MINUTE * 5)), + csrfToken + }, { transaction }); + } + + private resolveCsrfTokenFromHeader(context: HttpRequestContext | WebsocketRequestContext): string | undefined { + if (!(context instanceof HttpRequestContext)) { + return undefined; + } + return context.request.headers[this.csrfTokenHeader as KnownHeader]; + } + + private clearCookies(): void { + const context: HttpRequestContext | WebsocketRequestContext | undefined = inject(ZIBRI_DI_TOKENS.CURRENT_REQUEST_CONTEXT); + if (!(context instanceof HttpRequestContext)) { + return; + } + context.response.clearCookie(this.sessionOptions.name, this.sessionOptions); + context.response.clearCookie(this.refreshSessionOptions.name, this.refreshSessionOptions); + } + + private setSessionCookie(session: CookieAuthSession): void { + const context: HttpRequestContext | WebsocketRequestContext | undefined = inject(ZIBRI_DI_TOKENS.CURRENT_REQUEST_CONTEXT); + if (!(context instanceof HttpRequestContext)) { + return; + } + const secure: boolean = context.request.secure || context.request.headers[KnownHeader.X_FORWARDED_PROTO] === 'https'; + context.response.cookie(this.sessionOptions.name, session.id, { ...this.sessionOptions, secure }); + } + + private setRefreshSessionCookie(session: CookieAuthRefreshSession): void { + const context: HttpRequestContext | WebsocketRequestContext | undefined = inject(ZIBRI_DI_TOKENS.CURRENT_REQUEST_CONTEXT); + if (!(context instanceof HttpRequestContext)) { + return; + } + const secure: boolean = context.request.secure || context.request.headers[KnownHeader.X_FORWARDED_PROTO] === 'https'; + context.response.cookie(this.refreshSessionOptions.name, session.id, { ...this.refreshSessionOptions, secure }); + } +} \ No newline at end of file diff --git a/src/auth/strategies/cookie/cookie-auth.controller.ts b/src/auth/strategies/cookie/cookie-auth.controller.ts new file mode 100644 index 0000000..00cc953 --- /dev/null +++ b/src/auth/strategies/cookie/cookie-auth.controller.ts @@ -0,0 +1,182 @@ +import { CookieAuthConfirmPasswordResetData } from './cookie-auth-confirm-password-reset-data.model'; +import { CookieAuthCredentialsDto } from './cookie-auth-credentials.model'; +import { CookieAuthData } from './cookie-auth-data.model'; +import { CookieAuthLogoutData } from './cookie-auth-logout-data.model'; +import { CookieAuthRefreshLoginData } from './cookie-auth-refresh-login-data.model'; +import { CookieAuthStrategy } from './cookie-auth.auth-strategy'; +import { IsolationLevel } from '../../../data-source/data-sources/data-source.interface'; +import { Repository } from '../../../data-source/repository'; +import { Transaction } from '../../../data-source/transaction/transaction.model'; +import { InjectRepository } from '../../../di/decorators/inject-repository.decorator'; +import { Inject } from '../../../di/decorators/inject.decorator'; +import { ZIBRI_DI_TOKENS } from '../../../di/default/zibri-di-tokens.default'; +import { Property } from '../../../entity/decorators/property.decorator'; +import { OmitClass } from '../../../entity/omit-class.model'; +import { Response } from '../../../open-api/decorators/response.decorator'; +import { Body } from '../../../routing/decorators/body.decorator'; +import { Controller } from '../../../routing/decorators/controller.decorator'; +import { Post } from '../../../routing/decorators/post.decorator'; +import { AuthControllerInterface } from '../../auth-controller.interface'; +import { type AuthServiceInterface } from '../../auth-service.interface'; +import { BaseUser } from '../../models/base-user.model'; +import { PasswordResetToken } from '../../models/password-reset-token.model'; +import { type UserServiceInterface } from '../../user/user-service.interface'; + +class CookieAuthRequestPasswordResetInput { + @Property.string({ format: 'email' }) + email!: string; +} + +class CookieAuthVerifyPasswordResetTokenInput { + @Property.string() + resetToken!: string; +} + +class CookieAuthVerifyPasswordResetTokenResponse { + @Property.boolean() + isValid!: boolean; +} + +class CookieAuthConfirmPasswordResetDto extends OmitClass(CookieAuthConfirmPasswordResetData, ['transaction']) {} + +class CookieAuthRefreshLoginDto extends OmitClass(CookieAuthRefreshLoginData, ['transaction']) {} + +class CookieAuthLogoutDto extends OmitClass(CookieAuthLogoutData, ['transaction']) {} + +@Controller('/auth', { allowOrphan: true }) +export class CookieAuthController implements AuthControllerInterface< + CookieAuthCredentialsDto, + CookieAuthData, + CookieAuthRefreshLoginData, + CookieAuthRequestPasswordResetInput, + CookieAuthConfirmPasswordResetData +> { + constructor( + @Inject(ZIBRI_DI_TOKENS.AUTH_SERVICE) + private readonly authService: AuthServiceInterface, + @Inject(ZIBRI_DI_TOKENS.USER_SERVICE) + private readonly userService: UserServiceInterface, + @InjectRepository(PasswordResetToken) + private readonly passwordResetTokenRepository: Repository + ) {} + + @Response.object(CookieAuthData) + @Post('/login') + async login( + @Body(CookieAuthCredentialsDto) + credentials: CookieAuthCredentialsDto + ): Promise> { + const transaction: Transaction = await this.passwordResetTokenRepository.dataSource.startTransaction(IsolationLevel.READ_COMMITTED); + try { + const res: CookieAuthData = await this.authService.login(CookieAuthStrategy, { ...credentials, transaction }); + await transaction.commit(); + return res; + } + catch (error) { + await transaction.rollback(); + throw error; + } + } + + @Response.object(CookieAuthData) + @Post('/refresh-login') + async refreshLogin( + @Body(CookieAuthRefreshLoginDto) + data: CookieAuthRefreshLoginDto + ): Promise> { + const transaction: Transaction = await this.passwordResetTokenRepository.dataSource.startTransaction(IsolationLevel.READ_COMMITTED); + try { + const res: CookieAuthData = await this.authService.refreshLogin(CookieAuthStrategy, { ...data, transaction }); + await transaction.commit(); + return res; + } + catch (error) { + await transaction.rollback(); + throw error; + } + } + + @Response.empty() + @Post('/request-password-reset') + async requestPasswordReset( + @Body(CookieAuthRequestPasswordResetInput) + data: CookieAuthRequestPasswordResetInput + ): Promise { + const transaction: Transaction = await this.passwordResetTokenRepository.dataSource.startTransaction(IsolationLevel.READ_COMMITTED); + try { + const user: BaseUser = await this.userService.findByEmail(data.email); + await this.authService.requestPasswordReset(CookieAuthStrategy, { user, transaction }); + await transaction.commit(); + } + catch (error) { + await transaction.rollback(); + throw error; + } + } + + @Response.object(CookieAuthVerifyPasswordResetTokenResponse) + @Post('/verify-password-reset-token') + async verifyResetToken( + @Body(CookieAuthVerifyPasswordResetTokenInput) + input: CookieAuthVerifyPasswordResetTokenInput + ): Promise { + const resetToken: PasswordResetToken | undefined + = await this.passwordResetTokenRepository.findOne({ where: { value: input.resetToken } }, false); + if (!resetToken) { + return { + isValid: false + }; + } + if (new Date(resetToken.expirationDate).getTime() <= Date.now()) { + await this.passwordResetTokenRepository.deleteById(resetToken.id); + return { + isValid: false + }; + } + try { + await this.userService.findById(resetToken.userId); + return { + isValid: true + }; + } + catch { + return { + isValid: false + }; + } + } + + @Response.empty() + @Post('/confirm-password-reset') + async confirmPasswordReset( + @Body(CookieAuthConfirmPasswordResetDto) + data: CookieAuthConfirmPasswordResetDto + ): Promise { + const transaction: Transaction = await this.passwordResetTokenRepository.dataSource.startTransaction(IsolationLevel.READ_COMMITTED); + try { + await this.authService.confirmPasswordReset(CookieAuthStrategy, { ...data, transaction }); + await transaction.commit(); + } + catch (error) { + await transaction.rollback(); + throw error; + } + } + + @Response.empty() + @Post('/logout') + async logout( + @Body(CookieAuthLogoutDto) + data: CookieAuthLogoutDto + ): Promise { + const transaction: Transaction = await this.passwordResetTokenRepository.dataSource.startTransaction(IsolationLevel.READ_COMMITTED); + try { + await this.authService.logout(CookieAuthStrategy, { ...data, transaction }); + await transaction.commit(); + } + catch (error) { + await transaction.rollback(); + throw error; + } + } +} \ No newline at end of file diff --git a/src/auth/strategies/jwt/jwt-auth.controller.ts b/src/auth/strategies/jwt/jwt-auth.controller.ts index fe3109e..e76bb9b 100644 --- a/src/auth/strategies/jwt/jwt-auth.controller.ts +++ b/src/auth/strategies/jwt/jwt-auth.controller.ts @@ -3,11 +3,14 @@ import { JwtConfirmPasswordResetData } from './jwt-confirm-password-reset-data.m import { JwtCredentialsDto } from './jwt-credentials.model'; import { JwtRefreshLoginData } from './jwt-refresh-login-data.model'; import { JwtAuthStrategy } from './jwt.auth-strategy'; +import { IsolationLevel } from '../../../data-source/data-sources/data-source.interface'; import { Repository } from '../../../data-source/repository'; +import { Transaction } from '../../../data-source/transaction/transaction.model'; import { InjectRepository } from '../../../di/decorators/inject-repository.decorator'; import { Inject } from '../../../di/decorators/inject.decorator'; import { ZIBRI_DI_TOKENS } from '../../../di/default/zibri-di-tokens.default'; import { Property } from '../../../entity/decorators/property.decorator'; +import { OmitClass } from '../../../entity/omit-class.model'; import { Response } from '../../../open-api/decorators/response.decorator'; import { PreactEmailComponent } from '../../../preact/preact-email-component.model'; import { Body } from '../../../routing/decorators/body.decorator'; @@ -49,6 +52,10 @@ class JwtVerifyPasswordResetTokenResponse { isValid!: boolean; } +class JwtConfirmPasswordResetDto extends OmitClass(JwtConfirmPasswordResetData, ['transaction']) {} + +class JwtRefreshLoginDto extends OmitClass(JwtRefreshLoginData, ['transaction']) {} + @Controller('/auth', { allowOrphan: true }) export class JwtAuthController implements AuthControllerInterface< JwtCredentialsDto, @@ -78,10 +85,19 @@ export class JwtAuthController implements AuthControllerInterface< @Response.object(JwtAuthData) @Post('/refresh-login') async refreshLogin( - @Body(JwtRefreshLoginData) - data: JwtRefreshLoginData + @Body(JwtRefreshLoginDto) + data: JwtRefreshLoginDto ): Promise> { - return await this.authService.refreshLogin(JwtAuthStrategy, data); + const transaction: Transaction = await this.passwordResetTokenRepository.dataSource.startTransaction(IsolationLevel.READ_COMMITTED); + try { + const res: JwtAuthData = await this.authService.refreshLogin(JwtAuthStrategy, { ...data, transaction }); + await transaction.commit(); + return res; + } + catch (error) { + await transaction.rollback(); + throw error; + } } @Response.empty() @@ -90,8 +106,16 @@ export class JwtAuthController implements AuthControllerInterface< @Body(JwtRequestPasswordResetInput) data: JwtRequestPasswordResetInput ): Promise { - const user: BaseUser = await this.userService.findByEmail(data.email); - await this.authService.requestPasswordReset(JwtAuthStrategy, { user }); + const transaction: Transaction = await this.passwordResetTokenRepository.dataSource.startTransaction(IsolationLevel.READ_COMMITTED); + try { + const user: BaseUser = await this.userService.findByEmail(data.email); + await this.authService.requestPasswordReset(JwtAuthStrategy, { user, transaction }); + await transaction.commit(); + } + catch (error) { + await transaction.rollback(); + throw error; + } } @Response.object(JwtVerifyPasswordResetTokenResponse) @@ -129,17 +153,25 @@ export class JwtAuthController implements AuthControllerInterface< @Response.empty() @Post('/confirm-password-reset') async confirmPasswordReset( - @Body(JwtConfirmPasswordResetData) - data: JwtConfirmPasswordResetData + @Body(JwtConfirmPasswordResetDto) + data: JwtConfirmPasswordResetDto ): Promise { - await this.authService.confirmPasswordReset(JwtAuthStrategy, data); + const transaction: Transaction = await this.passwordResetTokenRepository.dataSource.startTransaction(IsolationLevel.READ_COMMITTED); + try { + await this.authService.confirmPasswordReset(JwtAuthStrategy, { ...data, transaction }); + await transaction.commit(); + } + catch (error) { + await transaction.rollback(); + throw error; + } } @Response.empty() @Post('/logout') async logout( - @Body(JwtRefreshLoginData) - data: JwtRefreshLoginData + @Body(JwtRefreshLoginDto) + data: JwtRefreshLoginDto ): Promise { await this.authService.logout(JwtAuthStrategy, data); } diff --git a/src/auth/strategies/jwt/jwt-confirm-password-reset-data.model.ts b/src/auth/strategies/jwt/jwt-confirm-password-reset-data.model.ts index 3a465a6..9d68c5c 100644 --- a/src/auth/strategies/jwt/jwt-confirm-password-reset-data.model.ts +++ b/src/auth/strategies/jwt/jwt-confirm-password-reset-data.model.ts @@ -1,3 +1,4 @@ +import { Transaction } from '../../../data-source/transaction/transaction.model'; import { Property } from '../../../entity/decorators/property.decorator'; /** @@ -15,4 +16,8 @@ export class JwtConfirmPasswordResetData { */ @Property.string() newPassword!: string; + /** + * The transaction that this should run in. + */ + transaction!: Transaction; } \ No newline at end of file diff --git a/src/auth/strategies/jwt/jwt-refresh-login-data.model.ts b/src/auth/strategies/jwt/jwt-refresh-login-data.model.ts index 8e87ea9..db401c1 100644 --- a/src/auth/strategies/jwt/jwt-refresh-login-data.model.ts +++ b/src/auth/strategies/jwt/jwt-refresh-login-data.model.ts @@ -1,3 +1,4 @@ +import { Transaction } from '../../../data-source/transaction/transaction.model'; import { Property } from '../../../entity/decorators/property.decorator'; /** @@ -9,4 +10,8 @@ export class JwtRefreshLoginData { */ @Property.string() refreshToken!: string; + /** + * The transaction that this should run in. + */ + transaction!: Transaction; } \ No newline at end of file diff --git a/src/auth/strategies/jwt/jwt-refresh-token-cleanup.cron-job.ts b/src/auth/strategies/jwt/jwt-refresh-token-cleanup.cron-job.ts new file mode 100644 index 0000000..0320aa9 --- /dev/null +++ b/src/auth/strategies/jwt/jwt-refresh-token-cleanup.cron-job.ts @@ -0,0 +1,31 @@ +import { JwtRefreshToken } from './jwt-refresh-token.model'; +import { CronExpression } from '../../../cron/cron-expression.utilities'; +import { CronJob, InitialCronConfig } from '../../../cron/cron-job.model'; +import { Repository } from '../../../data-source/repository'; +import { InjectRepository } from '../../../di/decorators/inject-repository.decorator'; + +/** + * A cron job to cleanup expired jwt refresh tokens. + */ +export class JwtRefreshTokenCleanupCronJob extends CronJob { + // eslint-disable-next-line jsdoc/require-jsdoc + initialConfig: InitialCronConfig = { + name: 'Cleanup jwt refresh tokens', + cron: CronExpression.daily().build(), + runOnInit: false + }; + + constructor( + @InjectRepository(JwtRefreshToken) + private readonly refreshTokenRepository: Repository + ) { + super(); + } + + // eslint-disable-next-line jsdoc/require-jsdoc + async onTick(): Promise { + // blacklisted tokens need to stay, to find out + // if someone tries to use them a second time. + await this.refreshTokenRepository.deleteAll({ expirationDate: { before: new Date() } }); + } +} \ No newline at end of file diff --git a/src/auth/strategies/jwt/jwt-request-password-reset-data.model.ts b/src/auth/strategies/jwt/jwt-request-password-reset-data.model.ts index e59f647..d908b36 100644 --- a/src/auth/strategies/jwt/jwt-request-password-reset-data.model.ts +++ b/src/auth/strategies/jwt/jwt-request-password-reset-data.model.ts @@ -1,3 +1,4 @@ +import { Transaction } from '../../../data-source/transaction/transaction.model'; import { QueueEmailData } from '../../../email/models/create-email-data.model'; import { BaseUser } from '../../models/base-user.model'; @@ -19,5 +20,9 @@ export type JwtRequestPasswordResetData + >, + /** + * The transaction that this should run in. + */ + transaction: Transaction }; \ No newline at end of file diff --git a/src/auth/strategies/jwt/jwt.auth-strategy.ts b/src/auth/strategies/jwt/jwt.auth-strategy.ts index 740f1d1..fdcc1b8 100644 --- a/src/auth/strategies/jwt/jwt.auth-strategy.ts +++ b/src/auth/strategies/jwt/jwt.auth-strategy.ts @@ -6,13 +6,16 @@ import { JwtAuthData } from './jwt-auth-data.model'; import { JwtConfirmPasswordResetData } from './jwt-confirm-password-reset-data.model'; import { JwtCredentials, JwtCredentialsDto } from './jwt-credentials.model'; import { JwtRefreshLoginData } from './jwt-refresh-login-data.model'; +import { JwtRefreshTokenCleanupCronJob } from './jwt-refresh-token-cleanup.cron-job'; import { JwtRefreshTokenPayload } from './jwt-refresh-token-payload.model'; import { JwtRefreshToken, JwtRefreshTokenCreateDto } from './jwt-refresh-token.model'; import { JwtRequestPasswordResetData } from './jwt-request-password-reset-data.model'; import { JwtUtilities } from './jwt.utilities'; +import { ZibriApplication } from '../../../application'; import { HttpRequestContext } from '../../../context/request/http-request.context'; import { WebsocketRequestContext } from '../../../context/request/websocket-request.context'; import { Repository } from '../../../data-source/repository'; +import { Transaction } from '../../../data-source/transaction/transaction.model'; import { InjectRepository, repositoryTokenFor } from '../../../di/decorators/inject-repository.decorator'; import { Inject } from '../../../di/decorators/inject.decorator'; import { ZIBRI_DI_TOKENS } from '../../../di/default/zibri-di-tokens.default'; @@ -26,8 +29,8 @@ import { UnauthorizedError } from '../../../error-handling/errors/unauthorized.e import { GlobalRegistry } from '../../../global/global-registry'; import { OpenApiSecuritySchemeObject } from '../../../open-api/open-api.model'; import { Newable } from '../../../types/newable.type'; +import { OmitStrict } from '../../../types/omit-strict.type'; import { Ms } from '../../../utilities/ms'; -import { UUIDUtilities } from '../../../utilities/uuid.utilities'; import { HashUtilities } from '../../hash.utilities'; import { BaseUser } from '../../models/base-user.model'; import { PasswordResetToken, PasswordResetTokenCreateData } from '../../models/password-reset-token.model'; @@ -35,6 +38,7 @@ import { type UserServiceInterface } from '../../user/user-service.interface'; import { AuthStrategyInterface } from '../auth-strategy.interface'; import { PasswordResetEmailTemplate } from './jwt-auth.controller'; import { PreactUtilities } from '../../../preact/preact.utilities'; +import { UUIDUtilities } from '../../../utilities/uuid.utilities'; /** * Jwt auth strategy implementation of Zibri. @@ -51,7 +55,7 @@ implements AuthStrategyInterface< JwtRequestPasswordResetData, JwtConfirmPasswordResetData, JwtRefreshLoginData, - JwtRefreshLoginData + OmitStrict > { // eslint-disable-next-line jsdoc/require-jsdoc readonly name: string = 'jwt'; @@ -80,7 +84,7 @@ implements AuthStrategyInterface< private readonly accessTokenExpiresInMs: number, @Inject(ZIBRI_DI_TOKENS.JWT_REFRESH_TOKEN_EXPIRES_IN_MS) private readonly refreshTokenExpiresInMs: number, - @Inject(ZIBRI_DI_TOKENS.JWT_PASSWORD_RESET_TOKEN_EXPIRES_IN_MS) + @Inject(ZIBRI_DI_TOKENS.PASSWORD_RESET_TOKEN_EXPIRES_IN_MS) private readonly passwordResetTokenExpiresInMs: number, @Inject(ZIBRI_DI_TOKENS.USER_SERVICE) private readonly userService: UserServiceInterface, @@ -95,15 +99,15 @@ implements AuthStrategyInterface< if (!refreshTokenSecret) { throw new NoProviderError(ZIBRI_DI_TOKENS.JWT_REFRESH_TOKEN_SECRET, []); } - const confirmPasswordResetUrl: string | undefined = inject(ZIBRI_DI_TOKENS.JWT_CONFIRM_PASSWORD_RESET_URL); + const confirmPasswordResetUrl: string | undefined = inject(ZIBRI_DI_TOKENS.CONFIRM_PASSWORD_RESET_URL); if (!confirmPasswordResetUrl) { - throw new NoProviderError(ZIBRI_DI_TOKENS.JWT_CONFIRM_PASSWORD_RESET_URL, []); + throw new NoProviderError(ZIBRI_DI_TOKENS.CONFIRM_PASSWORD_RESET_URL, []); } const PasswordResetEmail: PasswordResetEmailTemplate | undefined = inject( - ZIBRI_DI_TOKENS.JWT_PASSWORD_RESET_EMAIL_TEMPLATE + ZIBRI_DI_TOKENS.PASSWORD_RESET_EMAIL_TEMPLATE ); if (!PasswordResetEmail) { - throw new NoProviderError(ZIBRI_DI_TOKENS.JWT_PASSWORD_RESET_EMAIL_TEMPLATE, []); + throw new NoProviderError(ZIBRI_DI_TOKENS.PASSWORD_RESET_EMAIL_TEMPLATE, []); } this.accessTokenSecret = accessTokenSecret; @@ -112,6 +116,13 @@ implements AuthStrategyInterface< this.PasswordResetEmail = PasswordResetEmail; } + // eslint-disable-next-line jsdoc/require-jsdoc + init(app: ZibriApplication): void { + if (!app.options.cronJobs.includes(JwtRefreshTokenCleanupCronJob)) { + app.options.cronJobs.push(JwtRefreshTokenCleanupCronJob); + } + } + // eslint-disable-next-line jsdoc/require-jsdoc async login(credentials: JwtCredentialsDto): Promise> { try { @@ -123,7 +134,7 @@ implements AuthStrategyInterface< } const accessTokenValue: string = await this.generateAccessToken(foundUser); const refreshTokenValue: string = await this.generateRefreshToken(foundUser); - await this.createRefreshToken(foundUser, refreshTokenValue); + await this.createRefreshToken(foundUser, refreshTokenValue, UUIDUtilities.generate(), undefined); return { accessToken: { @@ -144,10 +155,12 @@ implements AuthStrategyInterface< } // eslint-disable-next-line jsdoc/require-jsdoc - async logout(data: JwtRefreshLoginData): Promise { + async logout(data: OmitStrict): Promise { try { - const refreshToken: JwtRefreshToken | undefined - = await this.refreshTokenRepository.findOne({ where: { value: data.refreshToken } }, false); + const refreshToken: JwtRefreshToken | undefined = await this.refreshTokenRepository.findOne( + { where: { value: data.refreshToken } }, + false + ); if (!refreshToken) { return; } @@ -158,20 +171,25 @@ implements AuthStrategyInterface< } } - private async createRefreshToken(foundUser: UserType, refreshTokenValue: string): Promise { + private async createRefreshToken( + foundUser: UserType, + refreshTokenValue: string, + familyId: string, + transaction: Transaction | undefined + ): Promise { const data: JwtRefreshTokenCreateDto = { userId: foundUser.id, value: refreshTokenValue, - familyId: UUIDUtilities.generate(), + familyId, blacklisted: false, expirationDate: new Date(Date.now() + this.refreshTokenExpiresInMs) }; - return await this.refreshTokenRepository.create(data); + return await this.refreshTokenRepository.create(data, { transaction }); } // eslint-disable-next-line jsdoc/require-jsdoc async refreshLogin(data: JwtRefreshLoginData): Promise> { - const refreshToken: JwtRefreshToken = await this.verifyAndResolveRefreshToken(data.refreshToken); + const refreshToken: JwtRefreshToken = await this.verifyAndResolveRefreshToken(data.refreshToken, data.transaction); const user: UserType = await this.userService.findById(refreshToken.userId); const accessTokenValue: string = await this.generateAccessToken(user); @@ -189,10 +207,13 @@ implements AuthStrategyInterface< }; } - private async verifyAndResolveRefreshToken(tokenValue: string): Promise { - await JwtUtilities.verify(tokenValue, this.refreshTokenSecret); + private async verifyAndResolveRefreshToken(tokenValue: string, transaction: Transaction): Promise { + const encoded: EncodedJwtAccessToken | undefined = await JwtUtilities.verify(tokenValue, this.refreshTokenSecret); + if (!encoded) { + throw new UnauthorizedError('Error verifying token: Invalid Token'); + } const refreshToken: JwtRefreshToken | undefined = await this.refreshTokenRepository.findOne( - { where: { value: tokenValue } }, + { where: { value: tokenValue }, transaction }, false ); @@ -200,32 +221,25 @@ implements AuthStrategyInterface< throw new UnauthorizedError('Error verifying token: Invalid Token'); } if (refreshToken.blacklisted) { - await this.refreshTokenRepository.deleteAll({ familyId: refreshToken.familyId }); + await this.refreshTokenRepository.deleteAll({ familyId: refreshToken.familyId }, { transaction }); throw new UnauthorizedError('The given refresh token has already been used.'); } - - if (!this.isRefreshTokenExpired(refreshToken)) { - return refreshToken; + if (new Date(refreshToken.expirationDate).getTime() <= Date.now()) { + await this.refreshTokenRepository.deleteAll({ familyId: refreshToken.familyId }, { transaction }); + throw new UnauthorizedError('The given refresh token is expired.'); } const user: UserType = await this.userService.findById(refreshToken.userId); const refreshTokenValue: string = await this.generateRefreshToken(user); - const res: JwtRefreshToken = await this.createRefreshToken(user, refreshTokenValue); - await this.refreshTokenRepository.updateById(refreshToken.id, { blacklisted: true }); - await this.refreshTokenRepository.deleteAll({ expirationDate: { before: new Date() } }); + const res: JwtRefreshToken = await this.createRefreshToken(user, refreshTokenValue, refreshToken.familyId, transaction); + await this.refreshTokenRepository.updateById(refreshToken.id, { blacklisted: true }, { transaction }); return res; } - private isRefreshTokenExpired(refreshToken: JwtRefreshToken): boolean { - const createdAt: Date = new Date(new Date(refreshToken.expirationDate).getTime() - this.refreshTokenExpiresInMs); - const refreshTokenLifeTimeInMs: number = Date.now() - createdAt.getTime(); - return refreshTokenLifeTimeInMs > this.refreshTokenExpiresInMs; - } - // eslint-disable-next-line jsdoc/require-jsdoc async requestPasswordReset(data: JwtRequestPasswordResetData): Promise { - if (await this.activePasswordResetTokenAlreadyExists(data.user)) { + if (await this.activePasswordResetTokenAlreadyExists(data.user, data.transaction)) { throw new TooManyRequestsError('A password reset has already been requested for this account.'); } @@ -234,7 +248,10 @@ implements AuthStrategyInterface< userId: data.user.id, expirationDate: new Date(Date.now() + this.passwordResetTokenExpiresInMs) }; - const resetToken: PasswordResetToken = await this.passwordResetTokenRepository.create(resetTokenData); + const resetToken: PasswordResetToken = await this.passwordResetTokenRepository.create( + resetTokenData, + { transaction: data.transaction } + ); const html: string = PreactUtilities.renderEmail( this.PasswordResetEmail, @@ -249,19 +266,21 @@ implements AuthStrategyInterface< subject: 'Password Reset', html, priority: EmailPriority.HIGH, - ...data + ...data.emailData }); } // eslint-disable-next-line jsdoc/require-jsdoc async confirmPasswordReset(data: JwtConfirmPasswordResetData): Promise { - // eslint-disable-next-line stylistic/max-len - const resetToken: PasswordResetToken | undefined = await this.passwordResetTokenRepository.findOne({ where: { value: data.resetToken } }, false); + const resetToken: PasswordResetToken | undefined = await this.passwordResetTokenRepository.findOne( + { where: { value: data.resetToken }, transaction: data.transaction }, + false + ); if (!resetToken) { throw new UnauthorizedError('Link invalid'); } if (new Date(resetToken.expirationDate).getTime() <= Date.now()) { - await this.passwordResetTokenRepository.deleteById(resetToken.id); + await this.passwordResetTokenRepository.deleteById(resetToken.id, { transaction: data.transaction }); throw new UnauthorizedError('Link expired'); } @@ -270,22 +289,22 @@ implements AuthStrategyInterface< const hashedPassword: string = await HashUtilities.hash(data.newPassword); credentials.password = hashedPassword; - await this.credentialsRepository.updateById(credentials.id, credentials); - await this.passwordResetTokenRepository.deleteById(resetToken.id); - await this.refreshTokenRepository.deleteAll({ userId: resetToken.userId }); + await this.credentialsRepository.updateById(credentials.id, credentials, { transaction: data.transaction }); + await this.passwordResetTokenRepository.deleteById(resetToken.id, { transaction: data.transaction }); + await this.refreshTokenRepository.deleteAll({ userId: resetToken.userId }, { transaction: data.transaction }); // TODO: set require password change to false } - private async activePasswordResetTokenAlreadyExists(user: BaseUser): Promise { + private async activePasswordResetTokenAlreadyExists(user: BaseUser, transaction: Transaction): Promise { const existingToken: PasswordResetToken | undefined = await this.passwordResetTokenRepository.findOne( - { where: { userId: user.id } }, + { where: { userId: user.id }, transaction }, false ); if (existingToken) { if (new Date(existingToken.expirationDate).getTime() > Date.now()) { return true; } - await this.passwordResetTokenRepository.deleteById(existingToken.id); + await this.passwordResetTokenRepository.deleteById(existingToken.id, { transaction }); } return false; } diff --git a/src/change-sets/change-set-repository.ts b/src/change-sets/change-set-repository.ts index 96f3cbf..e861611 100644 --- a/src/change-sets/change-set-repository.ts +++ b/src/change-sets/change-set-repository.ts @@ -11,6 +11,7 @@ import { NewChange } from './models/change.model'; import { BaseUser } from '../auth/models/base-user.model'; import { HttpRequestContext } from '../context/request/http-request.context'; import { WebsocketRequestContext } from '../context/request/websocket-request.context'; +import { DataSourceInterface } from '../data-source/data-sources/data-source.interface'; import { BaseRepositoryOptions } from '../data-source/models/options/base-repository-options.model'; import { CreateAllOptions } from '../data-source/models/options/create-all-options.model'; import { CreateOptions } from '../data-source/models/options/create-options.model'; @@ -63,8 +64,8 @@ export class ChangeSetRepository< private readonly changeSetRepository: Repository; private readonly authService: AuthServiceInterface; - constructor(entityClass: Newable, repo: TORepository | Repository, logger: LoggerInterface) { - super(entityClass, repo, logger); + constructor(entityClass: Newable, repo: TORepository | Repository, logger: LoggerInterface, dataSource: DataSourceInterface) { + super(entityClass, repo, logger, dataSource); this.authService = inject(ZIBRI_DI_TOKENS.AUTH_SERVICE); this.changeSetRepository = inject(repositoryTokenFor(ChangeSet)); diff --git a/src/change-sets/soft-delete-repository.ts b/src/change-sets/soft-delete-repository.ts index b9b2471..954b944 100644 --- a/src/change-sets/soft-delete-repository.ts +++ b/src/change-sets/soft-delete-repository.ts @@ -16,6 +16,7 @@ import { SoftDeleteFindOneOptions } from './models/soft-delete-find-one-options. import { SoftDeleteUpdateAllOptions } from './models/soft-delete-update-all-options.model'; import { SoftDeleteUpdateByIdOptions } from './models/soft-delete-update-by-id-options.model'; import { SoftDeleteWhere } from './models/soft-delete-where.model'; +import { DataSourceInterface } from '../data-source/data-sources/data-source.interface'; import { Where } from '../data-source/models/where/where-filter.model'; import { NotFoundError } from '../error-handling/errors/not-found.error'; import { removeExcludeProperties } from '../global/model-registry/remove-exclude-properties.function'; @@ -54,8 +55,8 @@ export class SoftDeleteRepository< protected override readonly keysToExcludeFromChangeSets: Set = new Set(); - constructor(entityClass: Newable, repo: TORepository | Repository, logger: LoggerInterface) { - super(entityClass, repo, logger); + constructor(entityClass: Newable, repo: TORepository | Repository, logger: LoggerInterface, dataSource: DataSourceInterface) { + super(entityClass, repo, logger, dataSource); this.keysToExcludeFromChangeSets.add('deleted'); } diff --git a/src/context/base-context.ts b/src/context/base-context.ts index 15762f0..1122343 100644 --- a/src/context/base-context.ts +++ b/src/context/base-context.ts @@ -7,4 +7,6 @@ export abstract class BaseContext { * The cached token values. */ protected readonly tokenValues: Map = new Map(); + + constructor() {} } \ No newline at end of file diff --git a/src/context/request/http-request.context.ts b/src/context/request/http-request.context.ts index 1aaff85..a69d776 100644 --- a/src/context/request/http-request.context.ts +++ b/src/context/request/http-request.context.ts @@ -1,6 +1,8 @@ + import { BaseContext } from '../base-context'; import { RequestContextToken } from './request-context-token.model'; import { HttpRequest } from '../../http/http-request.model'; +import { HttpResponse } from '../../http/http-response.model'; import { Newable } from '../../types/newable.type'; /** @@ -12,6 +14,7 @@ export class HttpRequestContext extends BaseContext<'http-request'> { constructor( readonly request: HttpRequest, + readonly response: HttpResponse, readonly controllerClass: Newable | undefined, readonly controllerMethod: string | undefined ) { @@ -32,10 +35,10 @@ export class HttpRequestContext extends BaseContext<'http-request'> { * @param token - The token to get the value of. * @returns Either the cached or a new value. */ - get(token: RequestContextToken): T | Promise { + get(token: RequestContextToken): T { if (!this.has(token)) { this.tokenValues.set(token.key, token.fn(this)); } - return this.tokenValues.get(token.key) as T | Promise; + return this.tokenValues.get(token.key) as T; } } \ No newline at end of file diff --git a/src/context/request/request-context-token.model.ts b/src/context/request/request-context-token.model.ts index f8414d4..1528009 100644 --- a/src/context/request/request-context-token.model.ts +++ b/src/context/request/request-context-token.model.ts @@ -1,3 +1,5 @@ +import { randomBytes } from 'node:crypto'; + import { HttpRequestContext } from './http-request.context'; import { WebsocketRequestContext } from './websocket-request.context'; import { TwoFactorServiceInterface } from '../../auth/2fa/two-factor-service.interface'; @@ -7,7 +9,9 @@ import { BaseUser } from '../../auth/models/base-user.model'; import { IsLoggedInMetadata } from '../../auth/models/is-logged-in-metadata.model'; import { ZIBRI_DI_TOKENS } from '../../di/default/zibri-di-tokens.default'; import { inject } from '../../di/inject.function'; +import { KnownHeader } from '../../http/known-header.enum'; import { MetadataUtilities } from '../../utilities/metadata.utilities'; +import { UUIDUtilities } from '../../utilities/uuid.utilities'; const allRequestContextTokenKeys: Set = new Set(); @@ -20,7 +24,7 @@ export class RequestContextToken { constructor( readonly key: string, - readonly fn: (ctx: HttpRequestContext | WebsocketRequestContext) => T | Promise + readonly fn: (ctx: HttpRequestContext | WebsocketRequestContext) => T ) { if (allRequestContextTokenKeys.has(key)) { throw new Error([`A RequestContextToken with the key "${key}" already exists.`].join('\n')); @@ -39,6 +43,17 @@ export class RequestContextToken { */ // eslint-disable-next-line typescript/typedef export const ZIBRI_REQUEST_CONTEXT_TOKENS = { + NONCE: new RequestContextToken( + 'nonce', + () => randomBytes(16).toString('base64') + ), + CORRELATION_ID: new RequestContextToken( + 'correlation_id', + ctx => { + const correlationIdHeader: string = inject(ZIBRI_DI_TOKENS.CORRELATION_ID_HEADER); + return ctx.request.headers[correlationIdHeader as KnownHeader] ?? UUIDUtilities.generate(); + } + ), CURRENT_USER: new RequestContextToken( 'current_user', async ctx => { diff --git a/src/context/request/websocket-request.context.ts b/src/context/request/websocket-request.context.ts index 2a30152..32f46eb 100644 --- a/src/context/request/websocket-request.context.ts +++ b/src/context/request/websocket-request.context.ts @@ -34,10 +34,10 @@ export class WebsocketRequestContext extends BaseContext<'websocket-request'> { * @param token - The token to get the value of. * @returns Either the cached or a new value. */ - get(token: RequestContextToken): T | Promise { + get(token: RequestContextToken): T { if (!this.has(token)) { this.tokenValues.set(token.key, token.fn(this)); } - return this.tokenValues.get(token.key) as T | Promise; + return this.tokenValues.get(token.key) as T; } } \ No newline at end of file diff --git a/src/cron/cron-expression.utilities.ts b/src/cron/cron-expression.utilities.ts new file mode 100644 index 0000000..e2032af --- /dev/null +++ b/src/cron/cron-expression.utilities.ts @@ -0,0 +1,238 @@ +import { IntRange } from '../types/percentage.type'; + +// eslint-disable-next-line jsdoc/require-jsdoc +type CronUnit = 'seconds' | 'minutes' | 'hours' | 'days' | 'months'; + +// eslint-disable-next-line jsdoc/require-jsdoc +type DayOfWeek = 'Sunday' | 'Monday' | 'Tuesday' | 'Wednesday' + | 'Thursday' | 'Friday' | 'Saturday'; + +// eslint-disable-next-line jsdoc/require-jsdoc +type MonthName = | 'January' | 'February' | 'March' | 'April' + | 'May' | 'June' | 'July' | 'August' + | 'September' | 'October' | 'November' | 'December'; + +// eslint-disable-next-line jsdoc/require-jsdoc +export type CronExpressionString = string & { + // eslint-disable-next-line jsdoc/require-jsdoc + __brand: 'CronExpression' +}; + +// eslint-disable-next-line jsdoc/require-jsdoc +type CronFields = [ + second: string, + minute: string, + hour: string, + day: string, + month: string, + weekday: string +]; + +// eslint-disable-next-line jsdoc/require-jsdoc +type RangeForCronUnit = T extends 'seconds' + ? IntRange<0, 60> + : T extends 'minutes' + ? IntRange<0, 60> + : T extends 'hours' + ? IntRange<0, 24> + : T extends 'days' + ? IntRange<1, 32> + : T extends 'months' + ? IntRange<1, 13> + : never; + +const UNIT_FIELD_INDEX: Record = { + seconds: 0, + minutes: 1, + hours: 2, + days: 3, + months: 4 +}; + +const DAY_VALUES: Record = { + Sunday: 0, + Monday: 1, + Tuesday: 2, + Wednesday: 3, + Thursday: 4, + Friday: 5, + Saturday: 6 +}; + +const MONTH_VALUES: Record = { + January: 1, + February: 2, + March: 3, + April: 4, + May: 5, + June: 6, + July: 7, + August: 8, + September: 9, + October: 10, + November: 11, + December: 12 +}; + +// eslint-disable-next-line jsdoc/require-jsdoc +function withField( + fields: CronFields, + index: number, + value: string +): CronFields { + const next: CronFields = [...fields]; + next[index] = value; + return next; +} + +/** + * Utility class for creating cron expressions with guardrails. + */ +export class CronExpression { + private readonly _fields: CronFields; + + private constructor(fields: CronFields) { + this._fields = fields; + } + + private static blank(): CronExpression { + return new CronExpression(['*', '*', '*', '*', '*', '*']); + } + + // ── Static entry points ────────────────────────────────────────────────── + + /** + * Runs every N units. + * @param value - The interval at which to run. + * @param unit - The unit, like 'minutes', 'hours', 'days' etc. + * @example + * CronExpression.every(5, 'minutes') // "* *\/5 * * * *" + * CronExpression.every(2, 'hours') // "* * *\/2 * * *" + * @returns A cron expression to either continue working with or building the result string. + */ + static every(value: RangeForCronUnit, unit: T): CronExpression { + const idx: number = UNIT_FIELD_INDEX[unit]; + const field: string = value === 1 ? '*' : `*/${value}`; + return new CronExpression( + withField(CronExpression.blank()._fields, idx, field) + ); + } + + /** + * Runs once a day. Default: midnight (00:00:00). + * Chain `.at()` to set the time. + * @example + * CronExpression.daily().at(9, 'hours').at(30, 'minutes') // "0 30 9 * * *" + * @returns A cron expression to either continue working with or building the result string. + */ + static daily(): CronExpression { + return new CronExpression(['0', '0', '0', '*', '*', '*']); + } + + /** + * Runs once a week. Default: Sunday midnight. + * Chain `.on()` and `.at()` to customize. + * @example + * CronExpression.weekly().on('Monday').at(8, 'hours') // "0 0 8 * * 1" + * @returns A cron expression to either continue working with or building the result string. + */ + static weekly(): CronExpression { + return new CronExpression(['0', '0', '0', '*', '*', '0']); + } + + /** + * Runs once a month. Default: 1st of the month at midnight. + * @example + * CronExpression.monthly().at(15, 'days').at(6, 'hours') // "0 0 6 15 * *" + * @returns A cron expression to either continue working with or building the result string. + */ + static monthly(): CronExpression { + return new CronExpression(['0', '0', '0', '1', '*', '*']); + } + + /** + * Builds a CronExpression from a raw node-cron string (for interop / parsing). + * @param expression - The raw expression too build from. + * @returns The final CronExpressionString. + * @throws If there are more than 6 parts provided. + */ + static fromString(expression: string): CronExpressionString { + const parts: string[] = expression.trim().split(/\s+/); + if (parts.length !== 6) { + throw new Error( + `Expected 6 fields for node-cron expression, got ${parts.length}: "${expression}"` + ); + } + return new CronExpression(parts as CronFields).build(); + } + + // ── Instance modifiers ─────────────────────────────────────────────────── + + /** + * Fixes a specific field to a concrete value. + * @param value - The concrete value. + * @param unit - The unit, like 'minutes', 'hours', 'days' etc. + * @example + * CronExpression.every(1, 'hours').at(30, 'minutes') // "* 30 * * * *" (at :30 past every hour) + * CronExpression.daily().at(9, 'hours') // "0 0 9 * * *" + * @returns A cron expression to either continue working with or building the result string. + */ + at(value: RangeForCronUnit, unit: T): CronExpression { + const idx: number = UNIT_FIELD_INDEX[unit]; + return new CronExpression(withField(this._fields, idx, String(value))); + } + + /** + * Restricts execution to a range of values for a given unit. + * @param from - The value at which the range starts. + * @param to - The value at which the range ends. + * @param unit - The unit, like 'minutes', 'hours', 'days' etc. + * @example + * CronExpression.every(1, 'minutes').between(9, 17, 'hours') // "* * 9-17 * * *" (business hours only) + * @returns A cron expression to either continue working with or building the result string. + * @throws If the "from" value is smaller than the "to" value. + */ + between(from: RangeForCronUnit, to: RangeForCronUnit, unit: T): CronExpression { + if (from >= to) { + throw new RangeError(`'from' (${from}) must be less than 'to' (${to})`); + } + const idx: number = UNIT_FIELD_INDEX[unit]; + return new CronExpression( + withField(this._fields, idx, `${from}-${to}`) + ); + } + + /** + * Restricts execution to specific days of the week. + * @param days - The days on which the execution should happen. + * @example + * CronExpression.daily().on('Monday', 'Wednesday', 'Friday') // "0 0 0 * * 1,3,5" + * @returns A cron expression to either continue working with or building the result string. + */ + on(...days: [DayOfWeek, ...DayOfWeek[]]): CronExpression { + const value: string = days.map(d => DAY_VALUES[d]).join(','); + return new CronExpression(withField(this._fields, 5, value)); + } + + /** + * Restricts execution to specific months. + * @param months - The months in which the execution should happen. + * @example + * CronExpression.monthly().in('March', 'June', 'September', 'December') // "0 0 0 1 3,6,9,12 *" + * @returns A cron expression to either continue working with or building the result string. + */ + in(...months: [MonthName, ...MonthName[]]): CronExpression { + const value: string = months.map(m => MONTH_VALUES[m]).join(','); + return new CronExpression(withField(this._fields, 4, value)); + } + + // ── Output ─────────────────────────────────────────────────────────────── + + /** + * Builds the final result from the builder input. + * @returns The branded node-cron expression string. + */ + build(): CronExpressionString { + return this._fields.join(' ') as CronExpressionString; + } +} \ No newline at end of file diff --git a/src/cron/cron-job-entity.model.ts b/src/cron/cron-job-entity.model.ts index a86406d..7b86f26 100644 --- a/src/cron/cron-job-entity.model.ts +++ b/src/cron/cron-job-entity.model.ts @@ -1,3 +1,4 @@ +import { type CronExpressionString } from './cron-expression.utilities'; import { BaseEntity } from '../entity/base-entity.model'; import { Entity } from '../entity/decorators/entity.decorator'; import { Property } from '../entity/decorators/property.decorator'; @@ -18,7 +19,7 @@ export class CronJobEntity extends BaseEntity { * The cron expression. */ @Property.string() - cron!: string; + cron!: CronExpressionString; /** * Whether or not the cron job is currently active. diff --git a/src/cron/cron-job.model.ts b/src/cron/cron-job.model.ts index 875c119..3c4e27b 100644 --- a/src/cron/cron-job.model.ts +++ b/src/cron/cron-job.model.ts @@ -1,12 +1,10 @@ import cron, { ScheduledTask } from 'node-cron'; +import { type CronExpressionString } from './cron-expression.utilities'; import { CreateCronJobEntityData, CronJobEntity } from './cron-job-entity.model'; import { CronUpdateData } from './cron.service'; import { Repository } from '../data-source/repository'; -import { repositoryTokenFor } from '../di/decorators/inject-repository.decorator'; -import { ZIBRI_DI_TOKENS } from '../di/default/zibri-di-tokens.default'; -import { inject } from '../di/inject.function'; import { unknownToErrorString } from '../error-handling/unknown-to-error-string.function'; import { type LoggerInterface } from '../logging/logger.interface'; import { OmitStrict } from '../types/omit-strict.type'; @@ -53,12 +51,12 @@ export abstract class CronJob { /** * The repository for syncing cron jobs back and forth to the db. */ - protected readonly cronJobRepository: Repository; + protected cronJobRepository!: Repository; /** * A logger instance. */ - protected readonly logger: LoggerInterface; + protected logger!: LoggerInterface; // eslint-disable-next-line jsdoc/require-returns /** @@ -97,15 +95,16 @@ export abstract class CronJob { return this.entity.active; } - constructor(protected readonly overrideName?: string) { - this.cronJobRepository = inject(repositoryTokenFor(CronJobEntity)); - this.logger = inject(ZIBRI_DI_TOKENS.LOGGER); - } + constructor(protected readonly overrideName?: string) {} /** * Initializes the cron job. + * @param logger - A logger instance. + * @param repo - The cron job repository to sync to a data source. */ - async init(): Promise { + async init(logger: LoggerInterface, repo: Repository): Promise { + this.logger = logger; + this.cronJobRepository = repo; if (this.entity) { throw new Error('the cron job has already been initialized.'); } @@ -245,7 +244,7 @@ export abstract class CronJob { * Changes the cron expression. * @param cronExpression - The new cron expression to change to. */ - async changeCron(cronExpression: string): Promise { + async changeCron(cronExpression: CronExpressionString): Promise { if (!this.entity || !this.task) { throw new Error(NOT_INITIALIZED_MESSAGE); } diff --git a/src/cron/cron-service.interface.ts b/src/cron/cron-service.interface.ts index ed3bc81..ed85586 100644 --- a/src/cron/cron-service.interface.ts +++ b/src/cron/cron-service.interface.ts @@ -1,3 +1,4 @@ +import { CronExpressionString } from './cron-expression.utilities'; import { CronJob } from './cron-job.model'; import { CronUpdateData } from './cron.service'; @@ -24,7 +25,7 @@ export interface CronServiceInterface { /** * Changes the cron expression of the cron job with the given name. */ - changeCron: (name: string, cron: string) => Promise, + changeCron: (name: string, cron: CronExpressionString) => Promise, /** * Updates the cron job with the given name. */ diff --git a/src/cron/cron.service.ts b/src/cron/cron.service.ts index eba27f3..179538a 100644 --- a/src/cron/cron.service.ts +++ b/src/cron/cron.service.ts @@ -2,6 +2,8 @@ import { CronJobEntity } from './cron-job-entity.model'; import { CronJob } from './cron-job.model'; import { CronServiceInterface } from './cron-service.interface'; import { ZibriApplication } from '../application'; +import { CronExpressionString } from './cron-expression.utilities'; +import { repositoryTokenFor } from '../di/decorators/inject-repository.decorator'; import { Inject } from '../di/decorators/inject.decorator'; import { Injectable } from '../di/decorators/injectable.decorator'; import { ZIBRI_DI_TOKENS } from '../di/default/zibri-di-tokens.default'; @@ -42,7 +44,7 @@ export class CronService implements CronServiceInterface, AfterAppInit, BeforeAp for (const cronJobClass of cronJobs) { register({ token: cronJobClass, useClass: cronJobClass }); const cronJob: CronJob = inject(cronJobClass); - await cronJob.init(); + await cronJob.init(this.logger, inject(repositoryTokenFor(CronJobEntity))); await this.logger.info(` - ${cronJobClass.name} (${cronJob.active ? 'active' : 'not active'})`); this.cronJobs.push(cronJob); } @@ -55,7 +57,7 @@ export class CronService implements CronServiceInterface, AfterAppInit, BeforeAp // eslint-disable-next-line jsdoc/require-jsdoc async schedule(cronJob: CronJob): Promise { - await cronJob.init(); + await cronJob.init(this.logger, inject(repositoryTokenFor(CronJobEntity))); this.cronJobs.push(cronJob); } @@ -78,7 +80,7 @@ export class CronService implements CronServiceInterface, AfterAppInit, BeforeAp } // eslint-disable-next-line jsdoc/require-jsdoc - async changeCron(name: string, cron: string): Promise { + async changeCron(name: string, cron: CronExpressionString): Promise { const foundJob: CronJob | undefined = this.cronJobs.find(c => c.name === name); if (!foundJob) { throw new Error(`Could not find cron job with name ${name}`); diff --git a/src/data-source/data-sources/data-source.interface.ts b/src/data-source/data-sources/data-source.interface.ts index cefce79..c3f2a4e 100644 --- a/src/data-source/data-sources/data-source.interface.ts +++ b/src/data-source/data-sources/data-source.interface.ts @@ -1,5 +1,3 @@ -import { IsolationLevel } from 'typeorm/driver/types/IsolationLevel'; - import { BackupResourceInterface } from '../../backup/backup-resource.interface'; import { BaseEntity } from '../../entity/base-entity.model'; import { PropertyMetadataInput, PropertyMetadata, RelationMetadata } from '../../entity/decorators/property.decorator'; @@ -10,6 +8,16 @@ import { Migration } from '../migration/migration.model'; import { Repository } from '../repository'; import { Transaction } from '../transaction/transaction.model'; +/** + * The isolation level of any data source calls made inside a transaction. + */ +export enum IsolationLevel { + READ_UNCOMMITTED = 'READ UNCOMMITTED', + READ_COMMITTED = 'READ COMMITTED', + REPEATABLE_READ = 'REPEATABLE READ', + SERIALIZABLE = 'SERIALIZABLE' +} + /** * Definition for a data source. */ diff --git a/src/data-source/data-sources/postgres-data-source.model.ts b/src/data-source/data-sources/postgres-data-source.model.ts index 190de1f..ee3619f 100644 --- a/src/data-source/data-sources/postgres-data-source.model.ts +++ b/src/data-source/data-sources/postgres-data-source.model.ts @@ -3,12 +3,11 @@ import { PassThrough, Readable, Writable } from 'node:stream'; import { DataSource as TODataSource, Repository as TORepository, EntityMetadata as TOEntityMetadata, EntitySchema, EntitySchemaColumnOptions, QueryRunner, EntitySchemaRelationOptions, Table, TableColumnOptions, TableColumn, EntityTarget } from 'typeorm'; import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions.js'; -import { IsolationLevel } from 'typeorm/driver/types/IsolationLevel.js'; import { ColumnMetadata } from 'typeorm/metadata/ColumnMetadata.js'; import { OnDeleteType } from 'typeorm/metadata/types/OnDeleteType.js'; import { OnUpdateType } from 'typeorm/metadata/types/OnUpdateType.js'; -import { DataSourceInterface } from './data-source.interface'; +import { DataSourceInterface, IsolationLevel } from './data-source.interface'; import { ChangeSetRepository } from '../../change-sets/change-set-repository'; import { isChangeSetEntityNewable, ChangeSetEntity } from '../../change-sets/models/change-set-entity.model'; import { isSoftDeleteEntityNewable, SoftDeleteEntity } from '../../change-sets/models/soft-delete-entity.model'; @@ -397,17 +396,19 @@ export abstract class PostgresDataSource implements DataSourceInterface { return new SoftDeleteRepository( cls, repo as unknown as TORepository, - this.logger + this.logger, + this ) as unknown as Repository; } if (isChangeSetEntityNewable(cls)) { return new ChangeSetRepository( cls, repo as unknown as TORepository, - this.logger + this.logger, + this ) as unknown as Repository; } - return new Repository(cls, repo, this.logger); + return new Repository(cls, repo, this.logger, this); } // eslint-disable-next-line jsdoc/require-jsdoc diff --git a/src/data-source/repository.ts b/src/data-source/repository.ts index 008ac0d..d2395b1 100644 --- a/src/data-source/repository.ts +++ b/src/data-source/repository.ts @@ -1,10 +1,11 @@ -import { Repository as TORepository, FindOptionsWhere, EntityManager, QueryFailedError as TOQueryFailedError } from 'typeorm'; +import { Repository as TORepository, FindOptionsWhere, EntityManager, QueryFailedError as TOQueryFailedError, DeepPartial as ToDeepPartial } from 'typeorm'; import { BaseEntity } from '../entity/base-entity.model'; import { LoggerInterface } from '../logging/logger.interface'; import { PaginationResult } from '../open-api/pagination-result.model'; import { DeepPartial } from '../types/deep-partial.type'; import { Newable } from '../types/newable.type'; +import { DataSourceInterface } from './data-sources/data-source.interface'; import { CreateAllOptions } from './models/options/create-all-options.model'; import { CreateOptions } from './models/options/create-options.model'; import { DeleteAllOptions } from './models/options/delete-all-options.model'; @@ -35,10 +36,19 @@ export class Repository< > { private readonly typeOrmRepository: TORepository; + // eslint-disable-next-line jsdoc/require-returns + /** + * The data source that this repository is connected to. + */ + get dataSource(): DataSourceInterface { + return this._dataSource; + } + constructor( protected readonly entityClass: Newable, repo: TORepository | Repository, - protected readonly logger: LoggerInterface + protected readonly logger: LoggerInterface, + private readonly _dataSource: DataSourceInterface ) { this.typeOrmRepository = repo instanceof Repository ? repo.typeOrmRepository : repo; ModelRegistry.get(this.entityClass); @@ -70,7 +80,7 @@ export class Repository< const manager: EntityManager = this.getManager(options?.transaction); try { - const res: T = await manager.save(this.entityClass, data); + const res: T = await manager.save(this.entityClass, data as ToDeepPartial); await removeExcludeProperties(res, this.entityClass); return res; } @@ -108,7 +118,7 @@ export class Repository< const manager: EntityManager = this.getManager(options?.transaction); try { - const res: T[] = await manager.save(this.entityClass, data); + const res: T[] = await manager.save(this.entityClass, data as ToDeepPartial[]); await Promise.all(res.map(r => removeExcludeProperties(r, this.entityClass))); return res; } @@ -242,7 +252,7 @@ export class Repository< await this.beforeSave(data, false); try { - const res: T = await manager.save(this.entityClass, data); + const res: T = await manager.save(this.entityClass, data as ToDeepPartial); await removeExcludeProperties(res, this.entityClass); return res; } @@ -276,7 +286,7 @@ export class Repository< const manager: EntityManager = this.getManager(options?.transaction); try { - const res: T[] = await manager.save(this.entityClass, toUpdate); + const res: T[] = await manager.save(this.entityClass, toUpdate as ToDeepPartial[]); await Promise.all(res.map(r => removeExcludeProperties(r, this.entityClass))); return res; } diff --git a/src/di/default/zibri-di-providers.default.ts b/src/di/default/zibri-di-providers.default.ts index 7f1a1bb..cf91ddc 100644 --- a/src/di/default/zibri-di-providers.default.ts +++ b/src/di/default/zibri-di-providers.default.ts @@ -12,6 +12,9 @@ import { AuthService } from '../../auth/auth.service'; import { UserService } from '../../auth/user/user.service'; import { BackupService } from '../../backup/backup.service'; import { AlsUtilities } from '../../context/als.utilities'; +import { HttpRequestContext } from '../../context/request/http-request.context'; +import { ZIBRI_REQUEST_CONTEXT_TOKENS } from '../../context/request/request-context-token.model'; +import { WebsocketRequestContext } from '../../context/request/websocket-request.context'; import { CronService } from '../../cron/cron.service'; import { DataSourceService } from '../../data-source/data-source.service'; import { EmailService } from '../../email/email.service'; @@ -25,6 +28,7 @@ import { LoggerTransport } from '../../logging/transport/logger-transport.model' import { PrometheusMetricsService } from '../../metrics/metrics.service'; import { MultithreadingService } from '../../multithreading/services/multithreading.service'; import { OpenApiService } from '../../open-api/open-api.service'; +import { CspSource } from '../../parsing/html/csp-options.model'; import { Parser } from '../../parsing/parser'; import { Router } from '../../routing/router'; import { FsUtilities } from '../../utilities/fs.utilities'; @@ -77,7 +81,7 @@ export const ZIBRI_DI_PROVIDERS: DiTokenProviderRecord = USER_SERVICE: { useClass: UserService }, JWT_ACCESS_TOKEN_SECRET: { useFactory: () => undefined }, JWT_REFRESH_TOKEN_SECRET: { useFactory: () => undefined }, - JWT_PASSWORD_RESET_EMAIL_TEMPLATE: { useFactory: () => undefined }, + PASSWORD_RESET_EMAIL_TEMPLATE: { useFactory: () => undefined }, JWT_ACCESS_TOKEN_EXPIRES_IN_MS: { useFactory: () => Ms.HOUR }, JWT_REFRESH_TOKEN_EXPIRES_IN_MS: { useFactory: () => 100 * Ms.DAY }, CRON_SERVICE: { useClass: CronService }, @@ -98,8 +102,8 @@ export const ZIBRI_DI_PROVIDERS: DiTokenProviderRecord = FORMAT_PRICE: { useFactory: () => formatPrice }, FORMAT_PERCENT: { useFactory: () => formatPercent }, EMAIL_CONFIG: { useFactory: () => undefined }, - JWT_PASSWORD_RESET_TOKEN_EXPIRES_IN_MS: { useFactory: () => 300000 }, - JWT_CONFIRM_PASSWORD_RESET_URL: { useFactory: () => undefined }, + PASSWORD_RESET_TOKEN_EXPIRES_IN_MS: { useFactory: () => 300000 }, + CONFIRM_PASSWORD_RESET_URL: { useFactory: () => undefined }, MULTITHREADING_OPTIONS: { useFactory: () => ({ maxThreads, @@ -113,9 +117,53 @@ export const ZIBRI_DI_PROVIDERS: DiTokenProviderRecord = WEBSOCKET_OPTIONS: { useFactory: () => ({ timeoutInMs: Ms.SECOND * 5, isAllowedToConnect: () => true }) }, HTTP_CLIENT: { useClass: HttpClient }, EVENT_SERVICE: { useClass: EventService }, + CORRELATION_ID_HEADER: { useValue: 'x-correlation-id' }, + CSRF_TOKEN_HEADER: { useValue: 'x-csrf-token' }, + COOKIE_AUTH_SESSION_OPTIONS: { + useValue: { + name: 'sessionId', + sameSite: 'lax', + path: '/' + } + }, + COOKIE_AUTH_REFRESH_SESSION_OPTIONS: { + useValue: { + name: 'refreshSessionId', + sameSite: 'lax', + path: '/' + } + }, + COOKIE_SIGN_SECRET: { useValue: undefined }, + COOKIE_AUTH_SESSION_EXPIRES_IN_MS: { useValue: Ms.DAY }, + COOKIE_AUTH_REFRESH_SESSION_EXPIRES_IN_MS: { useValue: Ms.DAY * 100 }, // dynamic CURRENT_REQUEST_CONTEXT: { useFactory: () => AlsUtilities.getCurrentRequestContext(), cache: false + }, + DEFAULT_CSP_OPTIONS: { + useFactory: () => { + const context: HttpRequestContext | WebsocketRequestContext | undefined = inject(ZIBRI_DI_TOKENS.CURRENT_REQUEST_CONTEXT); + const nonce: string | undefined = context?.get(ZIBRI_REQUEST_CONTEXT_TOKENS.NONCE); + const nonceSrc: CspSource | undefined = nonce ? `'nonce-${nonce}'` : undefined; + return { + baseUri: ['\'self\''], + connectSrc: [], + defaultSrc: ['\'self\''], + fontSrc: [], + formAction: ['\'self\''], + frameAncestors: ['\'self\''], + imgSrc: [], + mediaSrc: [], + objectSrc: ['\'none\''], + scriptSrc: [ + '\'self\'', + ...nonceSrc ? [nonceSrc] : [] + ], + scriptSrcAttr: ['\'none\''], + styleSrc: [] + }; + }, + cache: false } }; \ No newline at end of file diff --git a/src/di/default/zibri-di-tokens.default.ts b/src/di/default/zibri-di-tokens.default.ts index edeb4b1..702ee3e 100644 --- a/src/di/default/zibri-di-tokens.default.ts +++ b/src/di/default/zibri-di-tokens.default.ts @@ -1,6 +1,7 @@ import { AssetServiceInterface } from '../../assets/asset-service.interface'; import { TwoFactorServiceInterface } from '../../auth/2fa/two-factor-service.interface'; import { AuthServiceInterface } from '../../auth/auth-service.interface'; +import { CookieAuthSessionOptionsInput } from '../../auth/strategies/cookie/cookie-auth.auth-strategy'; import { PasswordResetEmailTemplate } from '../../auth/strategies/jwt/jwt-auth.controller'; import { UserServiceInterface } from '../../auth/user/user-service.interface'; import { BackupServiceInterface } from '../../backup/backup-service.interface'; @@ -24,6 +25,7 @@ import { MetricsServiceInterface } from '../../metrics/metrics-service.interface import { MultithreadingOptions } from '../../multithreading/models/multithreading-options.model'; import { MultithreadingServiceInterface } from '../../multithreading/services/multithreading-service.interface'; import { OpenApiServiceInterface } from '../../open-api/open-api-service.interface'; +import { CspOptions } from '../../parsing/html/csp-options.model'; import { ParserInterface } from '../../parsing/parser.interface'; import { RouterInterface } from '../../routing/router.interface'; import { FsPath } from '../../utilities/fs.utilities'; @@ -65,10 +67,14 @@ export const ZIBRI_DI_TOKENS = { JWT_ACCESS_TOKEN_EXPIRES_IN_MS: ziToken('zi.jwt_access_token_expires_in_ms'), JWT_REFRESH_TOKEN_SECRET: ziToken('zi.jwt_refresh_token_secret'), JWT_REFRESH_TOKEN_EXPIRES_IN_MS: ziToken('zi.jwt_refresh_token_expires_in_ms'), - JWT_PASSWORD_RESET_TOKEN_EXPIRES_IN_MS: ziToken('zi.jwt_password_reset_token_expires_in_ms'), - JWT_CONFIRM_PASSWORD_RESET_URL: ziToken('zi.jwt_confirm_password_reset_url'), + COOKIE_AUTH_SESSION_OPTIONS: ziToken('zi.cookie_auth_session_options'), + COOKIE_AUTH_REFRESH_SESSION_OPTIONS: ziToken('zi.cookie_auth_refresh_session_options'), + COOKIE_AUTH_SESSION_EXPIRES_IN_MS: ziToken('zi.cookie_auth_session_expires_in_ms'), + COOKIE_AUTH_REFRESH_SESSION_EXPIRES_IN_MS: ziToken('zi.cookie_auth_refresh_session_expires_in_ms'), + PASSWORD_RESET_TOKEN_EXPIRES_IN_MS: ziToken('zi.password_reset_token_expires_in_ms'), + CONFIRM_PASSWORD_RESET_URL: ziToken('zi.confirm_password_reset_url'), // eslint-disable-next-line typescript/no-explicit-any - JWT_PASSWORD_RESET_EMAIL_TEMPLATE: ziToken | undefined>('zi.jwt_password_reset_email_template'), + PASSWORD_RESET_EMAIL_TEMPLATE: ziToken | undefined>('zi.password_reset_email_template'), USER_SERVICE: ziToken('zi.user_service'), CRON_SERVICE: ziToken('zi.cron_service'), FILE_UPLOAD_TEMP_FOLDER: ziToken('zi.file_upload_temp_folder'), @@ -86,6 +92,10 @@ export const ZIBRI_DI_TOKENS = { WEBSOCKET_OPTIONS: ziToken('zi.websocket_options'), HTTP_CLIENT: ziToken('zi.http_client'), EVENT_SERVICE: ziToken>>('zi.event_service'), + CORRELATION_ID_HEADER: ziToken('zi.correlation_id_header'), + CSRF_TOKEN_HEADER: ziToken('zi.csrf_token_header'), + DEFAULT_CSP_OPTIONS: ziToken('zi.default_csp_options'), + COOKIE_SIGN_SECRET: ziToken('zi.cookie_sign_secret'), // dynamic/context based tokens CURRENT_REQUEST_CONTEXT: ziToken('zi.current_request_context') } as const satisfies TokenRecord; \ No newline at end of file diff --git a/src/di/di-container.ts b/src/di/di-container.ts index 1c358fb..7bbcb0b 100644 --- a/src/di/di-container.ts +++ b/src/di/di-container.ts @@ -123,9 +123,12 @@ export class DiContainer { } private createInstanceFromProvider(provider: DiProvider, resolvingStack: Function[]): T { + if ('useValue' in provider) { + return provider.useValue as T; + } + const provide: Newable | ((...deps: unknown[]) => T) | T | undefined = provider.useClass - ?? provider.useFactory - ?? provider.useValue; + ?? provider.useFactory; if (provide == undefined) { throw new Error(`Provider for ${provider.token.toString()} is invalid`); @@ -155,12 +158,9 @@ export class DiContainer { if (provider.useClass) { return new provider.useClass(...deps); } - if (provider.useFactory) { + if (provider.useFactory != undefined) { return provider.useFactory(...deps); } - if ('useValue' in provider) { - return provider.useValue; - } throw new Error(`Provider for ${(provider as DiProvider).token.toString()} is invalid`); } diff --git a/src/email/email.service.ts b/src/email/email.service.ts index 6b23ac7..43bafa9 100644 --- a/src/email/email.service.ts +++ b/src/email/email.service.ts @@ -60,7 +60,9 @@ export class EmailService implements EmailServiceInterface, OnAppInit, OnAppShut // eslint-disable-next-line jsdoc/require-jsdoc onAppInit(app: ZibriApplication): void { - app.options.cronJobs.push(SendQueuedEmailsCronJob); + if (!app.options.cronJobs.includes(SendQueuedEmailsCronJob)) { + app.options.cronJobs.push(SendQueuedEmailsCronJob); + } } // eslint-disable-next-line jsdoc/require-jsdoc diff --git a/src/email/send-queued-emails.cron-job.ts b/src/email/send-queued-emails.cron-job.ts index c10e0f8..e2b33dd 100644 --- a/src/email/send-queued-emails.cron-job.ts +++ b/src/email/send-queued-emails.cron-job.ts @@ -1,4 +1,5 @@ import { type EmailServiceInterface } from './email-service.interface'; +import { CronExpression } from '../cron/cron-expression.utilities'; import { CronJob, InitialCronConfig } from '../cron/cron-job.model'; import { Inject } from '../di/decorators/inject.decorator'; import { ZIBRI_DI_TOKENS } from '../di/default/zibri-di-tokens.default'; @@ -10,7 +11,7 @@ export class SendQueuedEmailsCronJob extends CronJob { // eslint-disable-next-line jsdoc/require-jsdoc initialConfig: InitialCronConfig = { name: 'send queued emails', - cron: '*/5 * * * * *', + cron: CronExpression.every(5, 'seconds').build(), runOnInit: false }; diff --git a/src/event/event-cleanup.cron-job.ts b/src/event/event-cleanup.cron-job.ts index fcba9ed..0d27a96 100644 --- a/src/event/event-cleanup.cron-job.ts +++ b/src/event/event-cleanup.cron-job.ts @@ -1,5 +1,6 @@ import { Event, EventStatus } from './event.model'; +import { CronExpression } from '../cron/cron-expression.utilities'; import { CronJob, InitialCronConfig } from '../cron/cron-job.model'; import { Repository } from '../data-source/repository'; import { InjectRepository } from '../di/decorators/inject-repository.decorator'; @@ -11,7 +12,7 @@ export class EventCleanupCronJob extends CronJob { // eslint-disable-next-line jsdoc/require-jsdoc initialConfig: InitialCronConfig = { name: 'Event Cleanup', - cron: '0 0 * * *', + cron: CronExpression.daily().build(), runOnInit: false }; diff --git a/src/event/event.service.ts b/src/event/event.service.ts index 7e43dde..b55d263 100644 --- a/src/event/event.service.ts +++ b/src/event/event.service.ts @@ -68,7 +68,9 @@ implements EventServiceInterface, OnAppInit, OnAppStart, AfterAppShutdo // eslint-disable-next-line jsdoc/require-jsdoc onAppInit(app: ZibriApplication): void { validateEntitiesRegistered('EventService', app, Event, EventSubscriberRun); - app.options.cronJobs.push(EventCleanupCronJob); + if (!app.options.cronJobs.includes(EventCleanupCronJob)) { + app.options.cronJobs.push(EventCleanupCronJob); + } } // eslint-disable-next-line jsdoc/require-jsdoc diff --git a/src/http/cookie-options.model.ts b/src/http/cookie-options.model.ts new file mode 100644 index 0000000..006d64e --- /dev/null +++ b/src/http/cookie-options.model.ts @@ -0,0 +1,11 @@ +import { CookieOptions as ExpressCookieOptions } from 'express'; + +/** + * Options for a cookie. + */ +export type CookieOptions = ExpressCookieOptions & { + /** + * The name of the cookie. + */ + name: string +}; \ No newline at end of file diff --git a/src/http/http-request.model.ts b/src/http/http-request.model.ts index e93bded..376e9d1 100644 --- a/src/http/http-request.model.ts +++ b/src/http/http-request.model.ts @@ -1,5 +1,6 @@ import { Request } from 'express'; +import { HttpMethod } from './http-method.enum'; import { KnownHeader } from './known-header.enum'; import { OmitStrict } from '../types/omit-strict.type'; @@ -12,7 +13,7 @@ export type HttpRequest< QueryParamsObject extends Record = Record, HeaderParamsObject extends Record = Partial> // eslint-disable-next-line typescript/no-explicit-any -> = OmitStrict, any, T>, 'query' | 'headers' | 'params'> & { +> = OmitStrict, any, T>, 'query' | 'headers' | 'params' | 'method'> & { /** * The path parameters of the request, as an object. */ @@ -24,7 +25,11 @@ export type HttpRequest< /** * The header parameters of the request, as an object. */ - headers: HeaderParamsObject + headers: HeaderParamsObject, + /** + * The http method of the request. + */ + method: HttpMethod }; /** diff --git a/src/http/known-header.enum.ts b/src/http/known-header.enum.ts index 9b7711b..ddd3c44 100644 --- a/src/http/known-header.enum.ts +++ b/src/http/known-header.enum.ts @@ -11,17 +11,21 @@ export enum KnownHeader { CONTENT_LENGTH = 'content-length', CONTENT_TYPE = 'content-type', CONTENT_DISPOSITION = 'content-disposition', + CONTENT_SECURITY_POLICY = 'content-security-policy', COOKIE = 'cookie', HOST = 'host', + STRICT_TRANSPORT_SECURITY = 'strict-transport-security', ORIGIN = 'origin', REFERER = 'referer', + REFERRER_POLICY = 'referrer-policy', USER_AGENT = 'user-agent', + X_FRAME_OPTIONS = 'x-frame-options', + X_CONTENT_TYPE_OPTIONS = 'x-content-type-options', X_REQUESTED_WITH = 'x-requested-with', X_FORWARDED_FOR = 'x-forwarded-for', X_FORWARDED_HOST = 'x-forwarded-host', X_FORWARDED_PROTO = 'x-forwarded-proto', X_REAL_IP = 'x-real-ip', - X_CORRELATION_ID = 'x-correlation-id', IF_NONE_MATCH = 'if-none-match', IIF_MODIFIED_SINCE = 'if-modified-since', CONNECTION = 'connection', diff --git a/src/http/mime-type.enum.ts b/src/http/mime-type.enum.ts index 76a938a..8539f01 100644 --- a/src/http/mime-type.enum.ts +++ b/src/http/mime-type.enum.ts @@ -4,29 +4,68 @@ import { ExcludeStrict } from '../types/exclude-strict.type'; * All known mime types. */ export enum MimeType { + // Application JSON = 'application/json', XML = 'application/xml', - HTML = 'text/html', FORM_DATA = 'multipart/form-data', FORM_URL_ENCODED = 'application/x-www-form-urlencoded', OCTET_STREAM = 'application/octet-stream', + PDF = 'application/pdf', + ZIP = 'application/zip', + GZIP = 'application/gzip', + TAR = 'application/x-tar', + WASM = 'application/wasm', + JSON_LD = 'application/ld+json', + // eslint-disable-next-line cspell/spellchecker + NDJSON = 'application/x-ndjson', + XLSX = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + DOCX = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + // eslint-disable-next-line cspell/spellchecker + PPTX = 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + // Text + HTML = 'text/html', + CSS = 'text/css', + CSV = 'text/csv', + TXT = 'text/plain', + JAVASCRIPT = 'text/javascript', + YAML = 'text/yaml', + EVENT_STREAM = 'text/event-stream', + // Image PNG = 'image/png', JPEG = 'image/jpeg', - ZIP = 'application/zip', + GIF = 'image/gif', + WEBP = 'image/webp', SVG = 'image/svg+xml', - CSS = 'text/css', + ICO = 'image/x-icon', + BMP = 'image/bmp', + TIFF = 'image/tiff', + AVIF = 'image/avif', + // Audio + MP3 = 'audio/mpeg', + WAV = 'audio/wav', + OGG_AUDIO = 'audio/ogg', + WEBM_AUDIO = 'audio/webm', + // Video + MP4 = 'video/mp4', + WEBM_VIDEO = 'video/webm', + OGG_VIDEO = 'video/ogg', + // Font TTF = 'font/ttf', - PDF = 'application/pdf', - CSV = 'text/csv', - XLSX = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - DOCX = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - TXT = 'text/plain' + OTF = 'font/otf', + WOFF = 'font/woff', + WOFF2 = 'font/woff2' } /** * File mime types. */ -export type FileMimeType = ExcludeStrict; +export type FileMimeType = ExcludeStrict< + MimeType, + MimeType.OCTET_STREAM + | MimeType.FORM_DATA + | MimeType.FORM_URL_ENCODED + | MimeType.EVENT_STREAM +>; /** * File mime types with the possibility to provide custom mime types. diff --git a/src/http/mime-type.helpers.ts b/src/http/mime-type.helpers.ts index e6d6548..31a7202 100644 --- a/src/http/mime-type.helpers.ts +++ b/src/http/mime-type.helpers.ts @@ -4,42 +4,113 @@ import { ObjectUtilities } from '../utilities/object.utilities'; /** * All possible file extensions. */ -export type FileExtension = typeof mimeTypeToExtension[FileMimeType] | '.jpg'; +export type FileExtension = NonNullable<(typeof mimeTypeToExtensions)[keyof typeof mimeTypeToExtensions]>[number]; // eslint-disable-next-line typescript/typedef -const mimeTypeToExtension = { - [MimeType.JSON]: '.json', - [MimeType.HTML]: '.html', - [MimeType.PNG]: '.png', - [MimeType.JPEG]: '.jpeg', - [MimeType.ZIP]: '.zip', - [MimeType.SVG]: '.svg', - [MimeType.CSS]: '.css', - [MimeType.TTF]: '.ttf', - [MimeType.PDF]: '.pdf', - [MimeType.CSV]: '.csv', - [MimeType.XLSX]: '.xlsx', - [MimeType.DOCX]: '.docx', - [MimeType.TXT]: '.txt', - [MimeType.XML]: '.xml' -} satisfies Record | undefined>; +const mimeTypeToExtensions = { + [MimeType.JSON]: ['.json'], + [MimeType.HTML]: ['.html', '.htm'], + [MimeType.PNG]: ['.png'], + // eslint-disable-next-line cspell/spellchecker + [MimeType.JPEG]: ['.jpeg', '.jpg', '.jpe', '.jfif'], + [MimeType.ZIP]: ['.zip'], + // eslint-disable-next-line cspell/spellchecker + [MimeType.SVG]: ['.svg', '.svgz'], + [MimeType.CSS]: ['.css'], + [MimeType.TTF]: ['.ttf'], + [MimeType.PDF]: ['.pdf'], + [MimeType.CSV]: ['.csv'], + [MimeType.XLSX]: ['.xlsx'], + [MimeType.DOCX]: ['.docx'], + [MimeType.TXT]: ['.txt'], + [MimeType.XML]: ['.xml'], + [MimeType.GZIP]: ['.gz', '.gzip'], + [MimeType.TAR]: ['.tar'], + [MimeType.WASM]: ['.wasm'], + // eslint-disable-next-line cspell/spellchecker + [MimeType.JSON_LD]: ['.jsonld', '.json-ld'], + // eslint-disable-next-line cspell/spellchecker + [MimeType.NDJSON]: ['.ndjson', '.jsonl'], + [MimeType.PPTX]: ['.pptx'], + [MimeType.JAVASCRIPT]: ['.js', '.cjs', '.mjs'], + [MimeType.YAML]: ['.yaml', '.yml'], + [MimeType.GIF]: ['.gif'], + [MimeType.WEBP]: ['.webp'], + [MimeType.ICO]: ['.ico', '.cur'], + [MimeType.BMP]: ['.bmp', '.dib'], + [MimeType.TIFF]: ['.tif', '.tiff'], + [MimeType.AVIF]: ['.avif'], + [MimeType.MP3]: ['.mp3'], + [MimeType.WAV]: ['.wav'], + [MimeType.OGG_AUDIO]: ['.ogg', '.oga'], + [MimeType.WEBM_AUDIO]: ['.weba'], + [MimeType.MP4]: ['.mp4', '.m4v'], + [MimeType.WEBM_VIDEO]: ['.webm'], + [MimeType.OGG_VIDEO]: ['.ogv'], + [MimeType.OTF]: ['.otf'], + [MimeType.WOFF]: ['.woff'], + [MimeType.WOFF2]: ['.woff2'] +} satisfies Record[] | undefined>; const extensionToMimeType: Record = { - '.css': MimeType.CSS, + '.json': MimeType.JSON, + '.html': MimeType.HTML, + '.htm': MimeType.HTML, '.png': MimeType.PNG, - '.jpg': MimeType.JPEG, '.jpeg': MimeType.JPEG, - '.svg': MimeType.SVG, + '.jpg': MimeType.JPEG, + '.jpe': MimeType.JPEG, + // eslint-disable-next-line cspell/spellchecker + '.jfif': MimeType.JPEG, '.zip': MimeType.ZIP, + '.svg': MimeType.SVG, + // eslint-disable-next-line cspell/spellchecker + '.svgz': MimeType.SVG, + '.css': MimeType.CSS, '.ttf': MimeType.TTF, '.pdf': MimeType.PDF, '.csv': MimeType.CSV, '.xlsx': MimeType.XLSX, - '.json': MimeType.JSON, '.docx': MimeType.DOCX, '.txt': MimeType.TXT, - '.html': MimeType.HTML, - '.xml': MimeType.XML + '.xml': MimeType.XML, + '.gz': MimeType.GZIP, + '.gzip': MimeType.GZIP, + '.tar': MimeType.TAR, + '.wasm': MimeType.WASM, + // eslint-disable-next-line cspell/spellchecker + '.jsonld': MimeType.JSON_LD, + '.json-ld': MimeType.JSON_LD, + // eslint-disable-next-line cspell/spellchecker + '.ndjson': MimeType.NDJSON, + '.jsonl': MimeType.NDJSON, + '.pptx': MimeType.PPTX, + '.js': MimeType.JAVASCRIPT, + '.cjs': MimeType.JAVASCRIPT, + '.mjs': MimeType.JAVASCRIPT, + '.yaml': MimeType.YAML, + '.yml': MimeType.YAML, + '.gif': MimeType.GIF, + '.webp': MimeType.WEBP, + '.ico': MimeType.ICO, + '.cur': MimeType.ICO, + '.bmp': MimeType.BMP, + '.dib': MimeType.BMP, + '.tif': MimeType.TIFF, + '.tiff': MimeType.TIFF, + '.avif': MimeType.AVIF, + '.mp3': MimeType.MP3, + '.wav': MimeType.WAV, + '.ogg': MimeType.OGG_AUDIO, + '.oga': MimeType.OGG_AUDIO, + '.weba': MimeType.WEBM_AUDIO, + '.mp4': MimeType.MP4, + '.m4v': MimeType.MP4, + '.webm': MimeType.WEBM_VIDEO, + '.ogv': MimeType.OGG_VIDEO, + '.otf': MimeType.OTF, + '.woff': MimeType.WOFF, + '.woff2': MimeType.WOFF2 } as const; /** @@ -60,7 +131,7 @@ export function resolveMimeType(path: string): MimeType { * @returns The resolved file extension or undefined if it could not be resolved. */ export function resolveFileExtension(type: LooseFileMimeType): FileExtension | undefined { - const extension: FileExtension | undefined = mimeTypeToExtension[type as FileMimeType]; + const extension: FileExtension | undefined = mimeTypeToExtensions[type as FileMimeType]?.at(0); return extension; } diff --git a/src/index.ts b/src/index.ts index fe48401..4bde274 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,6 +37,19 @@ export * from './auth/strategies/jwt/jwt-refresh-token.model'; export * from './auth/strategies/jwt/jwt-request-password-reset-data.model'; export * from './auth/strategies/jwt/jwt-confirm-password-reset-data.model'; export * from './auth/strategies/jwt/jwt-auth.controller'; +export * from './auth/strategies/jwt/jwt-refresh-token-cleanup.cron-job'; + +export * from './auth/strategies/cookie/cookie-auth-confirm-password-reset-data.model'; +export * from './auth/strategies/cookie/cookie-auth-credentials.model'; +export * from './auth/strategies/cookie/cookie-auth-data.model'; +export * from './auth/strategies/cookie/cookie-auth-logout-data.model'; +export * from './auth/strategies/cookie/cookie-auth-refresh-login-data.model'; +export * from './auth/strategies/cookie/cookie-auth-refresh-session.model'; +export * from './auth/strategies/cookie/cookie-auth-request-password-reset-data.model'; +export * from './auth/strategies/cookie/cookie-auth-session-cleanup.cron-job'; +export * from './auth/strategies/cookie/cookie-auth-session.model'; +export * from './auth/strategies/cookie/cookie-auth.auth-strategy'; +export * from './auth/strategies/cookie/cookie-auth.controller'; export * from './auth/strategies/auth-strategy.interface'; export * from './auth/strategies/auth-strategies.model'; @@ -203,6 +216,7 @@ export * from './parsing/decorators/body-parser.decorator'; export * from './parsing/json/json.body-parser'; export * from './parsing/html/html-response.model'; +export * from './parsing/html/csp-options.model'; export * from './parsing/form-data/form-data.body-parser'; export * from './parsing/form-data/form-data.model'; export * from './parsing/form-data/file.model'; @@ -217,6 +231,7 @@ export * from './http/header.type'; export * from './http/http-request.model'; export * from './http/http-response.model'; export * from './http/mime-type.helpers'; +export * from './http/cookie-options.model'; // validation export * from './validation/validation-problem.model'; @@ -268,6 +283,7 @@ export * from './cron/cron-job-entity.model'; export * from './cron/cron-job.model'; export * from './cron/cron-service.interface'; export * from './cron/cron.service'; +export * from './cron/cron-expression.utilities'; // email export * from './email/email-service.interface'; @@ -305,6 +321,7 @@ export * from './metrics/gauge.interface'; export * from './metrics/histogram.interface'; export * from './metrics/metric-type.enum'; export * from './metrics/metric.model'; +export * from './metrics/collect-metrics.cron-job'; // change sets export * from './change-sets/change-set-repository'; @@ -457,7 +474,12 @@ export * from './http-client/http-client-response.model'; export * from './http-client/http-client.error'; // types +export * from './types/any-enum.type'; +export * from './types/deep-partial.type'; +export * from './types/exclude-strict.type'; export * from './types/newable.type'; +export * from './types/omit-strict.type'; +export * from './types/percentage.type'; export * from './types/version.type'; // utilities diff --git a/src/logging/log-cleanup.cron-job.ts b/src/logging/log-cleanup.cron-job.ts index 20d7d07..a533d9f 100644 --- a/src/logging/log-cleanup.cron-job.ts +++ b/src/logging/log-cleanup.cron-job.ts @@ -1,16 +1,17 @@ import { Log } from './log.model'; +import { CronExpression } from '../cron/cron-expression.utilities'; import { CronJob, InitialCronConfig } from '../cron/cron-job.model'; import { Repository } from '../data-source/repository'; import { InjectRepository } from '../di/decorators/inject-repository.decorator'; /** - * CronJob that cleans up the temp folder of the form data body parser. + * CronJob that cleans up expired logs. */ export class LogCleanupCronJob extends CronJob { // eslint-disable-next-line jsdoc/require-jsdoc initialConfig: InitialCronConfig = { name: 'Log Cleanup', - cron: '0 0 * * *', + cron: CronExpression.daily().build(), runOnInit: false }; diff --git a/src/logging/logger.ts b/src/logging/logger.ts index e0b7f14..2dea0a9 100644 --- a/src/logging/logger.ts +++ b/src/logging/logger.ts @@ -14,7 +14,6 @@ import { ZIBRI_DI_TOKENS } from '../di/default/zibri-di-tokens.default'; import { inject } from '../di/inject.function'; import { GlobalRegistry } from '../global/global-registry'; import { OnAppInit } from '../global/on-app-init.interface'; -import { HttpMethod } from '../http/http-method.enum'; import { KnownHeader } from '../http/known-header.enum'; import { UUIDUtilities } from '../utilities/uuid.utilities'; @@ -33,7 +32,9 @@ export class Logger implements LoggerInterface, OnAppInit { // eslint-disable-next-line jsdoc/require-jsdoc onAppInit(app: ZibriApplication): void { - app.options.cronJobs.push(LogCleanupCronJob); + if (!app.options.cronJobs.includes(LogCleanupCronJob)) { + app.options.cronJobs.push(LogCleanupCronJob); + } } // eslint-disable-next-line jsdoc/require-jsdoc @@ -77,7 +78,7 @@ export class Logger implements LoggerInterface, OnAppInit { status: requestContext.request.res?.statusCode, // TODO // durationInMs: currentRequest.res?.app, - method: requestContext.request.method as HttpMethod, + method: requestContext.request.method, url: requestContext.request.originalUrl, userAgent: requestContext.request.headers[KnownHeader.USER_AGENT] ?? '', clientIp: requestContext.request.ip ?? requestContext.request.socket?.remoteAddress ?? '' diff --git a/src/metrics/scrape-metrics.cron-job.ts b/src/metrics/collect-metrics.cron-job.ts similarity index 70% rename from src/metrics/scrape-metrics.cron-job.ts rename to src/metrics/collect-metrics.cron-job.ts index ed380be..2ebbc62 100644 --- a/src/metrics/scrape-metrics.cron-job.ts +++ b/src/metrics/collect-metrics.cron-job.ts @@ -1,16 +1,17 @@ import { type MetricsServiceInterface } from './metrics-service.interface'; +import { CronExpression } from '../cron/cron-expression.utilities'; import { CronJob, InitialCronConfig } from '../cron/cron-job.model'; import { Inject } from '../di/decorators/inject.decorator'; import { ZIBRI_DI_TOKENS } from '../di/default/zibri-di-tokens.default'; /** - * CronJob that cleans up the temp folder of the form data body parser. + * CronJob that collects metrics. */ -export class ScrapeMetricsCronJob extends CronJob { +export class CollectMetricsCronJob extends CronJob { // eslint-disable-next-line jsdoc/require-jsdoc initialConfig: InitialCronConfig = { - name: 'Scrape Metrics', - cron: '*/5 * * * * *', + name: 'Collect Metrics', + cron: CronExpression.every(5, 'seconds').build(), runOnInit: false }; @@ -23,7 +24,7 @@ export class ScrapeMetricsCronJob extends CronJob { // eslint-disable-next-line jsdoc/require-jsdoc async onTick(): Promise { - await this.logger.debug('scrapes metrics'); + await this.logger.debug('collects metrics'); await this.metricsService.collect(); } } \ No newline at end of file diff --git a/src/metrics/metrics.service.ts b/src/metrics/metrics.service.ts index 418ffb4..bc677e0 100644 --- a/src/metrics/metrics.service.ts +++ b/src/metrics/metrics.service.ts @@ -5,12 +5,12 @@ import si from 'systeminformation'; import { CounterMetricName, GaugeMetricName, HistogramMetricName, MetricsServiceInterface, MetricsSnapshot } from './metrics-service.interface'; import { ZibriApplication } from '../application'; +import { CollectMetricsCronJob } from './collect-metrics.cron-job'; import { CounterInterface } from './counter.interface'; import { GaugeInterface } from './gauge.interface'; import { HistogramInterface } from './histogram.interface'; import { MetricType } from './metric-type.enum'; import { Metric } from './metric.model'; -import { ScrapeMetricsCronJob } from './scrape-metrics.cron-job'; import { type AssetServiceInterface } from '../assets/asset-service.interface'; import { Inject } from '../di/decorators/inject.decorator'; import { Injectable } from '../di/decorators/injectable.decorator'; @@ -86,7 +86,9 @@ export class PrometheusMetricsService implements MetricsServiceInterface, OnAppI // eslint-disable-next-line jsdoc/require-jsdoc onAppInit(app: ZibriApplication): void { - app.options.cronJobs.push(ScrapeMetricsCronJob); + if (!app.options.cronJobs.includes(CollectMetricsCronJob)) { + app.options.cronJobs.push(CollectMetricsCronJob); + } app.use((req, res, next) => { const start: number = performance.now(); res.on('finish', () => { diff --git a/src/parsing/form-data/file-response.model.ts b/src/parsing/form-data/file-response.model.ts index 32eb42a..623b0ae 100644 --- a/src/parsing/form-data/file-response.model.ts +++ b/src/parsing/form-data/file-response.model.ts @@ -2,11 +2,13 @@ import { Readable } from 'stream'; import { ZIBRI_DI_TOKENS } from '../../di/default/zibri-di-tokens.default'; import { inject } from '../../di/inject.function'; -import { LooseFileMimeType } from '../../http/mime-type.enum'; +import { LooseFileMimeType, MimeType } from '../../http/mime-type.enum'; import { resolveMimeType } from '../../http/mime-type.helpers'; import { LoggerInterface } from '../../logging/logger.interface'; +import { DeepPartial } from '../../types/deep-partial.type'; import { OmitStrict } from '../../types/omit-strict.type'; import { FsUtilities, FsPath } from '../../utilities/fs.utilities'; +import { buildCspOptions, CspOptions } from '../html/csp-options.model'; /** * Data shared by all FileResponses. @@ -20,7 +22,12 @@ type BaseFileResponseData = { * The size of the file to send in bytes. * Used to set the Content-Length header. */ - size?: number + size?: number, + /** + * The configuration for CSP headers. + * Can either be false to not set any, true to set the default CSP headers or a custom configuration. + */ + csp?: boolean | DeepPartial }; /** @@ -61,7 +68,8 @@ export class FileResponse { readonly data: Readable | string, readonly filename: string, readonly mimeType: LooseFileMimeType, - readonly size: number | undefined + readonly size: number | undefined, + readonly csp: boolean | CspOptions ) {} /** @@ -84,8 +92,9 @@ export class FileResponse { } const size: number = options?.size ?? (await FsUtilities.stat(fullPath)).size; + const csp: boolean | CspOptions = buildCspOptions(options?.csp, this.getDefaultCsp(mimeType)); - return new this(fullPath, fileName, mimeType, size); + return new this(fullPath, fileName, mimeType, size, csp); } /** @@ -95,6 +104,14 @@ export class FileResponse { */ static fromStream(input: StreamFileResponseData): FileResponse { const mimeType: string = input.mimeType ?? resolveMimeType(input.filename); - return new this(input.stream, input.filename, mimeType, input.size); + const csp: boolean | CspOptions = buildCspOptions(input?.csp, this.getDefaultCsp(mimeType)); + return new this(input.stream, input.filename, mimeType, input.size, csp); + } + + private static getDefaultCsp(mimeType: string): boolean | CspOptions { + if (mimeType === MimeType.SVG) { + return inject(ZIBRI_DI_TOKENS.DEFAULT_CSP_OPTIONS); + } + return false; } } \ No newline at end of file diff --git a/src/parsing/form-data/form-data-body-parser-cleanup.cron-job.ts b/src/parsing/form-data/form-data-body-parser-cleanup.cron-job.ts index 2acbdd1..0cbb026 100644 --- a/src/parsing/form-data/form-data-body-parser-cleanup.cron-job.ts +++ b/src/parsing/form-data/form-data-body-parser-cleanup.cron-job.ts @@ -1,6 +1,7 @@ import { Dirent } from 'node:fs'; import { CLEANUP_AT_FILE_NAME } from './form-data.model'; +import { CronExpression } from '../../cron/cron-expression.utilities'; import { CronJob, InitialCronConfig } from '../../cron/cron-job.model'; import { Inject } from '../../di/decorators/inject.decorator'; import { ZIBRI_DI_TOKENS } from '../../di/default/zibri-di-tokens.default'; @@ -13,7 +14,7 @@ export class FormDataBodyParserCleanupCronJob extends CronJob { // eslint-disable-next-line jsdoc/require-jsdoc initialConfig: InitialCronConfig = { name: 'FormDataBodyParser Cleanup', - cron: '0 0 * * *', + cron: CronExpression.daily().build(), runOnInit: false }; diff --git a/src/parsing/form-data/form-data.body-parser.ts b/src/parsing/form-data/form-data.body-parser.ts index 41873c2..9671fbe 100644 --- a/src/parsing/form-data/form-data.body-parser.ts +++ b/src/parsing/form-data/form-data.body-parser.ts @@ -51,7 +51,9 @@ export class FormDataBodyParser implements BodyParserInterface, OnAppInit { // eslint-disable-next-line jsdoc/require-jsdoc onAppInit(app: ZibriApplication): void { - app.options.cronJobs.push(FormDataBodyParserCleanupCronJob); + if (!app.options.cronJobs.includes(FormDataBodyParserCleanupCronJob)) { + app.options.cronJobs.push(FormDataBodyParserCleanupCronJob); + } } // eslint-disable-next-line jsdoc/require-jsdoc diff --git a/src/parsing/html/csp-options.model.ts b/src/parsing/html/csp-options.model.ts new file mode 100644 index 0000000..5ce3355 --- /dev/null +++ b/src/parsing/html/csp-options.model.ts @@ -0,0 +1,109 @@ +import { ZIBRI_DI_TOKENS } from '../../di/default/zibri-di-tokens.default'; +import { inject } from '../../di/inject.function'; +import { DeepPartial } from '../../types/deep-partial.type'; +import { ObjectUtilities } from '../../utilities/object.utilities'; +import { toKebabCase } from '../../utilities/to-kebab-case.function'; + +/** + * The possible CSP sources/values for the CSP headers. + */ +export type CspSource = '\'self\'' + | '\'none\'' + | '\'unsafe-inline\'' + | '\'unsafe-eval\'' + | `\'nonce-${string}\'` + | `https://${string}` + | `http://${string}`; + +/** + * Definition of CSP options. + */ +export type CspOptions = { + /** + * Fallback for all fetch destinations that are not explicitly covered by a more specific directive. + */ + defaultSrc: CspSource[], + /** + * Restricts the base URL used to resolve relative URLs on the page. + */ + baseUri: CspSource[], + /** + * Restricts the sources from which plugins such as , , and may load. + */ + objectSrc: CspSource[], + /** + * Restricts the URLs that can be used as form submission targets. + */ + formAction: CspSource[], + /** + * Restricts which origins may embed this document in frames, iframes, or objects. + */ + frameAncestors: CspSource[], + /** + * Restricts valid sources for JavaScript. + */ + scriptSrc: CspSource[], + /** + * Restricts inline script event handlers and similar script attributes. + */ + scriptSrcAttr: CspSource[], + /** + * Restricts valid sources for stylesheets and inline style usage. + */ + styleSrc: CspSource[], + /** + * Restricts the origins from which images, icons, and similar media-like assets may be loaded. + */ + imgSrc: CspSource[], + /** + * Restricts the origins from which fonts may be loaded. + */ + fontSrc: CspSource[], + /** + * Restricts the endpoints that the document may connect to via fetch, XHR, WebSocket, EventSource, and similar APIs. + */ + connectSrc: CspSource[], + /** + * Restricts the origins from which audio and video media may be loaded. + */ + mediaSrc: CspSource[] +}; + +/** + * Builds csp options from the given input and default options. + * @param options - The input options. + * @param defaultValue - The default values to fall back to. + * @returns Valid CSP options. + */ +export function buildCspOptions( + options: DeepPartial | boolean | undefined, + defaultValue: boolean | CspOptions +): CspOptions | boolean { + if (typeof options === 'boolean') { + return options; + } + if (options == undefined) { + return defaultValue; + } + return { + ...inject(ZIBRI_DI_TOKENS.DEFAULT_CSP_OPTIONS), + ...options + }; +} + +/** + * Builds the Content-Security-Policy headers from the given options. + * @param options - The CSP options to build the headers from. + * @returns The CSP header as a string. + */ +export function buildCspHeaders(options: CspOptions): string { + return ObjectUtilities.entries(options) + .map(([key, value]) => { + if (!value?.length) { + return undefined; + } + return `${toKebabCase(key)} ${value.join(' ')}`; + }) + .filter(Boolean) + .join('; '); +} \ No newline at end of file diff --git a/src/parsing/html/html-response.model.ts b/src/parsing/html/html-response.model.ts index 8ecedf6..33281c3 100644 --- a/src/parsing/html/html-response.model.ts +++ b/src/parsing/html/html-response.model.ts @@ -1,27 +1,47 @@ import { Readable } from 'stream'; +import { buildCspOptions, CspOptions } from './csp-options.model'; +import { ZIBRI_DI_TOKENS } from '../../di/default/zibri-di-tokens.default'; +import { inject } from '../../di/inject.function'; +import { DeepPartial } from '../../types/deep-partial.type'; + +/** + * Additional options for a html response. + */ +export type HtmlResponseOptions = { + /** + * The configuration for CSP headers. + * Can either be false to not set any, true to set the default CSP headers or a custom configuration. + */ + csp?: boolean | DeepPartial +}; + /** * A html response. */ export class HtmlResponse { - private constructor(readonly data: Readable | string) {} + private constructor(readonly data: Readable | string, readonly csp: boolean | CspOptions) {} /** * Creates the response from the given html string. * @param html - The html value as a string. + * @param options - Additional options like eg. CSP headers. * @returns A new HtmlResponse. */ - static fromString(html: string): HtmlResponse { - return new this(html); + static fromString(html: string, options?: HtmlResponseOptions): HtmlResponse { + const csp: boolean | CspOptions = buildCspOptions(options?.csp, inject(ZIBRI_DI_TOKENS.DEFAULT_CSP_OPTIONS)); + return new this(html, csp); } /** * Creates the response from the given html stream. * @param stream - The html value as a stream. + * @param options - Additional options like eg. CSP headers. * @returns A new HtmlResponse. */ - static fromStream(stream: Readable): HtmlResponse { - return new this(stream); + static fromStream(stream: Readable, options?: HtmlResponseOptions): HtmlResponse { + const csp: boolean | CspOptions = buildCspOptions(options?.csp, inject(ZIBRI_DI_TOKENS.DEFAULT_CSP_OPTIONS)); + return new this(stream, csp); } } \ No newline at end of file diff --git a/src/plugin/mailing-list/mailing-list.controller.ts b/src/plugin/mailing-list/mailing-list.controller.ts index d400163..2f98992 100644 --- a/src/plugin/mailing-list/mailing-list.controller.ts +++ b/src/plugin/mailing-list/mailing-list.controller.ts @@ -67,7 +67,11 @@ export class MailingListController implements OnAppInit { const subscriber: MailingListSubscriber = await this.mailingListService.confirmSubscribeToList(token); const managePreferencesLink: string = this.mailingListService.getManagePreferencesLink(subscriber.id); - return PreactUtilities.renderResponse(this.SubscribeSuccessPage, { subscriber, mailingList, managePreferencesLink }); + const html: string = await PreactUtilities.renderPage( + this.SubscribeSuccessPage, + { subscriber, mailingList, managePreferencesLink } + ); + return HtmlResponse.fromString(html); } @Response.html() @@ -84,7 +88,11 @@ export class MailingListController implements OnAppInit { await this.mailingListService.unsubscribeFromList(id, subscriberId); const managePreferencesLink: string = this.mailingListService.getManagePreferencesLink(subscriberId); - return PreactUtilities.renderResponse(this.UnsubscribeConfirmationPage, { subscriber, mailingList, managePreferencesLink }); + const html: string = await PreactUtilities.renderPage( + this.UnsubscribeConfirmationPage, + { subscriber, mailingList, managePreferencesLink } + ); + return HtmlResponse.fromString(html); } @Response.html() @@ -97,7 +105,8 @@ export class MailingListController implements OnAppInit { const mailingLists: MailingList[] = await this.mailingListRepository.findAll(); const managePreferencesApiUrl: string = this.mailingListService.getManagePreferencesLink(subscriberId); - return PreactUtilities.renderResponse(this.PreferencesPage, { subscriber, mailingLists, managePreferencesApiUrl }); + const html: string = await PreactUtilities.renderPage(this.PreferencesPage, { subscriber, mailingLists, managePreferencesApiUrl }); + return HtmlResponse.fromString(html); } @Response.empty() diff --git a/src/preact/collector.ts b/src/preact/collector.ts index 5b7b436..8b779e2 100644 --- a/src/preact/collector.ts +++ b/src/preact/collector.ts @@ -1,7 +1,6 @@ import { ComponentChild, ComponentChildren, Fragment, VNode } from 'preact'; import { stringAwareReplace } from './string-aware-replace.function'; -import { OmitStrict } from '../types/omit-strict.type'; import { ObjectUtilities } from '../utilities/object.utilities'; const HANDLERS_DIRECTIVE: string = 'data-ssr-handlers'; @@ -25,11 +24,6 @@ type HandlerEntry = { ownerPrefix: string | undefined }; -/** - * A serialized event handler entry. - */ -type SerializedHandlerEntry = OmitStrict; - /** * Result for a simple delegate (basically just a passthrough). */ @@ -384,36 +378,28 @@ export class PreactCollector { * @returns The js section as a string. */ getHandlerReattachmentSectionForPrefix(ownerPrefix: string | undefined): string { - const filtered: Map = new Map( - [...this.map.entries()] - // eslint-disable-next-line unusedImports/no-unused-vars - .filter(([_, e]) => e.ownerPrefix === ownerPrefix) - .map(([id, e]) => [id, { event: e.event, src: e.src }]) - ); - if (!filtered.size) { + const filtered: [string, HandlerEntry][] = [...this.map.entries()] + // eslint-disable-next-line unusedImports/no-unused-vars + .filter(([_, e]) => e.ownerPrefix === ownerPrefix); + + if (!filtered.length) { return ''; } - const json: string = JSON.stringify(Object.fromEntries(filtered), undefined, 4) - .split('\n') - .map((l, i) => i === 0 ? l : ' ' + l) - .join('\n'); + const entries: string[] = filtered.map(([id, e]) => { + const safeSrc: string = e.src.replaceAll('', '\\u003c/script>'); + return ` ${JSON.stringify(id)}: { event: ${JSON.stringify(e.event)}, fn: (${safeSrc}) }`; + }); return [ ' (function() {', ' try {', - ` const map = ${json};`, + ' const map = {', + ...entries, + ' };', ' for (const id of Object.keys(map)) {', ' const info = map[id];', - ' const { event, src } = info;', - ' let fn;', - ' try {', - ' fn = eval("(" + src + ")");', - ' }', - ' catch (error) {', - ' console.error("ssr handler compile error", id, error);', - ' continue;', - ' }', + ' const { event, fn } = info;', ` for (const el of document.querySelectorAll('[${HANDLERS_DIRECTIVE}]')) {`, ` const raw = el.getAttribute('${HANDLERS_DIRECTIVE}');`, ' if (!raw) { continue; }', diff --git a/src/preact/preact.utilities.ts b/src/preact/preact.utilities.ts index 1c8db67..d6a70cb 100644 --- a/src/preact/preact.utilities.ts +++ b/src/preact/preact.utilities.ts @@ -7,7 +7,11 @@ import { preactHooks } from './hooks/hooks'; import { PreactComponent } from './preact-component.model'; import { PreactEmailComponent } from './preact-email-component.model'; import { findStringEnd, stringAwareReplace } from './string-aware-replace.function'; -import { HtmlResponse } from '../parsing/html/html-response.model'; +import { HttpRequestContext } from '../context/request/http-request.context'; +import { ZIBRI_REQUEST_CONTEXT_TOKENS } from '../context/request/request-context-token.model'; +import { WebsocketRequestContext } from '../context/request/websocket-request.context'; +import { ZIBRI_DI_TOKENS } from '../di/default/zibri-di-tokens.default'; +import { inject } from '../di/inject.function'; import { FsUtilities, FsPath } from '../utilities/fs.utilities'; import { ObjectUtilities } from '../utilities/object.utilities'; @@ -230,43 +234,23 @@ export abstract class PreactUtilities { .join('\n') .replaceAll('', '\\u003c/script>'); + const context: HttpRequestContext | WebsocketRequestContext | undefined = inject(ZIBRI_DI_TOKENS.CURRENT_REQUEST_CONTEXT); + const nonce: string | undefined = await context?.get(ZIBRI_REQUEST_CONTEXT_TOKENS.NONCE); + const nonceAttr: string = nonce ? ` nonce="${nonce}"` : ''; if (html.includes('')) { html = html.replace( '', - `\n` + `\n` ); } else { - html += `\n`; + html += `\n`; } } return '\n' + html; } - /** - * Render a component and inline the component "body" (everything before the top-level return) - * into the same ', '\\u003c/script>'); - return ` ${JSON.stringify(id)}: { event: ${JSON.stringify(e.event)}, fn: (${safeSrc}) }`; + const fnExpr: string = e.loopContext + ? `((${e.loopContext.varName}) => (${safeSrc}))(${this.serializeLoopValue(e.loopContext.value)})` + : `(${safeSrc})`; + return ` ${JsonUtilities.stringify(id)}: { event: ${JsonUtilities.stringify(e.event)}, fn: ${fnExpr} }`; }); return [ ' (function() {', ' try {', ' const map = {', - ...entries, + entries.join(',\n'), ' };', ' for (const id of Object.keys(map)) {', ' const info = map[id];', @@ -428,13 +477,33 @@ export class PreactCollector { return `${base}${count}_`; } - private registerHandler(fn: Function, event: string, ownerPrefix: string | undefined): string { + private registerHandler( + fn: Function, + event: string, + ownerPrefix: string | undefined, + loopContext: LoopContext | undefined + ): string { const src: string = fn.toString().replaceAll('', '\\u003c/script>'); - const result: DelegateResult | undefined = this.extractDelegateName(src); const id: string = `h${++this.seq}`; + + // If there's a loop context we must keep the full source so the IIFE + // can pass the captured value through. extractDelegateName would discard + // the argument (e.g. tab.id) and only keep the callee name. + const srcDelegateName: DelegateResult | undefined = this.extractDelegateName(src); + const storedSrc: string = loopContext + ? src + : srcDelegateName?.kind === 'simple' + ? srcDelegateName.name + : src; + // For simple delegations store just the name; for complex expressions store the full src. // Both are renamed uniformly by applyRenames(). - this.map.set(id, { event, src: result?.kind === 'simple' ? result.name : src, ownerPrefix }); + this.map.set(id, { + event, + ownerPrefix, + loopContext, + src: storedSrc + }); return id; } @@ -657,4 +726,33 @@ export class PreactCollector { private isVNode(node: unknown): node is VNode { return !(typeof node !== 'object' || !node || !('props' in node)); } + + private extractFirstParamName(fnSrc: string): string | undefined { + const s: string = fnSrc.trim().replace(/^async\s+/, ''); + // (tab) => ... or (tab, index) => ... + const parenMatch: RegExpMatchArray | null = s.match(/^\(([^)]*)\)/); + if (parenMatch) { + const param: string = parenMatch[1].trim().split(',')[0].trim(); + return param || undefined; + } + // tab => ... + const bareMatch: RegExpMatchArray | null = s.match(/^([$_a-z]\w*)\s*=>/i); + return bareMatch ? bareMatch[1] : undefined; + } + + private serializeLoopValue(value: unknown): string { + if (typeof value === 'function') { + return value.toString(); + } + if (Array.isArray(value)) { + return `[${value.map(v => this.serializeLoopValue(v)).join(', ')}]`; + } + if (value !== null && typeof value === 'object') { + const entries: string[] = Object.entries(value).map( + ([k, v]) => `${JsonUtilities.stringify(k)}: ${this.serializeLoopValue(v)}` + ); + return `{ ${entries.join(', ')} }`; + } + return JsonUtilities.stringify(value); + } } \ No newline at end of file diff --git a/src/preact/generate-client-scripts.function.ts b/src/preact/generate-client-scripts.function.ts index dbff1cd..8035acc 100644 --- a/src/preact/generate-client-scripts.function.ts +++ b/src/preact/generate-client-scripts.function.ts @@ -1,6 +1,7 @@ import { createRequire } from 'node:module'; import { FsUtilities, FsPath } from '../utilities/fs.utilities'; +import { JsonUtilities } from '../utilities/json.utilities'; import { toKebabCase } from '../utilities/to-kebab-case.function'; const defaultGlobs: string[] = ['src/templates/pages/**/*.tsx', 'src/templates/components/**/*.tsx']; @@ -53,7 +54,7 @@ export async function generateClientScripts(glob: string | string[] = defaultGlo const sorted: Record = Object.fromEntries( Object.entries(packagesByComponent).sort(([a], [b]) => a.localeCompare(b)) ); - const newManifestContent: string = JSON.stringify(sorted, undefined, 4); + const newManifestContent: string = JsonUtilities.stringify(sorted, undefined, 4); if (await FsUtilities.exists(manifestFile)) { const oldFileContent: string = await FsUtilities.readFile(manifestFile); if (oldFileContent.trim() === newManifestContent.trim()) { @@ -91,8 +92,8 @@ async function resolveBrowserDist(pkg: string): Promise { // eslint-disable-next-line sonar/no-duplicate-string const userRequire: NodeJS.Require = createRequire(FsUtilities.getPath(process.cwd(), 'package.json')); const pkgDir: string = await findPackageDir(pkg, userRequire); - // eslint-disable-next-line typescript/no-unsafe-assignment - const pkgJson: Record = JSON.parse(await FsUtilities.readFile(FsUtilities.getPath(pkgDir, 'package.json'))); + + const pkgJson: Record = JsonUtilities.parse(await FsUtilities.readFile(FsUtilities.getPath(pkgDir, 'package.json'))); const browserEntry: string | undefined = resolveBrowserEntry(pkgJson); if (!browserEntry) { @@ -122,8 +123,8 @@ async function findPackageDir(pkg: string, userRequire: NodeJS.Require): Promise while (true) { const candidate: FsPath = FsUtilities.getPath(dir, 'package.json'); try { - // eslint-disable-next-line typescript/no-unsafe-assignment - const json: Record = JSON.parse(await FsUtilities.readFile(candidate)); + + const json: Record = JsonUtilities.parse(await FsUtilities.readFile(candidate)); if (json['name'] === pkg) { return dir; } diff --git a/src/preact/preact.utilities.ts b/src/preact/preact.utilities.ts index d6a70cb..f5f2e80 100644 --- a/src/preact/preact.utilities.ts +++ b/src/preact/preact.utilities.ts @@ -13,6 +13,7 @@ import { WebsocketRequestContext } from '../context/request/websocket-request.co import { ZIBRI_DI_TOKENS } from '../di/default/zibri-di-tokens.default'; import { inject } from '../di/inject.function'; import { FsUtilities, FsPath } from '../utilities/fs.utilities'; +import { JsonUtilities } from '../utilities/json.utilities'; import { ObjectUtilities } from '../utilities/object.utilities'; /** @@ -324,8 +325,7 @@ export abstract class PreactUtilities { } try { const manifestPath: FsPath = FsUtilities.getPath(process.cwd(), 'assets', 'public', 'vendor', 'manifest.json'); - // eslint-disable-next-line typescript/no-unsafe-assignment - this.clientManifest = JSON.parse(await FsUtilities.readFile(manifestPath)); + this.clientManifest = JsonUtilities.parse(await FsUtilities.readFile(manifestPath)); } catch { this.clientManifest = {}; @@ -744,7 +744,7 @@ export abstract class PreactUtilities { } } - const safe: string = JSON.stringify(serializableProps, undefined, 4) + const safe: string = JsonUtilities.stringify(serializableProps, undefined, 4) .split('\n') .map((l, i) => i === 0 ? l : ' ' + l) .join('\n') @@ -1030,8 +1030,7 @@ export abstract class PreactUtilities { for (const [key, value] of ObjectUtilities.entries(propBindings)) { if (key.startsWith('__propsObj_')) { - // eslint-disable-next-line typescript/no-unsafe-assignment - const parsed: Record = JSON.parse(value); + const parsed: Record = JsonUtilities.parse(value); for (const [propName, resolvedName] of ObjectUtilities.entries(parsed)) { entries.push(`${propName}: ${resolveValue(resolvedName)}`); } @@ -1050,7 +1049,7 @@ export abstract class PreactUtilities { } for (const [propName, val] of ObjectUtilities.entries(propValues)) { - entries.push(`${propName}: ${JSON.stringify(val)}`); + entries.push(`${propName}: ${JsonUtilities.stringify(val)}`); } if (entries.length) { @@ -1090,7 +1089,7 @@ export abstract class PreactUtilities { const propName: string = destructureRenameMap.get(localName) ?? localName; if (propName in propValues) { - lines.push(` const ${prefix}${localName} = ${JSON.stringify(propValues[propName])};`); + lines.push(` const ${prefix}${localName} = ${JsonUtilities.stringify(propValues[propName])};`); continue; } if (defaultSrc !== undefined) { diff --git a/src/rate-limiting/rate-limiter.ts b/src/rate-limiting/rate-limiter.ts index 5ee2b35..fd902cd 100644 --- a/src/rate-limiting/rate-limiter.ts +++ b/src/rate-limiting/rate-limiter.ts @@ -52,6 +52,16 @@ export class RateLimiter { return new this(max, Ms.SECOND); } + /** + * Creates a rate limiter with a custom interval. + * @param max - The maximum available per the given interval. + * @param intervalInMs - The interval in ms. + * @returns The RateLimiter. + */ + static custom(max: number, intervalInMs: number): RateLimiter { + return new this(max, intervalInMs); + } + private refill(): void { const now: number = Date.now(); const elapsedMs: number = now - this.lastRefill; diff --git a/src/routing/decorators/body.decorator.ts b/src/routing/decorators/body.decorator.ts index 6804871..5b68f43 100644 --- a/src/routing/decorators/body.decorator.ts +++ b/src/routing/decorators/body.decorator.ts @@ -5,9 +5,9 @@ import { Relation } from '../../entity/models/relation.enum'; import { MimeType } from '../../http/mime-type.enum'; import { Newable } from '../../types/newable.type'; import { OmitStrict } from '../../types/omit-strict.type'; -import { BigNumber } from '../../utilities/big-number.utilities'; import { MetadataUtilities } from '../../utilities/metadata.utilities'; import { Ms } from '../../utilities/ms'; +import { BigNumber } from '../../utilities/number.utilities'; /** * Base metadata shared by all possible http request body properties. diff --git a/src/routing/router.ts b/src/routing/router.ts index 27cac6d..2b127af 100644 --- a/src/routing/router.ts +++ b/src/routing/router.ts @@ -37,6 +37,7 @@ import type { ValidationServiceInterface } from '../validation/validation-servic import { ControllerData } from './decorators/controller.decorator'; import { AlsUtilities } from '../context/als.utilities'; import { HttpRequestContext } from '../context/request/http-request.context'; +import { JsonUtilities } from '../utilities/json.utilities'; import { ObjectUtilities } from '../utilities/object.utilities'; /** @@ -340,7 +341,7 @@ export class Router implements RouterInterface, OnAppInit, OnAppStart { return; } - res.json(result); + res.json(JsonUtilities.parse(JsonUtilities.stringify(result))); } private async returnFileResult(res: HttpResponse, result: FileResponse, next: NextFunction): Promise { diff --git a/src/types/any-enum.type.ts b/src/types/any-enum.type.ts index 04f07e7..36c101f 100644 --- a/src/types/any-enum.type.ts +++ b/src/types/any-enum.type.ts @@ -1,2 +1,2 @@ // eslint-disable-next-line jsdoc/require-jsdoc -export type AnyEnum = { [key: string]: T }; \ No newline at end of file +export type AnyEnum = { [key: string]: T }; \ No newline at end of file diff --git a/src/utilities/bytes.ts b/src/utilities/bytes.ts new file mode 100644 index 0000000..a858d72 --- /dev/null +++ b/src/utilities/bytes.ts @@ -0,0 +1,20 @@ +/* eslint-disable typescript/typedef */ +// eslint-disable-next-line jsdoc/require-jsdoc +export abstract class Bytes { + /** + * The amount of ms in a second. + */ + static B = 1 as const; + /** + * The amount of ms in a minute. + */ + static KB = 1000 as const; + /** + * The amount of ms in an hour. + */ + static MB = 1_000_000 as const; + /** + * The amount of ms in a day. + */ + static GB = 1_000_000_000 as const; +} \ No newline at end of file diff --git a/src/utilities/doubly-linked-list.ts b/src/utilities/doubly-linked-list.ts new file mode 100644 index 0000000..01af9a0 --- /dev/null +++ b/src/utilities/doubly-linked-list.ts @@ -0,0 +1,132 @@ +/** + * A node inside a DoublyLinkedList. + * You should never instantiate this directly. + */ +export class LinkedListNode { + /** + * The actual value stored inside this node. + */ + value: T; + /** + * The previous list node. + */ + prev: LinkedListNode | undefined = undefined; + /** + * The next list node. + */ + next: LinkedListNode | undefined = undefined; + + constructor(value: T) { + this.value = value; + } +} + +/** + * Generic doubly linked list with O(1) removal of arbitrary nodes + * and O(1) append / move-to-tail operations. + */ +export class DoublyLinkedList { + /** + * The first list element. + */ + head: LinkedListNode | undefined = undefined; + /** + * The last list element. + */ + tail: LinkedListNode | undefined = undefined; + + private _size: number = 0; + + // eslint-disable-next-line jsdoc/require-returns + /** + * The amount of elements inside the list. + */ + get size(): number { + return this._size; + } + + /** + * Appends a value and returns its node (you keep it for later removal). + * @param value - The value to add. + * @returns The list node of the just added value. + */ + addLast(value: T): LinkedListNode { + const node: LinkedListNode = new LinkedListNode(value); + if (!this.tail) { + this.head = this.tail = node; + } + else { + node.prev = this.tail; + this.tail.next = node; + this.tail = node; + } + this._size++; + return node; + } + + /** + * Removes the given node in O(1). + * @param node - The node to remove. + */ + remove(node: LinkedListNode): void { + if (node.prev) { + node.prev.next = node.next; + } + else { + this.head = node.next; + } + + if (node.next) { + node.next.prev = node.prev; + } + else { + this.tail = node.prev; + } + + // Prevent accidental reuse + node.prev = node.next = undefined; + this._size--; + } + + /** + * Moves an existing node to the tail (mark as most recently used). + * @param node - The node to move to the tail. + */ + moveToTail(node: LinkedListNode): void { + if (node === this.tail) { + return; + } // already last + // remove from current position + if (node.prev) { + node.prev.next = node.next; + } + else { + this.head = node.next; + } + if (node.next) { + node.next.prev = node.prev; + } + else { + this.tail = node.prev; + } + + // re-insert at tail + node.prev = this.tail; + node.next = undefined; + if (this.tail) { + this.tail.next = node; + } + else { + this.head = node; + } + this.tail = node; + } + + /** + * Clears the list completely. + */ + clear(): void { + this.head = this.tail = undefined; + this._size = 0; + } +} \ No newline at end of file diff --git a/src/utilities/is-numeric.function.ts b/src/utilities/is-numeric.function.ts index 524e1f6..768e4f6 100644 --- a/src/utilities/is-numeric.function.ts +++ b/src/utilities/is-numeric.function.ts @@ -1,14 +1,16 @@ +const NUMERIC_REGEX: RegExp = /^-?(0|[1-9]\d*)(\.\d+)?$/; + /** * Checks whether or not the given value is numeric. * @param value - The value to check. * @returns True if the value is numeric, false otherwise. */ -export function isNumeric(value: unknown): boolean { +export function isNumeric(value: unknown): value is number | string { if (typeof value === 'number') { return true; } if (typeof value === 'string') { - return /^-?\d+(\.\d+)?$/.test(value); + return NUMERIC_REGEX.test(value); } return false; } \ No newline at end of file diff --git a/src/utilities/json.utilities.ts b/src/utilities/json.utilities.ts new file mode 100644 index 0000000..ec2776c --- /dev/null +++ b/src/utilities/json.utilities.ts @@ -0,0 +1,37 @@ +/** + * Utilities for handling json. + */ +export abstract class JsonUtilities { + /** + * Converts a JavaScript Object Notation (JSON) string into an object. + * @param value - A valid JSON string. + * @param reviver - A function that transforms the results. This function is called for each member of the object. + * If a member contains nested objects, the nested objects are transformed before the parent object is. + * @throws {SyntaxError} If `text` is not valid JSON. + * @returns The parsed object. + */ + // eslint-disable-next-line typescript/no-explicit-any + static parse(value: string, reviver?: (this: any, key: string, value: unknown) => unknown): T { + return JSON.parse(value, reviver) as T; + } + + /** + * Converts a JavaScript value to a JavaScript Object Notation (JSON) string. + * @param value - A JavaScript value, usually an object or array, to be converted. + * @param replacer - A function that transforms the results. + * @param space - Adds indentation, white space, and line break characters to the return-value JSON text to make it easier to read. + * @throws {TypeError} If a circular reference is found. + * @returns A json string. + */ + // eslint-disable-next-line typescript/no-explicit-any + static stringify(value: T, replacer?: (this: any, key: string, value: unknown) => unknown, space?: string | number): string { + return JSON.stringify( + value, + (_key, value: unknown) => { + const res: unknown = typeof value === 'bigint' ? value.toString() : value; + return replacer ? replacer(_key, res) : res; + }, + space + ); + } +} \ No newline at end of file diff --git a/src/utilities/now-in-ns.function.ts b/src/utilities/now-in-ns.function.ts new file mode 100644 index 0000000..4d6bef7 --- /dev/null +++ b/src/utilities/now-in-ns.function.ts @@ -0,0 +1,13 @@ +import { NumberUtilities } from './number.utilities'; + +const startHr: bigint = process.hrtime.bigint(); +// its okay that we loose precision here, that's what the calculation below is for +const startInNs: bigint = BigInt(NumberUtilities.multiply(Date.now(), 1_000_000).toString()); + +/** + * Gets the current timestamp with nanosecond precision. + * @returns The nanoseconds as a bigint. + */ +export function nowInNs(): bigint { + return startInNs + (process.hrtime.bigint() - startHr); +} \ No newline at end of file diff --git a/src/utilities/big-number.utilities.ts b/src/utilities/number.utilities.ts similarity index 59% rename from src/utilities/big-number.utilities.ts rename to src/utilities/number.utilities.ts index c3cca13..b0800dc 100644 --- a/src/utilities/big-number.utilities.ts +++ b/src/utilities/number.utilities.ts @@ -1,20 +1,20 @@ import BigNumberJs from 'bignumber.js'; /** - * Numbers with increased precision. (No rounding errors). + * Number with increased precision. (No rounding errors). */ export type BigNumber = BigNumberJs; /** * Provides functionality around calculating with high precision. */ -export abstract class BigNumberUtilities { +export abstract class NumberUtilities { /** * Creates a BigNumber from the provided input. * @param value - The number value to create from. * @returns A new BigNumber. */ - static new(value: number): BigNumber { + static new(value: number | bigint): BigNumber { return new BigNumberJs(value); } @@ -24,7 +24,7 @@ export abstract class BigNumberUtilities { * @param value2 - The second number to multiply. * @returns The precise result as BigNumber. */ - static multiply(value1: number | BigNumber, value2: number | BigNumber): BigNumber { + static multiply(value1: number | bigint | BigNumber, value2: number | bigint | BigNumber): BigNumber { return new BigNumberJs(value1).multipliedBy(new BigNumberJs(value2)); } @@ -34,7 +34,7 @@ export abstract class BigNumberUtilities { * @param divisor - The value that divides the first value. * @returns The precise result as BigNumber. */ - static divide(dividend: number | BigNumber, divisor: number | BigNumber): BigNumber { + static divide(dividend: number | bigint | BigNumber, divisor: number | bigint | BigNumber): BigNumber { return new BigNumberJs(dividend).dividedBy(new BigNumberJs(divisor)); } @@ -44,7 +44,17 @@ export abstract class BigNumberUtilities { * @param value2 - The second number for the addition. * @returns The precise result as BigNumber. */ - static add(value1: number | BigNumber, value2: number | BigNumber): BigNumber { + static add(value1: number | bigint | BigNumber, value2: number | bigint | BigNumber): BigNumber { return new BigNumberJs(value1).plus(new BigNumberJs(value2)); } + + /** + * Precisely subtracts the given values. + * @param value1 - The first number for the subtraction. + * @param value2 - The second number for the subtraction. + * @returns The precise result as BigNumber. + */ + static subtract(value1: number | bigint | BigNumber, value2: number | bigint | BigNumber): BigNumber { + return new BigNumberJs(value1).minus(new BigNumberJs(value2)); + } } \ No newline at end of file diff --git a/src/validation/functions/validate-file.function.ts b/src/validation/functions/validate-file.function.ts index e14ac7b..a3d8702 100644 --- a/src/validation/functions/validate-file.function.ts +++ b/src/validation/functions/validate-file.function.ts @@ -6,7 +6,7 @@ import { PropertyMetadata } from '../../entity/decorators/property.decorator'; import { fileSizeToBytes } from '../../entity/models/file-property-metadata.model'; import { MimeType } from '../../http/mime-type.enum'; import { File } from '../../parsing/form-data/file.model'; -import { BigNumberUtilities } from '../../utilities/big-number.utilities'; +import { NumberUtilities } from '../../utilities/number.utilities'; import { MaxFileSizeValidationProblem, IsRequiredValidationProblem, TypeMismatchValidationProblem, ValidationProblem, MimeTypeMismatchValidationProblem } from '../validation-problem.model'; /** @@ -39,7 +39,7 @@ export async function validateFile( return [new TypeMismatchValidationProblem(fullKey, 'file')]; } - if (BigNumberUtilities.new(property.size).isGreaterThan(fileSizeToBytes(metadata.maxSize))) { + if (NumberUtilities.new(property.size).isGreaterThan(fileSizeToBytes(metadata.maxSize))) { return [new MaxFileSizeValidationProblem(fullKey, metadata.maxSize)]; } if (metadata.allowedMimeTypes !== 'all' && !metadata.allowedMimeTypes.includes(property.mimetype as MimeType)) { diff --git a/src/validation/functions/validate-number.function.ts b/src/validation/functions/validate-number.function.ts index e15fe5e..02cb1d9 100644 --- a/src/validation/functions/validate-number.function.ts +++ b/src/validation/functions/validate-number.function.ts @@ -1,9 +1,12 @@ +import assert from 'node:assert'; + import { HttpRequestContext } from '../../context/request/http-request.context'; import { WebsocketRequestContext } from '../../context/request/websocket-request.context'; import { ZIBRI_DI_TOKENS } from '../../di/default/zibri-di-tokens.default'; import { inject } from '../../di/inject.function'; import { PropertyMetadata } from '../../entity/decorators/property.decorator'; -import { NumberPropertyMetadata } from '../../entity/models/number-property-metadata.model'; +import { NumberFormat, NumberPropertyMetadata } from '../../entity/models/number-property-metadata.model'; +import { INTEGER_REGEX } from '../../parsing/functions/parse-number.function'; import { QueryParamMetadata, HeaderParamMetadata, PathParamMetadata } from '../../routing/decorators/param.decorator'; import { NumberParamMetadata } from '../../routing/models/number-param-metadata.model'; import { ObjectUtilities } from '../../utilities/object.utilities'; @@ -38,9 +41,18 @@ export async function validateNumber( return []; } } - if (typeof property !== 'number') { + if (typeof property !== 'number' && meta.format !== 'bigint') { return [new TypeMismatchValidationProblem(fullKey, 'number')]; } + if (typeof property !== 'bigint' && meta.format === 'bigint') { + return [new TypeMismatchValidationProblem(fullKey, 'BigIntString')]; + } + + assert(typeof property === 'bigint' || typeof property === 'number'); + + if (meta.format && !isFormatValid(meta.format, property)) { + return [{ key: fullKey, message: `needs to be in format "${meta.format}"` }]; + } if (meta.enum && !ObjectUtilities.values(meta.enum).includes(property)) { return [{ key: fullKey, message: `needs to match one of "${ObjectUtilities.values(meta.enum)}"` }]; } @@ -51,4 +63,16 @@ export async function validateNumber( return [{ key: fullKey, message: `needs to be at most ${meta.max}` }]; } return []; +} + +// eslint-disable-next-line jsdoc/require-jsdoc +function isFormatValid(format: NumberFormat, value: number | bigint): boolean { + switch (format) { + case 'bigint': { + return INTEGER_REGEX.test(value.toString()); + } + case 'integer': { + return Number.isInteger(value); + } + } } \ No newline at end of file diff --git a/src/websocket/services/websocket.service.ts b/src/websocket/services/websocket.service.ts index 32ce44b..43e715a 100644 --- a/src/websocket/services/websocket.service.ts +++ b/src/websocket/services/websocket.service.ts @@ -27,6 +27,7 @@ import { type LoggerInterface } from '../../logging/logger.interface'; import { type ParserInterface } from '../../parsing/parser.interface'; import { resolveRouteParams } from '../../routing/resolve-route-params.function'; import { Newable } from '../../types/newable.type'; +import { JsonUtilities } from '../../utilities/json.utilities'; import { MetadataUtilities } from '../../utilities/metadata.utilities'; import { UUIDUtilities } from '../../utilities/uuid.utilities'; import { type ValidationServiceInterface } from '../../validation/validation-service.interface'; @@ -203,7 +204,7 @@ export class WebsocketService implements WebsocketServiceInterface = { - request: await data.connection.emitWithAck(data.event, message, this.options.timeoutInMs), + request: await data.connection.emitWithAck( + data.event, + JsonUtilities.parse(JsonUtilities.stringify(message)), + this.options.timeoutInMs + ), connection: data.connection }; return res as B extends false ? void : WebsocketRequestWithConnection; } - data.connection.emit(data.event, message); + data.connection.emit(data.event, JsonUtilities.parse(JsonUtilities.stringify(message))); return undefined as B extends false ? void : WebsocketRequestWithConnection; } @@ -383,7 +388,7 @@ export class WebsocketService implements WebsocketServiceInterface[]; } @@ -447,7 +452,7 @@ export class WebsocketService implements WebsocketServiceInterface[]; } From f8b4b49974c0b1dd1658ebe02e2242c7e489d90e Mon Sep 17 00:00:00 2001 From: Tim Fabian Date: Thu, 7 May 2026 00:41:01 +0200 Subject: [PATCH 5/6] added multi tier caches --- sandbox/src/controllers/page.controller.ts | 13 +- src/auth/encryption/encryption.service.ts | 35 ++- src/caching/cache-service.interface.ts | 4 +- src/caching/cache.service.test.ts | 16 +- src/caching/cache.service.ts | 9 +- src/caching/cache/base-cache.model.ts | 48 +-- src/caching/cache/cache-options.model.ts | 28 +- src/caching/cache/cache.interface.ts | 35 ++- src/caching/cache/multi-tier.cache.test.ts | 150 +++++++++ src/caching/cache/multi-tier.cache.ts | 297 ++++++++++++++++++ .../cache/read-aside/read-aside.cache.ts | 15 +- .../write-around-read-aside.cache.ts | 18 +- .../write-behind-read-aside.cache.ts | 39 ++- ...e-invalidate-read-aside-args-only.cache.ts | 26 +- ...invalidate-read-aside-with-result.cache.ts | 26 +- .../write-through-read-aside.cache.ts | 34 +- .../cache/read-through/read-through.cache.ts | 16 +- .../write-around-read-through.cache.ts | 18 +- .../write-behind-read-through.cache.ts | 39 ++- ...invalidate-read-through-args-only.cache.ts | 26 +- ...validate-read-through-with-result.cache.ts | 26 +- .../write-through-read-through.cache.test.ts | 44 +-- .../write-through-read-through.cache.ts | 34 +- .../decorators/cache-delete.decorator.ts | 18 +- .../decorators/cache-invalidate.decorator.ts | 15 +- .../decorators/cache-write.decorator.ts | 80 +++-- src/caching/decorators/cache.decorator.ts | 5 +- src/caching/decorators/cached.decorator.ts | 26 +- src/caching/decorators/decorator-types.ts | 20 ++ src/caching/store/in-memory.cache-store.ts | 245 ++++++--------- .../request/http-request.context.test.ts | 2 +- 31 files changed, 1037 insertions(+), 370 deletions(-) create mode 100644 src/caching/cache/multi-tier.cache.test.ts create mode 100644 src/caching/cache/multi-tier.cache.ts create mode 100644 src/caching/decorators/decorator-types.ts diff --git a/sandbox/src/controllers/page.controller.ts b/sandbox/src/controllers/page.controller.ts index 667d707..b9164e1 100644 --- a/sandbox/src/controllers/page.controller.ts +++ b/sandbox/src/controllers/page.controller.ts @@ -1,11 +1,18 @@ -import { AssetServiceInterface, Cache, Cached, Controller, Get, GlobalRegistry, HtmlResponse, inject, InMemoryCacheStore, PreactUtilities, Response, TreeNode, WriteThroughReadThroughCache, ZIBRI_DI_TOKENS } from 'zibri'; +import { AssetServiceInterface, Cache, Cached, CacheServiceInterface, Controller, Get, GlobalRegistry, HtmlResponse, Inject, inject, InMemoryCacheStore, LoggerInterface, MetricsServiceInterface, PreactUtilities, Response, TreeNode, WriteThroughReadThroughCache, ZIBRI_DI_TOKENS } from 'zibri'; import { AssetsPage } from '../templates/pages/assets'; import { HomePage } from '../templates/pages/home'; @Cache() -export class StaticPagesCache extends WriteThroughReadThroughCache { - constructor() { +export class StaticPagesCache extends WriteThroughReadThroughCache { + constructor( + @Inject(ZIBRI_DI_TOKENS.LOGGER) + protected readonly logger: LoggerInterface, + @Inject(ZIBRI_DI_TOKENS.CACHE_SERVICE) + protected readonly cacheService: CacheServiceInterface, + @Inject(ZIBRI_DI_TOKENS.METRICS_SERVICE) + protected readonly metricsService: MetricsServiceInterface + ) { super('StaticPagesCache', new InMemoryCacheStore(), []); } } diff --git a/src/auth/encryption/encryption.service.ts b/src/auth/encryption/encryption.service.ts index 20faaf2..2eeead5 100644 --- a/src/auth/encryption/encryption.service.ts +++ b/src/auth/encryption/encryption.service.ts @@ -44,15 +44,24 @@ const INIT_ERROR_MESSAGE: string = 'Error initializing encryption service.'; @Cache() // eslint-disable-next-line jsdoc/require-jsdoc -export class EncryptionKeyCache extends WriteThroughReadThroughCache { - constructor( - @Inject(ZIBRI_DI_TOKENS.LOGGER) - protected readonly logger: LoggerInterface, - @Inject(ZIBRI_DI_TOKENS.CACHE_SERVICE) - protected readonly cacheService: CacheServiceInterface, - @Inject(ZIBRI_DI_TOKENS.CACHE_SERVICE) - protected readonly metricsService: MetricsServiceInterface - ) { +export class EncryptionKeyCache extends WriteThroughReadThroughCache { + + // eslint-disable-next-line jsdoc/require-jsdoc + protected get logger(): LoggerInterface { + return inject(ZIBRI_DI_TOKENS.LOGGER); + } + + // eslint-disable-next-line jsdoc/require-jsdoc + protected get cacheService(): CacheServiceInterface { + return inject(ZIBRI_DI_TOKENS.CACHE_SERVICE); + } + + // eslint-disable-next-line jsdoc/require-jsdoc + protected get metricsService(): MetricsServiceInterface { + return inject(ZIBRI_DI_TOKENS.METRICS_SERVICE); + } + + constructor() { super('EncryptionKeyCache', new InMemoryCacheStore(), []); } } @@ -383,12 +392,13 @@ export class EncryptionService implements EncryptionServiceInterface, OnAppInit } // eslint-disable-next-line unusedImports/no-unused-vars - @Cached(EncryptionKeyCache, (id, _) => id) + @Cached(EncryptionKeyCache, (id, ..._) => id) private async findKeyEntityById(id: string, options: BaseRepositoryOptions | undefined): Promise { return await this.keyRepository.findById(id, { relations: ['strategy'], ...options }); } - @CacheWrite(EncryptionKeyCache, (key) => key.id) + // eslint-disable-next-line unusedImports/no-unused-vars + @CacheWrite(EncryptionKeyCache, (key, ..._) => key.id) private async createKeyEntity( encryptedValue: EncryptionString, strategy: EncryptionStrategyEntity, @@ -405,7 +415,8 @@ export class EncryptionService implements EncryptionServiceInterface, OnAppInit return key; } - @CacheWrite(EncryptionKeyCache, (key) => key.id) + // eslint-disable-next-line unusedImports/no-unused-vars + @CacheWrite(EncryptionKeyCache, (key, ..._) => key.id) private async updateKeyEntityById( id: string, data: DeepPartial, diff --git a/src/caching/cache-service.interface.ts b/src/caching/cache-service.interface.ts index e5ee31e..1ab6220 100644 --- a/src/caching/cache-service.interface.ts +++ b/src/caching/cache-service.interface.ts @@ -1,4 +1,4 @@ -import { CacheInterface } from './cache/cache.interface'; +import { AnyCache } from './cache/cache.interface'; /** * Interface for a cache service. @@ -7,7 +7,7 @@ export interface CacheServiceInterface { /** * All caches that have been registered. */ - readonly caches: CacheInterface[], + readonly caches: AnyCache[], /** * Invalidates the given tags through all caches. */ diff --git a/src/caching/cache.service.test.ts b/src/caching/cache.service.test.ts index fd43059..f48373d 100644 --- a/src/caching/cache.service.test.ts +++ b/src/caching/cache.service.test.ts @@ -14,7 +14,7 @@ import { type MetricsServiceInterface } from '../metrics/metrics-service.interfa import { WriteThroughReadThroughCache } from './cache/read-through/write-through-read-through.cache'; @Cache() -class UserTagCache extends WriteThroughReadThroughCache { +class UserTagCache extends WriteThroughReadThroughCache { constructor( @Inject(ZIBRI_DI_TOKENS.LOGGER) protected readonly logger: LoggerInterface, @Inject(ZIBRI_DI_TOKENS.METRICS_SERVICE) protected readonly metricsService: MetricsServiceInterface, @@ -25,7 +25,7 @@ class UserTagCache extends WriteThroughReadThroughCache { } @Cache() -class PostTagCache extends WriteThroughReadThroughCache { +class PostTagCache extends WriteThroughReadThroughCache { constructor( @Inject(ZIBRI_DI_TOKENS.LOGGER) protected readonly logger: LoggerInterface, @Inject(ZIBRI_DI_TOKENS.METRICS_SERVICE) protected readonly metricsService: MetricsServiceInterface, @@ -36,7 +36,7 @@ class PostTagCache extends WriteThroughReadThroughCache { } @Cache() -class UserRegexCache extends WriteThroughReadThroughCache { +class UserRegexCache extends WriteThroughReadThroughCache { constructor( @Inject(ZIBRI_DI_TOKENS.LOGGER) protected readonly logger: LoggerInterface, @Inject(ZIBRI_DI_TOKENS.METRICS_SERVICE) protected readonly metricsService: MetricsServiceInterface, @@ -47,7 +47,7 @@ class UserRegexCache extends WriteThroughReadThroughCache { } @Cache() -class PostRegexCache extends WriteThroughReadThroughCache { +class PostRegexCache extends WriteThroughReadThroughCache { constructor( @Inject(ZIBRI_DI_TOKENS.LOGGER) protected readonly logger: LoggerInterface, @Inject(ZIBRI_DI_TOKENS.METRICS_SERVICE) protected readonly metricsService: MetricsServiceInterface, @@ -58,7 +58,7 @@ class PostRegexCache extends WriteThroughReadThroughCache { } @Cache() -class UserPredicateCache extends WriteThroughReadThroughCache { +class UserPredicateCache extends WriteThroughReadThroughCache { constructor( @Inject(ZIBRI_DI_TOKENS.LOGGER) protected readonly logger: LoggerInterface, @Inject(ZIBRI_DI_TOKENS.METRICS_SERVICE) protected readonly metricsService: MetricsServiceInterface, @@ -69,7 +69,7 @@ class UserPredicateCache extends WriteThroughReadThroughCache { } @Cache() -class OtherPredicateCache extends WriteThroughReadThroughCache { +class OtherPredicateCache extends WriteThroughReadThroughCache { constructor( @Inject(ZIBRI_DI_TOKENS.LOGGER) protected readonly logger: LoggerInterface, @Inject(ZIBRI_DI_TOKENS.METRICS_SERVICE) protected readonly metricsService: MetricsServiceInterface, @@ -80,7 +80,7 @@ class OtherPredicateCache extends WriteThroughReadThroughCache { } @Cache() -class AllTagsCache extends WriteThroughReadThroughCache { +class AllTagsCache extends WriteThroughReadThroughCache { constructor( @Inject(ZIBRI_DI_TOKENS.LOGGER) protected readonly logger: LoggerInterface, @Inject(ZIBRI_DI_TOKENS.METRICS_SERVICE) protected readonly metricsService: MetricsServiceInterface, @@ -91,7 +91,7 @@ class AllTagsCache extends WriteThroughReadThroughCache { } @Cache() -class UnrelatedTagCache extends WriteThroughReadThroughCache { +class UnrelatedTagCache extends WriteThroughReadThroughCache { constructor( @Inject(ZIBRI_DI_TOKENS.LOGGER) protected readonly logger: LoggerInterface, @Inject(ZIBRI_DI_TOKENS.METRICS_SERVICE) protected readonly metricsService: MetricsServiceInterface, diff --git a/src/caching/cache.service.ts b/src/caching/cache.service.ts index ecb8231..6085ad7 100644 --- a/src/caching/cache.service.ts +++ b/src/caching/cache.service.ts @@ -1,4 +1,4 @@ -import { AnyCache, CacheInterface, isCache } from './cache/cache.interface'; +import { AnyCache, isCache } from './cache/cache.interface'; import { CacheServiceInterface } from './cache-service.interface'; import { matchesAnyTag } from './cache-tag-matchers'; import { Inject } from '../di/decorators/inject.decorator'; @@ -8,6 +8,7 @@ import { inject } from '../di/inject.function'; import { GlobalRegistry } from '../global/global-registry'; import { OnAppInit } from '../global/on-app-init.interface'; import { type LoggerInterface } from '../logging/logger.interface'; +import { MultiTierCache } from './cache/multi-tier.cache'; /** * Default implementation of the cache service. @@ -60,9 +61,9 @@ export class CacheService implements CacheServiceInterface, OnAppInit { // eslint-disable-next-line jsdoc/require-jsdoc async invalidateTags(tags: string[]): Promise { // Only hit caches that actually declared these tags - const affectedCaches: CacheInterface[] = this.caches.filter( - c => c.tags === 'all' || matchesAnyTag(c.tags, tags) + const affectedCaches: AnyCache[] = this.caches.filter( + c => !(c instanceof MultiTierCache) && (c.tags === 'all' || matchesAnyTag(c.tags, tags)) ); - await Promise.all(affectedCaches.map(c => c.store.invalidateTags(tags))); + await Promise.all(affectedCaches.map(c => c instanceof MultiTierCache ? undefined : c.store.invalidateTags(tags))); } } \ No newline at end of file diff --git a/src/caching/cache/base-cache.model.ts b/src/caching/cache/base-cache.model.ts index 2170fbb..bbd8c8a 100644 --- a/src/caching/cache/base-cache.model.ts +++ b/src/caching/cache/base-cache.model.ts @@ -7,7 +7,7 @@ import { CacheMetrics } from '../cache-metrics.model'; import { CacheServiceInterface } from '../cache-service.interface'; import { CacheTagMatcher } from '../cache-tag-matchers'; import { CacheOperation } from './cache-operation.enum'; -import { CacheKeyProvider, CacheTagsProvider, CacheTtlProvider, CacheWrapDeleteOptions, CacheWrapInvalidateOptions, OnInvalidationFailure, ResultCacheTagsProvider, ResultCacheTtlProvider } from './cache-options.model'; +import { CacheKeyProvider, CacheSetDirectOptions, CacheTagsProvider, CacheTtlProvider, CacheWrapDeleteOptions, CacheWrapInvalidateOptions, OnInvalidationFailure, ResultCacheTagsProvider, ResultCacheTtlProvider } from './cache-options.model'; import { CacheInterface } from './cache.interface'; import { CacheStoreInterface } from '../store/cache-store.interface'; import { CachedValue } from '../store/cached-value.model'; @@ -15,8 +15,8 @@ import { CachedValue } from '../store/cached-value.model'; /** * Shared base class of all caches. */ -export abstract class BaseCache -implements OmitStrict, 'wrap' | 'wrapWrite'> { +export abstract class BaseCache +implements OmitStrict, 'wrap' | 'wrapWrite'> { private readonly inFlight: Map> = new Map(); @@ -28,6 +28,8 @@ implements OmitStrict, 'wrap' | 'wrapWri protected abstract metricsService: MetricsServiceInterface; + abstract readonly _writeResultAvailable: WriteResultAvailable; + // eslint-disable-next-line jsdoc/require-returns /** * The cache metrics. @@ -38,10 +40,10 @@ implements OmitStrict, 'wrap' | 'wrapWri } constructor( - readonly name: string, + readonly name: N, readonly store: CacheStoreInterface, readonly tags: 'all' | readonly CacheTagMatcher[], - readonly defaultTtl?: number | (() => number), + readonly defaultTtl?: number | (() => number | Promise), readonly onInvalidationFailure?: OnInvalidationFailure ) {} @@ -63,7 +65,7 @@ implements OmitStrict, 'wrap' | 'wrapWri await Promise.all([ Promise.resolve() .then(async () => { - const key: K = keyFn(...args); + const key: K = await keyFn(...args); cacheCtx.key = key; const storeStart: number = performance.now(); @@ -108,6 +110,8 @@ implements OmitStrict, 'wrap' | 'wrapWri }; } + abstract setDirect(key: K, value: V, options?: CacheSetDirectOptions): Promise; + private initMetrics(): CacheMetrics { const label: string[] = ['cache']; const labelWithOp: string[] = ['cache', 'operation']; @@ -195,8 +199,8 @@ implements OmitStrict, 'wrap' | 'wrapWri ): Promise { try { const tags: CacheTag[] = maybeArgs !== undefined - ? this.resolveResultTags(provider as ResultCacheTagsProvider, resultOrArgs as V, maybeArgs) - : this.resolveArgTags(provider as CacheTagsProvider, resultOrArgs as TArgs); + ? await this.resolveResultTags(provider as ResultCacheTagsProvider, resultOrArgs as V, maybeArgs) + : await this.resolveArgTags(provider as CacheTagsProvider, resultOrArgs as TArgs); if (!tags.length) { return; @@ -229,16 +233,16 @@ implements OmitStrict, 'wrap' | 'wrapWri * @param args - Additional arguments. * @returns All resolved CacheTags. */ - protected resolveResultTags( + protected async resolveResultTags( provider: ResultCacheTagsProvider | undefined, result: V, args: TArgs - ): CacheTag[] { + ): Promise { if (!provider) { return []; } if (typeof provider === 'function') { - return provider(result, ...args); + return await provider(result, ...args); } return provider; } @@ -249,15 +253,15 @@ implements OmitStrict, 'wrap' | 'wrapWri * @param args - The arguments. * @returns All resolved CacheTags. */ - protected resolveArgTags( + protected async resolveArgTags( provider: CacheTagsProvider | undefined, args: TArgs - ): CacheTag[] { + ): Promise { if (!provider) { return []; } if (typeof provider === 'function') { - return provider(...args); + return await provider(...args); } return provider; } @@ -269,19 +273,19 @@ implements OmitStrict, 'wrap' | 'wrapWri * @param args - The arguments. * @returns The resolved time to live or undefined if values should be kept in cache without time limit. */ - protected resolveResultTtl( + protected async resolveResultTtl( provider: ResultCacheTtlProvider | undefined, result: V, args: TArgs - ): number | undefined { + ): Promise { if (provider == undefined) { if (typeof this.defaultTtl === 'function') { - return this.defaultTtl(); + return await this.defaultTtl(); } return this.defaultTtl; } if (typeof provider === 'function') { - return provider(result, ...args); + return await provider(result, ...args); } return provider; } @@ -292,18 +296,18 @@ implements OmitStrict, 'wrap' | 'wrapWri * @param args - The arguments. * @returns The resolved time to live or undefined if values should be kept in cache without time limit. */ - protected resolveArgsTtl( + protected async resolveArgsTtl( provider: CacheTtlProvider | undefined, args: TArgs - ): number | undefined { + ): Promise { if (provider == undefined) { if (typeof this.defaultTtl === 'function') { - return this.defaultTtl(); + return await this.defaultTtl(); } return this.defaultTtl; } if (typeof provider === 'function') { - return provider(...args); + return await provider(...args); } return provider; } diff --git a/src/caching/cache/cache-options.model.ts b/src/caching/cache/cache-options.model.ts index 4be8e03..ed6a79a 100644 --- a/src/caching/cache/cache-options.model.ts +++ b/src/caching/cache/cache-options.model.ts @@ -1,39 +1,55 @@ /** * Provider for a time to live value. */ -export type CacheTtlProvider = number | ((...args: TArgs) => number | undefined); +export type CacheTtlProvider = number | ((...args: TArgs) => (number | undefined) | Promise); /** * Provider for a time to live value where the result of the original function is available. */ -export type ResultCacheTtlProvider = number | ((result: TResult, ...args: TArgs) => number | undefined); +export type ResultCacheTtlProvider = number + | ((result: TResult, ...args: TArgs) => (number | undefined) | Promise); /** * Tags derived from args only (no result available) — wrapDelete, wrapInvalidate. */ -export type CacheTagsProvider = CacheTag[] | ((...args: TArgs) => CacheTag[]); +export type CacheTagsProvider = CacheTag[] + | ((...args: TArgs) => CacheTag[] | Promise); /** * Tags derived from result + args — wrap, wrapWrite. */ export type ResultCacheTagsProvider = CacheTag[] - | ((result: TResult, ...args: TArgs) => CacheTag[]); + | ((result: TResult, ...args: TArgs) => CacheTag[] | Promise); /** * Provider for a cache key. */ -export type CacheKeyProvider = (...args: TArgs) => K; +export type CacheKeyProvider = (...args: TArgs) => K | Promise; /** * Provider for a cache key where the result of the original function is available. */ -export type ResultCacheKeyProvider = (result: TResult, ...args: TArgs) => K; +export type ResultCacheKeyProvider = (result: TResult, ...args: TArgs) => K | Promise; /** * The options on how to handle invalidation failures. */ export type OnInvalidationFailure = 'bestEffort' | 'throw'; +/** + * Options for manually setting a value to the cache. + */ +export type CacheSetDirectOptions = { + /** + * The time to live value. + */ + ttl?: number, + /** + * Additional tags to set. + */ + tags?: CacheTag[] +}; + /** * Options for the wrap method of a cache. */ diff --git a/src/caching/cache/cache.interface.ts b/src/caching/cache/cache.interface.ts index 5197c59..df01f1f 100644 --- a/src/caching/cache/cache.interface.ts +++ b/src/caching/cache/cache.interface.ts @@ -1,15 +1,21 @@ import { type CacheTagMatcher } from '../cache-tag-matchers'; -import { CacheKeyProvider, CacheWrapDeleteOptions, CacheWrapInvalidateOptions, CacheWrapOptions, CacheWrapWriteOptionsArgsOnly, CacheWrapWriteOptionsWithResult, OnInvalidationFailure, ResultCacheKeyProvider } from './cache-options.model'; +import { CacheKeyProvider, CacheSetDirectOptions, CacheWrapDeleteOptions, CacheWrapInvalidateOptions, CacheWrapOptions, CacheWrapWriteOptionsArgsOnly, CacheWrapWriteOptionsWithResult, OnInvalidationFailure, ResultCacheKeyProvider } from './cache-options.model'; +import { MultiTierCache } from './multi-tier.cache'; +import { ExcludeStrict } from '../../types/exclude-strict.type'; import { type CacheStoreInterface } from '../store/cache-store.interface'; /** * Definition for a cache. */ -export interface CacheInterface { +export interface CacheInterface { + /** + * Phantom carrier, only for type inference. + */ + readonly _writeResultAvailable: WriteResultAvailable, /** * The name of the cache. Should be unique. */ - readonly name: string, + readonly name: N, /** * The tags that any values inside of this cache might have. * @@ -20,7 +26,7 @@ export interface CacheInterface number), + readonly defaultTtl?: number | (() => number | Promise), /** * Whether to throw when invalidation fails or to just log and ignore. */ @@ -73,14 +79,22 @@ export interface CacheInterface( fn: (...args: TArgs) => TReturn | Promise, options: CacheWrapInvalidateOptions // required — this method exists solely to invalidate - ) => (...args: TArgs) => Promise + ) => (...args: TArgs) => Promise, + /** + * Directly write a value into this cache, following its configured + * write strategy. + * + * Use this instead of `wrapWrite` when the source function has already + * been called and you only need to propagate the result. + */ + setDirect: (key: K, value: V, options?: CacheSetDirectOptions) => Promise } /** * The type for any unspecified cache. */ -// eslint-disable-next-line typescript/no-explicit-any -export type AnyCache = CacheInterface | CacheInterface; +// eslint-disable-next-line typescript/no-explicit-any, stylistic/max-len +export type AnyCache = MultiTierCache | CacheInterface | CacheInterface; /** * Checks whether or not the given value is a cache. @@ -88,7 +102,12 @@ export type AnyCache = CacheInterface | CacheInterface>)[] = [ 'defaultTtl', 'name', 'onInvalidationFailure', diff --git a/src/caching/cache/multi-tier.cache.test.ts b/src/caching/cache/multi-tier.cache.test.ts new file mode 100644 index 0000000..770b105 --- /dev/null +++ b/src/caching/cache/multi-tier.cache.test.ts @@ -0,0 +1,150 @@ +import { afterAll, beforeAll, describe, expect, it, jest } from '@jest/globals'; + +import { MultiTierCache } from './multi-tier.cache'; +import { StartedTestServer, startTestServer } from '../../__testing__/test-server/start-test-server.function'; +import { Inject } from '../../di/decorators/inject.decorator'; +import { ZIBRI_DI_TOKENS } from '../../di/default/zibri-di-tokens.default'; +import { inject } from '../../di/inject.function'; +import { type LoggerInterface } from '../../logging/logger.interface'; +import { type MetricsServiceInterface } from '../../metrics/metrics-service.interface'; +import { type CacheServiceInterface } from '../cache-service.interface'; +import { InMemoryCacheStore } from '../store/in-memory.cache-store'; +import { WriteThroughReadThroughCache } from './read-through/write-through-read-through.cache'; +import { Cache } from '../decorators/cache.decorator'; +import { CachedValue } from '../store/cached-value.model'; + +// --------------------------------------------------------------------------- +// Two simple tiers – both are plain WriteThroughReadThrough caches. +// --------------------------------------------------------------------------- +@Cache() +class FastCache extends WriteThroughReadThroughCache { + + constructor( + @Inject(ZIBRI_DI_TOKENS.LOGGER) + protected readonly logger: LoggerInterface, + @Inject(ZIBRI_DI_TOKENS.METRICS_SERVICE) + protected readonly metricsService: MetricsServiceInterface, + @Inject(ZIBRI_DI_TOKENS.CACHE_SERVICE) + protected readonly cacheService: CacheServiceInterface + ) { + super('Fast', new InMemoryCacheStore(), 'all'); + } +} + +@Cache() +class SlowCache extends WriteThroughReadThroughCache { + constructor( + @Inject(ZIBRI_DI_TOKENS.LOGGER) + protected readonly logger: LoggerInterface, + @Inject(ZIBRI_DI_TOKENS.METRICS_SERVICE) + protected readonly metricsService: MetricsServiceInterface, + @Inject(ZIBRI_DI_TOKENS.CACHE_SERVICE) + protected readonly cacheService: CacheServiceInterface + ) { + super('Slow', new InMemoryCacheStore(), 'all'); + } +} + +// --------------------------------------------------------------------------- +// The multi‑tier cache under test – it uses FastCache and SlowCache. +// --------------------------------------------------------------------------- +@Cache() +class TestMultiTierCache extends MultiTierCache { + constructor( + @Inject(ZIBRI_DI_TOKENS.LOGGER) + protected readonly logger: LoggerInterface, + @Inject(ZIBRI_DI_TOKENS.METRICS_SERVICE) + protected readonly metricsService: MetricsServiceInterface, + @Inject(ZIBRI_DI_TOKENS.CACHE_SERVICE) + protected readonly cacheService: CacheServiceInterface, + @Inject(FastCache) + fast: FastCache, + @Inject(SlowCache) + slow: SlowCache + ) { + super('TestMulti', [fast, slow]); + } +} + +describe('MultiTierCache – basic integration', () => { + let server: StartedTestServer; + + beforeAll(async () => { + server = await startTestServer({}); + }, 15000); + + afterAll(async () => { + await server?.shutdown(); + }); + + // ------------------------------------------------------------------ + // 1. read through: miss populates both tiers, next call hits fast tier + // ------------------------------------------------------------------ + it('populates both tiers on wrap miss and returns from fast tier on second call', async () => { + const multi: TestMultiTierCache = inject(TestMultiTierCache); + const fast: FastCache = inject(FastCache); + const slow: SlowCache = inject(SlowCache); + + // eslint-disable-next-line typescript/typedef, unusedImports/no-unused-vars + const fn = jest.fn((_key: string) => Promise.resolve(42)); + // eslint-disable-next-line typescript/typedef + const wrapped = multi.wrap(fn, key => key); + + // first call – miss, fn is invoked + const first: number = await wrapped('alpha'); + expect(first).toBe(42); + expect(fn).toHaveBeenCalledTimes(1); + + // both tiers now contain the value + expect(await fast.store.get('alpha')).toHaveProperty('value', 42); + expect(await slow.store.get('alpha')).toHaveProperty('value', 42); + + // second call – hit (fast tier), fn is NOT called again + const second: number = await wrapped('alpha'); + expect(second).toBe(42); + expect(fn).toHaveBeenCalledTimes(1); + }); + + // ------------------------------------------------------------------ + // 2. write through: populate both tiers with per‑tier options + // ------------------------------------------------------------------ + it('wrapWrite populates all tiers and honours per‑tier ttl', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + + const multi: TestMultiTierCache = inject(TestMultiTierCache); + const fast: FastCache = inject(FastCache); + const slow: SlowCache = inject(SlowCache); + + // eslint-disable-next-line typescript/typedef + const fn = (name: string): Promise => Promise.resolve(name.length); + + // eslint-disable-next-line typescript/typedef + const wrapped = multi.wrapWrite( + fn, + // keyFn – key comes from result + args (WR = true because both tiers are true) + (res, name) => `user:${name}:${res}`, + { + perCache: { + // fast tier: 10‑second TTL + Fast: { ttl: 10_000 }, + // slow tier: 60‑second TTL + Slow: { ttl: 60_000 } + } + } + ); + + await expect(wrapped('Alice')).resolves.toBe(5); + + const fastEntry: CachedValue | undefined = await fast.store.get('user:Alice:5'); + const slowEntry: CachedValue | undefined = await slow.store.get('user:Alice:5'); + + expect(fastEntry?.value).toBe(5); + expect(fastEntry?.expiresAt).toEqual(new Date('2026-01-01T00:00:10.000Z')); + + expect(slowEntry?.value).toBe(5); + expect(slowEntry?.expiresAt).toEqual(new Date('2026-01-01T00:01:00.000Z')); + + jest.useRealTimers(); + }); +}); \ No newline at end of file diff --git a/src/caching/cache/multi-tier.cache.ts b/src/caching/cache/multi-tier.cache.ts new file mode 100644 index 0000000..19bc44f --- /dev/null +++ b/src/caching/cache/multi-tier.cache.ts @@ -0,0 +1,297 @@ + +import { CacheOperation } from './cache-operation.enum'; +import { CacheWrapOptions, CacheWrapWriteOptionsWithResult, CacheWrapWriteOptionsArgsOnly, CacheWrapDeleteOptions, CacheWrapInvalidateOptions, CacheKeyProvider, ResultCacheKeyProvider, OnInvalidationFailure, CacheTagsProvider, CacheSetDirectOptions } from './cache-options.model'; +import { CacheInterface } from './cache.interface'; +import { AlsUtilities } from '../../context/als.utilities'; +import { LogCacheContext } from '../../logging/log-context.model'; +import { LoggerInterface } from '../../logging/logger.interface'; +import { MetricsServiceInterface } from '../../metrics/metrics-service.interface'; +import { CacheMetrics } from '../cache-metrics.model'; +import { CacheServiceInterface } from '../cache-service.interface'; +import { ExtractCacheWriteResultAvailable } from '../decorators/decorator-types'; +import { CacheStoreInterface } from '../store/cache-store.interface'; +import { CachedValue } from '../store/cached-value.model'; + +// eslint-disable-next-line jsdoc/require-jsdoc +type TierLike = { + // eslint-disable-next-line jsdoc/require-jsdoc + readonly name: N, + // eslint-disable-next-line jsdoc/require-jsdoc + store: CacheStoreInterface, + // eslint-disable-next-line jsdoc/require-jsdoc + _writeResultAvailable?: boolean, + // eslint-disable-next-line typescript/method-signature-style + setDirect(key: K, value: V, options?: CacheSetDirectOptions): Promise +}; + +// eslint-disable-next-line typescript/no-explicit-any, jsdoc/require-jsdoc +type TierName[]> = Tiers[number]['name']; + +/** Outer WR – true if any tier is true, else false. */ +// eslint-disable-next-line typescript/no-explicit-any +type OuterWR[]> = true extends ExtractCacheWriteResultAvailable + ? true + : false; + +/** The options object for wrapWrite. */ +export type MultiWrapWriteOptions< + V, + TArgs extends unknown[], + CacheTag extends string, + // eslint-disable-next-line typescript/no-explicit-any + Tiers extends readonly TierLike[] +> = { + /** + * The configuration for each internal cache. + */ + perCache?: Partial, CacheWrapOptions>>, + /** + * A provider for the tags to invalidate. + */ + invalidatesTags?: CacheTagsProvider +}; + +/** Per‑tier wrap options – just `CacheWrapOptions` for each tier. */ +export type MultiWrapOptions< + V, + TArgs extends unknown[], + CacheTag extends string, + // eslint-disable-next-line typescript/no-explicit-any + Tiers extends readonly TierLike[] +> = Partial, CacheWrapOptions>>; + +/** + * Multi‑tier cache that composes multiple {@link CacheInterface} instances. + * + * Reads are cascaded through the tiers (fastest first). When a lower tier + * hits, all faster tiers are back‑filled. Writes are propagated to **every** + * tier via their setDirect method. + * + * The `WriteResultAvailable` flag must be supplied explicitly. If you want + * to mix a `false` tier (e.g. Write‑Around) into a `true` cache, simply cast it + * `as CacheInterface` – the key is provided but the tier + * can safely ignore it. + */ +export abstract class MultiTierCache< + K, + V, + // eslint-disable-next-line typescript/no-explicit-any + Tiers extends readonly TierLike[], + CacheTag extends string = string +> { + // Outer WR is computed, not stated by the user. + private readonly _writeResultAvailable: boolean; + + private _metrics: CacheMetrics | undefined; + + protected abstract cacheService: CacheServiceInterface; + + protected abstract logger: LoggerInterface; + + protected abstract metricsService: MetricsServiceInterface; + + // eslint-disable-next-line jsdoc/require-returns + /** + * The cache metrics. + */ + protected get metrics(): CacheMetrics { + this._metrics ??= this.initMetrics(); + return this._metrics; + } + + constructor( + readonly name: string, + private readonly tiers: Tiers, + readonly onInvalidationFailure?: OnInvalidationFailure + ) { + if (tiers.length === 0) { + throw new Error('MultiTierCache requires at least one tier'); + } + this._writeResultAvailable = tiers.some(t => t._writeResultAvailable === true); + } + + private initMetrics(): CacheMetrics { + return { + hits: this.metricsService.getCounter('cache_hits_total', ['cache']), + misses: this.metricsService.getCounter('cache_misses_total', ['cache']), + writes: this.metricsService.getCounter('cache_writes_total', ['cache']), + deletes: this.metricsService.getCounter('cache_deletes_total', ['cache']), + invalidations: this.metricsService.getCounter('cache_invalidations_total', ['cache']), + invalidationFailures: this.metricsService.getCounter('cache_invalidation_failures_total', ['cache']), + expiredEvictions: this.metricsService.getCounter('cache_expired_evictions_total', ['cache']), + errors: this.metricsService.getCounter('cache_errors_total', ['cache', 'operation']), + inFlight: this.metricsService.getGauge('cache_in_flight', ['cache']), + size: this.metricsService.getGauge('cache_size', ['cache']), + sourceDuration: this.metricsService.getHistogram( + 'cache_source_duration_ms', + ['cache', 'operation'], + [1, 5, 10, 25, 50, 100, 250, 500, 1000] + ), + storeDuration: this.metricsService.getHistogram( + 'cache_store_duration_ms', + ['cache', 'operation'], + [1, 5, 10, 25, 50, 100, 250, 500] + ) + }; + } + + // eslint-disable-next-line jsdoc/require-jsdoc + wrap( + fn: (...args: TArgs) => V | Promise, + keyFn: CacheKeyProvider, + options?: MultiWrapOptions + ): (...args: TArgs) => Promise { + return async (...args) => { + const key: K = await keyFn(...args); + + for (let i: number = 0; i < this.tiers.length; i++) { + const cached: CachedValue | undefined = await this.tiers[i].store.get(key); + if (cached && (!cached.expiresAt || cached.expiresAt > new Date())) { + // back‑fill earlier tiers – per‑tier options are NOT used here + for (let j: number = 0; j < i; j++) { + this.tiers[j].setDirect(key, cached.value, { + ttl: cached.expiresAt + ? cached.expiresAt.getTime() - Date.now() + : undefined, + tags: cached.tags as CacheTag[] + // eslint-disable-next-line promise/prefer-await-to-then + }).catch(() => {}); + } + return cached.value; + } + } + + // miss + const value: V = await fn(...args); + + // populate all tiers using per‑tier options + await Promise.all(this.tiers.map(async (tier) => { + const tierOpt: CacheWrapOptions | undefined = options?.[tier.name as TierName]; + const ttl: number | undefined = typeof tierOpt?.ttl === 'function' + ? await tierOpt.ttl(value, ...args) + : tierOpt?.ttl; + const tags: CacheTag[] | undefined = typeof tierOpt?.tags === 'function' + ? await tierOpt.tags(value, ...args) + : tierOpt?.tags; + await tier.setDirect(key, value, { ttl, tags }).catch(() => {}); + })); + return value; + }; + } + + // eslint-disable-next-line jsdoc/require-jsdoc + wrapWrite( + fn: (...args: TArgs) => V | Promise, + keyFn: OuterWR extends true + ? ResultCacheKeyProvider + : CacheKeyProvider, + options?: MultiWrapWriteOptions + ): (...args: TArgs) => Promise { + return async (...args) => { + const value: V = await fn(...args); + const key: K = this._writeResultAvailable + ? await (keyFn as ResultCacheKeyProvider)(value, ...args) + : await (keyFn as CacheKeyProvider)(...args); + + const perTier: Partial, CacheWrapOptions>> | undefined = options?.perCache; + await Promise.all(this.tiers.map(async (tier) => { + const tierOpt: CacheWrapWriteOptionsWithResult + | CacheWrapWriteOptionsArgsOnly + | undefined = perTier?.[tier.name as TierName]; + let ttl: number | undefined; + let tags: CacheTag[] | undefined; + + if (this._writeResultAvailable) { + const o: CacheWrapWriteOptionsWithResult | undefined = tierOpt; + ttl = o?.ttl != undefined + ? typeof o.ttl === 'function' ? await o.ttl(value, ...args) : o.ttl + : undefined; + tags = o?.tags + ? typeof o.tags === 'function' ? await o.tags(value, ...args) : o.tags + : undefined; + } + else { + // eslint-disable-next-line stylistic/max-len + const o: CacheWrapWriteOptionsArgsOnly | undefined = tierOpt as CacheWrapWriteOptionsArgsOnly | undefined; + ttl = o?.ttl != undefined + ? typeof o.ttl === 'function' ? await o.ttl(...args) : o.ttl + : undefined; + tags = o?.tags + ? typeof o.tags === 'function' ? await o.tags(...args) : o.tags + : undefined; + } + + await tier.setDirect(key, value, { ttl, tags }).catch(() => {}); + })); + + // global tag invalidation + if (options?.invalidatesTags) { + const tags: CacheTag[] = typeof options.invalidatesTags === 'function' + ? await options.invalidatesTags(...args) + : options.invalidatesTags; + await this.safeInvalidateTags(tags); + } + return value; + }; + } + + // eslint-disable-next-line jsdoc/require-jsdoc + wrapDelete( + fn: (...args: TArgs) => TReturn | Promise, + keyFn: CacheKeyProvider, + options?: CacheWrapDeleteOptions + ): (...args: TArgs) => Promise { + return async (...args) => { + const cacheCtx: LogCacheContext = { cache: this.name, operation: CacheOperation.DELETE }; + return AlsUtilities.runWithCacheContext(cacheCtx, async () => { + const res: TReturn = await fn(...args); + const key: K = await keyFn(...args); + cacheCtx.key = key; + await Promise.all(this.tiers.map(tier => tier.store.delete(key))); + this.metrics.deletes.increase({ cache: this.name }); + if (options?.invalidatesTags) { + const tags: CacheTag[] = typeof options.invalidatesTags === 'function' + ? await options.invalidatesTags(...args) + : options.invalidatesTags; + await this.safeInvalidateTags(tags); + } + return res; + }); + }; + } + + // eslint-disable-next-line jsdoc/require-jsdoc + wrapInvalidate( + fn: (...args: TArgs) => TReturn | Promise, + options: CacheWrapInvalidateOptions + ): (...args: TArgs) => Promise { + return async (...args) => { + const cacheCtx: LogCacheContext = { cache: this.name, operation: CacheOperation.INVALIDATE }; + return AlsUtilities.runWithCacheContext(cacheCtx, async () => { + const result: TReturn = await fn(...args); + const tags: CacheTag[] = typeof options.invalidatesTags === 'function' + ? await options.invalidatesTags(...args) + : options.invalidatesTags; + await this.safeInvalidateTags(tags); + return result; + }); + }; + } + + private async safeInvalidateTags(tags: CacheTag[]): Promise { + if (!tags.length) { + return; + } + try { + await this.cacheService.invalidateTags(tags); + this.metrics.invalidations.increase({ cache: this.name }); + } + catch (error) { + this.metrics.invalidationFailures.increase({ cache: this.name }); + if (this.onInvalidationFailure === 'throw') { + throw error; + } + await this.logger.warn('Cache invalidation failed in multi‑tier cache', { error }); + } + } +} \ No newline at end of file diff --git a/src/caching/cache/read-aside/read-aside.cache.ts b/src/caching/cache/read-aside/read-aside.cache.ts index b7b974b..68f4bba 100644 --- a/src/caching/cache/read-aside/read-aside.cache.ts +++ b/src/caching/cache/read-aside/read-aside.cache.ts @@ -1,11 +1,9 @@ import { AlsUtilities } from '../../../context/als.utilities'; import { LogCacheContext } from '../../../logging/log-context.model'; -import { OmitStrict } from '../../../types/omit-strict.type'; import { CachedValue } from '../../store/cached-value.model'; import { BaseCache } from '../base-cache.model'; import { CacheOperation } from '../cache-operation.enum'; import { CacheKeyProvider, CacheWrapOptions } from '../cache-options.model'; -import { CacheInterface } from '../cache.interface'; /** * Read‑aside base class. @@ -17,10 +15,13 @@ import { CacheInterface } from '../cache.interface'; * `wrapDelete` and `wrapInvalidate` work exactly like their read‑through * counterparts. */ -export abstract class ReadAsideCache - extends BaseCache - implements OmitStrict, 'wrapWrite'> { - +export abstract class ReadAsideCache< + K, + V, + CacheTag extends string, + WriteResultAvailable extends boolean, + N extends string +> extends BaseCache { // eslint-disable-next-line jsdoc/require-jsdoc wrap( fn: (...args: TArgs) => V | Promise, @@ -37,7 +38,7 @@ export abstract class ReadAsideCache // ---------- try cache read ---------- try { - key = keyFn(...args); + key = await keyFn(...args); cacheCtx.key = key; const storeStart: number = performance.now(); diff --git a/src/caching/cache/read-aside/write-around-read-aside.cache.ts b/src/caching/cache/read-aside/write-around-read-aside.cache.ts index ac0f9ae..caca068 100644 --- a/src/caching/cache/read-aside/write-around-read-aside.cache.ts +++ b/src/caching/cache/read-aside/write-around-read-aside.cache.ts @@ -11,19 +11,20 @@ import { ReadAsideCache } from './read-aside.cache'; * Writes update the source and then only invalidate tags (no cache population). * Reads (`wrap`) check the cache but **never** populate it. */ -export abstract class WriteAroundReadAsideCache - extends ReadAsideCache - implements CacheInterface { +export abstract class WriteAroundReadAsideCache + extends ReadAsideCache + implements CacheInterface { + + // eslint-disable-next-line jsdoc/require-jsdoc + readonly _writeResultAvailable: false = false; // eslint-disable-next-line jsdoc/require-jsdoc wrapWrite( fn: (...args: TArgs) => V | Promise, - keyFn: CacheKeyProvider, // kept for interface compatibility, never used + _keyFn: CacheKeyProvider, // kept for interface compatibility, never used options?: CacheWrapWriteOptionsArgsOnly ): (...args: TArgs) => Promise { return async (...args) => { - void keyFn; - const cacheCtx: LogCacheContext = { cache: this.name, operation: CacheOperation.WRITE }; return AlsUtilities.runWithCacheContext(cacheCtx, async () => { @@ -43,4 +44,9 @@ export abstract class WriteAroundReadAsideCache { + // Write‑around: intentionally does not cache writes. + } } \ No newline at end of file diff --git a/src/caching/cache/read-aside/write-behind-read-aside.cache.ts b/src/caching/cache/read-aside/write-behind-read-aside.cache.ts index d17d956..fb56bd9 100644 --- a/src/caching/cache/read-aside/write-behind-read-aside.cache.ts +++ b/src/caching/cache/read-aside/write-behind-read-aside.cache.ts @@ -1,7 +1,7 @@ import { AlsUtilities } from '../../../context/als.utilities'; import { LogCacheContext } from '../../../logging/log-context.model'; import { CacheOperation } from '../cache-operation.enum'; -import { ResultCacheKeyProvider, CacheWrapWriteOptionsWithResult } from '../cache-options.model'; +import { ResultCacheKeyProvider, CacheWrapWriteOptionsWithResult, CacheSetDirectOptions } from '../cache-options.model'; import { CacheInterface } from '../cache.interface'; import { ReadAsideCache } from './read-aside.cache'; @@ -11,9 +11,12 @@ import { ReadAsideCache } from './read-aside.cache'; * Writes update the source and then fire‑and‑forget a cache store. * Reads (`wrap`) check the cache but **never** populate it. */ -export abstract class WriteBehindReadAsideCache - extends ReadAsideCache - implements CacheInterface { +export abstract class WriteBehindReadAsideCache + extends ReadAsideCache + implements CacheInterface { + + // eslint-disable-next-line jsdoc/require-jsdoc + readonly _writeResultAvailable: true = true; // eslint-disable-next-line jsdoc/require-jsdoc wrapWrite( @@ -36,10 +39,12 @@ export abstract class WriteBehindReadAsideCache): Promise { + const ttl: number | undefined = options?.ttl ?? await this.resolveResultTtl(undefined, value, []); + const tags: CacheTag[] = options?.tags ?? []; + Promise.resolve() + // eslint-disable-next-line promise/prefer-await-to-then + .then(async () => { + const storeStart: number = performance.now(); + await this.store.set(key, this.createCachedValue(value, tags, ttl)); + this.metrics.storeDuration.observe({ cache: this.name, operation: 'set' }, performance.now() - storeStart); + this.metrics.writes.increase({ cache: this.name }); + await this.updateSizeGauge(); + }) + // eslint-disable-next-line promise/prefer-await-to-then, promise/prefer-await-to-callbacks + .catch(async (error) => { + this.metrics.errors.increase({ cache: this.name, operation: 'set' }); + await this.logger.warn('Background cache write failed in setDirect', { error }); + }); + } } \ No newline at end of file diff --git a/src/caching/cache/read-aside/write-invalidate-read-aside-args-only.cache.ts b/src/caching/cache/read-aside/write-invalidate-read-aside-args-only.cache.ts index a3d77fd..4035fb4 100644 --- a/src/caching/cache/read-aside/write-invalidate-read-aside-args-only.cache.ts +++ b/src/caching/cache/read-aside/write-invalidate-read-aside-args-only.cache.ts @@ -11,9 +11,12 @@ import { ReadAsideCache } from './read-aside.cache'; * After a source write, the cache entry derived from **arguments** is deleted. * Reads (`wrap`) check the cache but **never** populate it. */ -export abstract class WriteInvalidateReadAsideArgsOnlyCache - extends ReadAsideCache - implements CacheInterface { +export abstract class WriteInvalidateReadAsideArgsOnlyCache + extends ReadAsideCache + implements CacheInterface { + + // eslint-disable-next-line jsdoc/require-jsdoc + readonly _writeResultAvailable: false = false; // eslint-disable-next-line jsdoc/require-jsdoc wrapWrite( @@ -38,7 +41,7 @@ export abstract class WriteInvalidateReadAsideArgsOnlyCache { - const key: K = keyFn(...args); + const key: K = await keyFn(...args); cacheCtx.key = key; const storeStart: number = performance.now(); @@ -63,4 +66,19 @@ export abstract class WriteInvalidateReadAsideArgsOnlyCache { + try { + const storeStart: number = performance.now(); + await this.store.delete(key); + this.metrics.storeDuration.observe({ cache: this.name, operation: 'delete' }, performance.now() - storeStart); + this.metrics.deletes.increase({ cache: this.name }); + await this.updateSizeGauge(); + } + catch (error) { + this.metrics.errors.increase({ cache: this.name, operation: 'delete' }); + await this.logger.warn('Cache invalidation (delete) failed in setDirect', { error }); + } + } } \ No newline at end of file diff --git a/src/caching/cache/read-aside/write-invalidate-read-aside-with-result.cache.ts b/src/caching/cache/read-aside/write-invalidate-read-aside-with-result.cache.ts index 3aa95fb..075c31b 100644 --- a/src/caching/cache/read-aside/write-invalidate-read-aside-with-result.cache.ts +++ b/src/caching/cache/read-aside/write-invalidate-read-aside-with-result.cache.ts @@ -11,9 +11,12 @@ import { ReadAsideCache } from './read-aside.cache'; * After a source write, the cache entry derived from the **result** is deleted. * Reads (`wrap`) check the cache but **never** populate it. */ -export abstract class WriteInvalidateReadAsideWithResultCache - extends ReadAsideCache - implements CacheInterface { +export abstract class WriteInvalidateReadAsideWithResultCache + extends ReadAsideCache + implements CacheInterface { + + // eslint-disable-next-line jsdoc/require-jsdoc + readonly _writeResultAvailable: true = true; // eslint-disable-next-line jsdoc/require-jsdoc wrapWrite( @@ -38,7 +41,7 @@ export abstract class WriteInvalidateReadAsideWithResultCache { - const key: K = keyFn(value, ...args); + const key: K = await keyFn(value, ...args); cacheCtx.key = key; const storeStart: number = performance.now(); @@ -63,4 +66,19 @@ export abstract class WriteInvalidateReadAsideWithResultCache { + try { + const storeStart: number = performance.now(); + await this.store.delete(key); + this.metrics.storeDuration.observe({ cache: this.name, operation: 'delete' }, performance.now() - storeStart); + this.metrics.deletes.increase({ cache: this.name }); + await this.updateSizeGauge(); + } + catch (error) { + this.metrics.errors.increase({ cache: this.name, operation: 'delete' }); + await this.logger.warn('Cache invalidation (delete) failed in setDirect', { error }); + } + } } \ No newline at end of file diff --git a/src/caching/cache/read-aside/write-through-read-aside.cache.ts b/src/caching/cache/read-aside/write-through-read-aside.cache.ts index ebd230e..e74994d 100644 --- a/src/caching/cache/read-aside/write-through-read-aside.cache.ts +++ b/src/caching/cache/read-aside/write-through-read-aside.cache.ts @@ -1,7 +1,7 @@ import { AlsUtilities } from '../../../context/als.utilities'; import { LogCacheContext } from '../../../logging/log-context.model'; import { CacheOperation } from '../cache-operation.enum'; -import { ResultCacheKeyProvider, CacheWrapWriteOptionsWithResult } from '../cache-options.model'; +import { ResultCacheKeyProvider, CacheWrapWriteOptionsWithResult, CacheSetDirectOptions } from '../cache-options.model'; import { CacheInterface } from '../cache.interface'; import { ReadAsideCache } from './read-aside.cache'; @@ -11,9 +11,12 @@ import { ReadAsideCache } from './read-aside.cache'; * Writes update the source and immediately store the result in the cache. * Reads (`wrap`) check the cache but **never** populate it. */ -export abstract class WriteThroughReadAsideCache - extends ReadAsideCache - implements CacheInterface { +export abstract class WriteThroughReadAsideCache + extends ReadAsideCache + implements CacheInterface { + + // eslint-disable-next-line jsdoc/require-jsdoc + readonly _writeResultAvailable: true = true; // eslint-disable-next-line jsdoc/require-jsdoc wrapWrite( @@ -37,10 +40,12 @@ export abstract class WriteThroughReadAsideCache): Promise { + try { + const ttl: number | undefined = options?.ttl ?? await this.resolveResultTtl(undefined, value, []); + const tags: CacheTag[] = options?.tags ?? []; + await this.store.set(key, this.createCachedValue(value, tags, ttl)); + this.metrics.writes.increase({ cache: this.name }); + await this.updateSizeGauge(); + } + catch (error) { + this.metrics.errors.increase({ cache: this.name, operation: 'set' }); + await this.logger.warn('Cache store failed in setDirect', { error }); + } + } } \ No newline at end of file diff --git a/src/caching/cache/read-through/read-through.cache.ts b/src/caching/cache/read-through/read-through.cache.ts index a4e6378..c3b5353 100644 --- a/src/caching/cache/read-through/read-through.cache.ts +++ b/src/caching/cache/read-through/read-through.cache.ts @@ -8,7 +8,13 @@ import { CacheKeyProvider, CacheWrapOptions } from '../cache-options.model'; /** * Base class for all read through caches. */ -export abstract class ReadThroughCache extends BaseCache { +export abstract class ReadThroughCache< + K, + V, + CacheTag extends string, + WriteResultAvailable extends boolean, + N extends string +> extends BaseCache { // eslint-disable-next-line jsdoc/require-jsdoc wrap( fn: (...args: TArgs) => V | Promise, @@ -23,7 +29,7 @@ export abstract class ReadThroughCache extends Ba let key: K | undefined; try { - key = keyFn(...args); + key = await keyFn(...args); cacheCtx.key = key; const storeStart: number = performance.now(); @@ -71,8 +77,10 @@ export abstract class ReadThroughCache extends Ba this.metrics.sourceDuration.observe({ cache: this.name, operation: CacheOperation.WRAP }, sourceDuration); try { - const ttl: number | undefined = this.resolveResultTtl(options?.ttl, value, args); - const tags: CacheTag[] = this.resolveResultTags(options?.tags, value, args); + const [tags, ttl] = await Promise.all([ + this.resolveResultTags(options?.tags, value, args), + this.resolveResultTtl(options?.ttl, value, args) + ]); const storeStart: number = performance.now(); await this.store.set(key, this.createCachedValue(value, tags, ttl)); diff --git a/src/caching/cache/read-through/write-around-read-through.cache.ts b/src/caching/cache/read-through/write-around-read-through.cache.ts index 7216f47..48be821 100644 --- a/src/caching/cache/read-through/write-around-read-through.cache.ts +++ b/src/caching/cache/read-through/write-around-read-through.cache.ts @@ -9,19 +9,20 @@ import { ReadThroughCache } from './read-through.cache'; * A write-around read-through cache. * Reads go through the cache, writes bypass the cache. */ -export abstract class WriteAroundReadThroughCache - extends ReadThroughCache - implements CacheInterface { +export abstract class WriteAroundReadThroughCache + extends ReadThroughCache + implements CacheInterface { + + // eslint-disable-next-line jsdoc/require-jsdoc + readonly _writeResultAvailable: false = false; // eslint-disable-next-line jsdoc/require-jsdoc wrapWrite( fn: (...args: TArgs) => V | Promise, - keyFn: CacheKeyProvider, + _keyFn: CacheKeyProvider, options?: CacheWrapWriteOptionsArgsOnly ): (...args: TArgs) => Promise { return async (...args) => { - void keyFn; - const cacheCtx: LogCacheContext = { cache: this.name, operation: CacheOperation.WRITE }; return AlsUtilities.runWithCacheContext(cacheCtx, async () => { @@ -42,4 +43,9 @@ export abstract class WriteAroundReadThroughCache { + // Write‑around: intentionally does not cache writes. + } } \ No newline at end of file diff --git a/src/caching/cache/read-through/write-behind-read-through.cache.ts b/src/caching/cache/read-through/write-behind-read-through.cache.ts index dcbf54d..11c6ff7 100644 --- a/src/caching/cache/read-through/write-behind-read-through.cache.ts +++ b/src/caching/cache/read-through/write-behind-read-through.cache.ts @@ -1,7 +1,7 @@ import { AlsUtilities } from '../../../context/als.utilities'; import { LogCacheContext } from '../../../logging/log-context.model'; import { CacheOperation } from '../cache-operation.enum'; -import { ResultCacheKeyProvider, CacheWrapWriteOptionsWithResult } from '../cache-options.model'; +import { ResultCacheKeyProvider, CacheWrapWriteOptionsWithResult, CacheSetDirectOptions } from '../cache-options.model'; import { CacheInterface } from '../cache.interface'; import { ReadThroughCache } from './read-through.cache'; @@ -12,9 +12,12 @@ import { ReadThroughCache } from './read-through.cache'; * **asynchronously** (fire‑and‑forget). The client receives the result * without waiting for the cache store to complete. */ -export abstract class WriteBehindReadThroughCache - extends ReadThroughCache - implements CacheInterface { +export abstract class WriteBehindReadThroughCache + extends ReadThroughCache + implements CacheInterface { + + // eslint-disable-next-line jsdoc/require-jsdoc + readonly _writeResultAvailable: true = true; // eslint-disable-next-line jsdoc/require-jsdoc wrapWrite( @@ -39,11 +42,13 @@ export abstract class WriteBehindReadThroughCache): Promise { + const ttl: number | undefined = options?.ttl ?? await this.resolveResultTtl(undefined, value, []); + const tags: CacheTag[] = options?.tags ?? []; + Promise.resolve() + // eslint-disable-next-line promise/prefer-await-to-then + .then(async () => { + const storeStart: number = performance.now(); + await this.store.set(key, this.createCachedValue(value, tags, ttl)); + this.metrics.storeDuration.observe({ cache: this.name, operation: 'set' }, performance.now() - storeStart); + this.metrics.writes.increase({ cache: this.name }); + await this.updateSizeGauge(); + }) + // eslint-disable-next-line promise/prefer-await-to-then, promise/prefer-await-to-callbacks + .catch(async (error) => { + this.metrics.errors.increase({ cache: this.name, operation: 'set' }); + await this.logger.warn('Background cache write failed in setDirect', { error }); + }); + } } \ No newline at end of file diff --git a/src/caching/cache/read-through/write-invalidate-read-through-args-only.cache.ts b/src/caching/cache/read-through/write-invalidate-read-through-args-only.cache.ts index 32f7e5b..10b5a20 100644 --- a/src/caching/cache/read-through/write-invalidate-read-through-args-only.cache.ts +++ b/src/caching/cache/read-through/write-invalidate-read-through-args-only.cache.ts @@ -12,9 +12,12 @@ import { ReadThroughCache } from './read-through.cache'; * argument‑derived key is **deleted**, and optional tag invalidations * are fired. */ -export abstract class WriteInvalidateReadThroughArgsOnlyCache - extends ReadThroughCache - implements CacheInterface { +export abstract class WriteInvalidateReadThroughArgsOnlyCache + extends ReadThroughCache + implements CacheInterface { + + // eslint-disable-next-line jsdoc/require-jsdoc + readonly _writeResultAvailable: false = false; // eslint-disable-next-line jsdoc/require-jsdoc wrapWrite( @@ -40,7 +43,7 @@ export abstract class WriteInvalidateReadThroughArgsOnlyCache { - const key: K = keyFn(...args); + const key: K = await keyFn(...args); cacheCtx.key = key; const storeStart: number = performance.now(); @@ -65,4 +68,19 @@ export abstract class WriteInvalidateReadThroughArgsOnlyCache { + try { + const storeStart: number = performance.now(); + await this.store.delete(key); + this.metrics.storeDuration.observe({ cache: this.name, operation: 'delete' }, performance.now() - storeStart); + this.metrics.deletes.increase({ cache: this.name }); + await this.updateSizeGauge(); + } + catch (error) { + this.metrics.errors.increase({ cache: this.name, operation: 'delete' }); + await this.logger.warn('Cache invalidation (delete) failed in setDirect', { error }); + } + } } \ No newline at end of file diff --git a/src/caching/cache/read-through/write-invalidate-read-through-with-result.cache.ts b/src/caching/cache/read-through/write-invalidate-read-through-with-result.cache.ts index c72d13e..6fa8e5d 100644 --- a/src/caching/cache/read-through/write-invalidate-read-through-with-result.cache.ts +++ b/src/caching/cache/read-through/write-invalidate-read-through-with-result.cache.ts @@ -11,9 +11,12 @@ import { ReadThroughCache } from './read-through.cache'; * After a successful source write, the cache entry identified by the * **result** is deleted (typically after a creation that generates an ID). */ -export abstract class WriteInvalidateReadThroughWithResultCache - extends ReadThroughCache - implements CacheInterface { +export abstract class WriteInvalidateReadThroughWithResultCache + extends ReadThroughCache + implements CacheInterface { + + // eslint-disable-next-line jsdoc/require-jsdoc + readonly _writeResultAvailable: true = true; // eslint-disable-next-line jsdoc/require-jsdoc wrapWrite( @@ -39,7 +42,7 @@ export abstract class WriteInvalidateReadThroughWithResultCache { - const key: K = keyFn(value, ...args); + const key: K = await keyFn(value, ...args); cacheCtx.key = key; const storeStart: number = performance.now(); @@ -64,4 +67,19 @@ export abstract class WriteInvalidateReadThroughWithResultCache { + try { + const storeStart: number = performance.now(); + await this.store.delete(key); + this.metrics.storeDuration.observe({ cache: this.name, operation: 'delete' }, performance.now() - storeStart); + this.metrics.deletes.increase({ cache: this.name }); + await this.updateSizeGauge(); + } + catch (error) { + this.metrics.errors.increase({ cache: this.name, operation: 'delete' }); + await this.logger.warn('Cache invalidation (delete) failed in setDirect', { error }); + } + } } \ No newline at end of file diff --git a/src/caching/cache/read-through/write-through-read-through.cache.test.ts b/src/caching/cache/read-through/write-through-read-through.cache.test.ts index 578adb1..859f46c 100644 --- a/src/caching/cache/read-through/write-through-read-through.cache.test.ts +++ b/src/caching/cache/read-through/write-through-read-through.cache.test.ts @@ -16,7 +16,7 @@ import { CachedValue } from '../../store/cached-value.model'; import { InMemoryCacheStore } from '../../store/in-memory.cache-store'; @Cache() -class TestCache extends WriteThroughReadThroughCache { +class TestCache extends WriteThroughReadThroughCache { constructor( @Inject(ZIBRI_DI_TOKENS.LOGGER) protected readonly logger: LoggerInterface, @@ -30,7 +30,7 @@ class TestCache extends WriteThroughReadThroughCache { } @Cache() -class MinuteTtlTestCache extends WriteThroughReadThroughCache { +class MinuteTtlTestCache extends WriteThroughReadThroughCache { constructor( @Inject(ZIBRI_DI_TOKENS.LOGGER) protected readonly logger: LoggerInterface, @@ -44,7 +44,7 @@ class MinuteTtlTestCache extends WriteThroughReadThroughCache { } @Cache() -class MinuteTtlIdTestCache extends WriteThroughReadThroughCache { +class MinuteTtlIdTestCache extends WriteThroughReadThroughCache { constructor( @Inject(ZIBRI_DI_TOKENS.LOGGER) protected readonly logger: LoggerInterface, @@ -58,7 +58,7 @@ class MinuteTtlIdTestCache extends WriteThroughReadThroughCache { +class MainCacheTestCache extends WriteThroughReadThroughCache { constructor( @Inject(ZIBRI_DI_TOKENS.LOGGER) protected readonly logger: LoggerInterface, @@ -72,7 +72,7 @@ class MainCacheTestCache extends WriteThroughReadThroughCache { +class AffectedCache1TestCache extends WriteThroughReadThroughCache { constructor( @Inject(ZIBRI_DI_TOKENS.LOGGER) protected readonly logger: LoggerInterface, @@ -86,7 +86,7 @@ class AffectedCache1TestCache extends WriteThroughReadThroughCache { +class UnaffectedCache1TestCache extends WriteThroughReadThroughCache { constructor( @Inject(ZIBRI_DI_TOKENS.LOGGER) protected readonly logger: LoggerInterface, @@ -100,7 +100,7 @@ class UnaffectedCache1TestCache extends WriteThroughReadThroughCache { +class OwnCacheTestCache extends WriteThroughReadThroughCache { constructor( @Inject(ZIBRI_DI_TOKENS.LOGGER) protected readonly logger: LoggerInterface, @@ -114,7 +114,7 @@ class OwnCacheTestCache extends WriteThroughReadThroughCache { } @Cache() -class UnaffectedCache2TestCache extends WriteThroughReadThroughCache { +class UnaffectedCache2TestCache extends WriteThroughReadThroughCache { constructor( @Inject(ZIBRI_DI_TOKENS.LOGGER) protected readonly logger: LoggerInterface, @@ -128,7 +128,7 @@ class UnaffectedCache2TestCache extends WriteThroughReadThroughCache { +class AbcCacheTestCache extends WriteThroughReadThroughCache { constructor( @Inject(ZIBRI_DI_TOKENS.LOGGER) protected readonly logger: LoggerInterface, @@ -224,7 +224,7 @@ describe('WriteThroughReadThroughCache', () => { jest.setSystemTime(new Date('2025-01-01T00:00:00.000Z')); try { - const cache: WriteThroughReadThroughCache = inject(MinuteTtlIdTestCache); + const cache: MinuteTtlIdTestCache = inject(MinuteTtlIdTestCache); // eslint-disable-next-line typescript/typedef const fn = (name: string): { id: string } => ({ id: `id:${name}` }); @@ -256,7 +256,7 @@ describe('WriteThroughReadThroughCache', () => { jest.setSystemTime(new Date('2025-01-01T00:00:00.000Z')); try { - const cache: WriteThroughReadThroughCache = inject(MinuteTtlTestCache); + const cache: MinuteTtlTestCache = inject(MinuteTtlTestCache); // eslint-disable-next-line typescript/typedef const fn = (id: string): number => id.length; @@ -280,9 +280,9 @@ describe('WriteThroughReadThroughCache', () => { }); it('wrapWrite invalidates matching caches and writes the result', async () => { - const mainCache: WriteThroughReadThroughCache = inject(MainCacheTestCache); - const affectedCache: WriteThroughReadThroughCache = inject(AffectedCache1TestCache); - const unaffectedCache: WriteThroughReadThroughCache = inject(UnaffectedCache1TestCache); + const mainCache: MainCacheTestCache = inject(MainCacheTestCache); + const affectedCache: AffectedCache1TestCache = inject(AffectedCache1TestCache); + const unaffectedCache: UnaffectedCache1TestCache = inject(UnaffectedCache1TestCache); cacheService.caches.push(mainCache, affectedCache, unaffectedCache); @@ -314,7 +314,7 @@ describe('WriteThroughReadThroughCache', () => { jest.setSystemTime(new Date('2025-01-01T00:00:00.000Z')); try { - const cache: WriteThroughReadThroughCache = inject(MinuteTtlIdTestCache); + const cache: MinuteTtlIdTestCache = inject(MinuteTtlIdTestCache); // eslint-disable-next-line typescript/typedef const fn = (name: string): { id: string } => ({ id: `id:${name}` }); @@ -338,9 +338,9 @@ describe('WriteThroughReadThroughCache', () => { }); it('wrapDelete deletes its own key and invalidates matching caches', async () => { - const cache: WriteThroughReadThroughCache = inject(OwnCacheTestCache); - const affectedCache: WriteThroughReadThroughCache = inject(AffectedCache1TestCache); - const unaffectedCache: WriteThroughReadThroughCache = inject(UnaffectedCache2TestCache); + const cache: OwnCacheTestCache = inject(OwnCacheTestCache); + const affectedCache: AffectedCache1TestCache = inject(AffectedCache1TestCache); + const unaffectedCache: UnaffectedCache2TestCache = inject(UnaffectedCache2TestCache); cacheService.caches.push(cache, affectedCache, unaffectedCache); @@ -368,8 +368,8 @@ describe('WriteThroughReadThroughCache', () => { }); it('wrapInvalidate calls fn and then invalidates tags', async () => { - const cache: WriteThroughReadThroughCache = inject(TestCache); - const affectedCache: WriteThroughReadThroughCache = inject(AffectedCache1TestCache); + const cache: TestCache = inject(TestCache); + const affectedCache: AffectedCache1TestCache = inject(AffectedCache1TestCache); cacheService.caches.push(cache, affectedCache); @@ -387,9 +387,9 @@ describe('WriteThroughReadThroughCache', () => { }); it('wrapInvalidate supports tag providers based on args', async () => { - const cache: WriteThroughReadThroughCache = inject(TestCache); + const cache: TestCache = inject(TestCache); - const affectedCache: WriteThroughReadThroughCache = inject(AbcCacheTestCache); + const affectedCache: AbcCacheTestCache = inject(AbcCacheTestCache); cacheService.caches.push(cache, affectedCache); await affectedCache.store.set('a1', createCachedValue('affected', ['tag:abc'], new Date())); diff --git a/src/caching/cache/read-through/write-through-read-through.cache.ts b/src/caching/cache/read-through/write-through-read-through.cache.ts index 56251e0..4737a99 100644 --- a/src/caching/cache/read-through/write-through-read-through.cache.ts +++ b/src/caching/cache/read-through/write-through-read-through.cache.ts @@ -1,16 +1,19 @@ import { AlsUtilities } from '../../../context/als.utilities'; import { LogCacheContext } from '../../../logging/log-context.model'; import { CacheOperation } from '../cache-operation.enum'; -import { ResultCacheKeyProvider, CacheWrapWriteOptionsWithResult } from '../cache-options.model'; +import { ResultCacheKeyProvider, CacheWrapWriteOptionsWithResult, CacheSetDirectOptions } from '../cache-options.model'; import { CacheInterface } from '../cache.interface'; import { ReadThroughCache } from './read-through.cache'; /** * A write-through read-through cache. */ -export abstract class WriteThroughReadThroughCache - extends ReadThroughCache - implements CacheInterface { +export abstract class WriteThroughReadThroughCache + extends ReadThroughCache + implements CacheInterface { + + // eslint-disable-next-line jsdoc/require-jsdoc + readonly _writeResultAvailable: true = true; // eslint-disable-next-line jsdoc/require-jsdoc wrapWrite( @@ -31,10 +34,12 @@ export abstract class WriteThroughReadThroughCache): Promise { + try { + const ttl: number | undefined = options?.ttl ?? await this.resolveResultTtl(undefined, value, []); + const tags: CacheTag[] = options?.tags ?? []; + await this.store.set(key, this.createCachedValue(value, tags, ttl)); + this.metrics.writes.increase({ cache: this.name }); + await this.updateSizeGauge(); + } + catch (error) { + this.metrics.errors.increase({ cache: this.name, operation: 'set' }); + await this.logger.warn('Cache store failed in setDirect', { error }); + } + } } \ No newline at end of file diff --git a/src/caching/decorators/cache-delete.decorator.ts b/src/caching/decorators/cache-delete.decorator.ts index deed39c..bf4d810 100644 --- a/src/caching/decorators/cache-delete.decorator.ts +++ b/src/caching/decorators/cache-delete.decorator.ts @@ -1,7 +1,6 @@ import { inject } from '../../di/inject.function'; import { DiToken } from '../../di/models/di-token.model'; import { CacheKeyProvider, CacheWrapDeleteOptions } from '../cache/cache-options.model'; -import { CacheInterface } from '../cache/cache.interface'; // eslint-disable-next-line jsdoc/require-returns /** @@ -10,8 +9,15 @@ import { CacheInterface } from '../cache/cache.interface'; * @param keyFn - How to resolve the key that should be deleted from the cache. * @param options - Additional options like tags to invalidate. */ -export function CacheDelete( - cacheToken: DiToken, 'wrapDelete'>>, +export function CacheDelete< + // eslint-disable-next-line jsdoc/require-jsdoc, typescript/no-explicit-any + C extends { wrapDelete: (...args: any[]) => any }, + K, + CacheTag extends string, + TReturn, + TArgs extends unknown[] +>( + cacheToken: DiToken, keyFn: CacheKeyProvider, options?: CacheWrapDeleteOptions ) { @@ -26,11 +32,13 @@ export function CacheDelete { if (!wrappedFns.has(this)) { - const cache: Pick, 'wrapDelete'> = inject(cacheToken); + // eslint-disable-next-line typescript/typedef + const cache = inject(cacheToken); + // eslint-disable-next-line typescript/no-unsafe-argument wrappedFns.set(this, cache.wrapDelete(original.bind(this), keyFn, options)); } // eslint-disable-next-line typescript/no-non-null-assertion - return wrappedFns.get(this)!(...args); + return await wrappedFns.get(this)!(...args); }; return descriptor; diff --git a/src/caching/decorators/cache-invalidate.decorator.ts b/src/caching/decorators/cache-invalidate.decorator.ts index a58e1b3..f00110a 100644 --- a/src/caching/decorators/cache-invalidate.decorator.ts +++ b/src/caching/decorators/cache-invalidate.decorator.ts @@ -1,7 +1,6 @@ import { inject } from '../../di/inject.function'; import { DiToken } from '../../di/models/di-token.model'; import { CacheWrapInvalidateOptions } from '../cache/cache-options.model'; -import { CacheInterface } from '../cache/cache.interface'; // eslint-disable-next-line jsdoc/require-returns /** @@ -9,8 +8,14 @@ import { CacheInterface } from '../cache/cache.interface'; * @param cacheToken - The token of the cache to use. * @param options - Additional options, including tags to invalidate. */ -export function CacheInvalidate( - cacheToken: DiToken, 'wrapInvalidate'>>, +export function CacheInvalidate< + // eslint-disable-next-line jsdoc/require-jsdoc, typescript/no-explicit-any + C extends { wrapInvalidate: (...args: any[]) => any }, + CacheTag extends string, + TReturn, + TArgs extends unknown[] +>( + cacheToken: DiToken, options: CacheWrapInvalidateOptions ) { return ( @@ -24,7 +29,9 @@ export function CacheInvalidate { if (!wrappedFns.has(this)) { - const cache: Pick, 'wrapInvalidate'> = inject(cacheToken); + // eslint-disable-next-line typescript/typedef + const cache = inject(cacheToken); + // eslint-disable-next-line typescript/no-unsafe-argument wrappedFns.set(this, cache.wrapInvalidate(original.bind(this), options)); } // eslint-disable-next-line typescript/no-non-null-assertion diff --git a/src/caching/decorators/cache-write.decorator.ts b/src/caching/decorators/cache-write.decorator.ts index 9a99dad..b628a8d 100644 --- a/src/caching/decorators/cache-write.decorator.ts +++ b/src/caching/decorators/cache-write.decorator.ts @@ -1,70 +1,60 @@ +import { ExtractCacheWriteResultAvailable } from './decorator-types'; import { inject } from '../../di/inject.function'; import { DiToken } from '../../di/models/di-token.model'; import { CacheKeyProvider, CacheWrapWriteOptionsArgsOnly, CacheWrapWriteOptionsWithResult, ResultCacheKeyProvider } from '../cache/cache-options.model'; import { CacheInterface } from '../cache/cache.interface'; +import { MultiTierCache, MultiWrapWriteOptions } from '../cache/multi-tier.cache'; +/** Extract the wrapWrite options type for a given cache and TArgs. */ +type ExtractWrapWriteOptions + // eslint-disable-next-line typescript/no-explicit-any + = C extends MultiTierCache + ? MultiWrapWriteOptions + // eslint-disable-next-line typescript/no-explicit-any + : C extends Pick, 'wrapWrite'> + ? (WR extends true + ? CacheWrapWriteOptionsWithResult + : CacheWrapWriteOptionsArgsOnly) + : never; + +// eslint-disable-next-line jsdoc/require-returns /** * Marks the method to write the result to the cache using the provided cache. * @param cacheToken - The token of the cache to use. * @param keyFn - How to resolve the key under which results should be cached. * @param options - Additional options like ttl or tags. */ -export function CacheWrite< - K, - V, - CacheTag extends string, - TArgs extends unknown[] ->( - cacheToken: DiToken, 'wrapWrite'>>, - keyFn: ResultCacheKeyProvider, - options?: CacheWrapWriteOptionsWithResult -): MethodDecorator; -export function CacheWrite< - K, - V, - CacheTag extends string, - TArgs extends unknown[] ->( - cacheToken: DiToken, 'wrapWrite'>>, - keyFn: CacheKeyProvider, - options?: CacheWrapWriteOptionsArgsOnly -): MethodDecorator; -export function CacheWrite< - K, - V, - CacheTag extends string, - TArgs extends unknown[] ->( - cacheToken: DiToken, 'wrapWrite'>>, - keyFn: CacheKeyProvider | ResultCacheKeyProvider, - options?: CacheWrapWriteOptionsArgsOnly - | CacheWrapWriteOptionsWithResult -): MethodDecorator { - return (( +// eslint-disable-next-line jsdoc/require-jsdoc, typescript/no-explicit-any +export function CacheWrite any }, K, V, TArgs extends unknown[]>( + cacheToken: DiToken, + keyFn: ExtractCacheWriteResultAvailable extends true + ? ResultCacheKeyProvider + : CacheKeyProvider, + options?: ExtractWrapWriteOptions +) { + return function decorator( target: object, propertyKey: string | symbol, descriptor: TypedPropertyDescriptor<(...args: TArgs) => Promise> - ): TypedPropertyDescriptor<(...args: TArgs) => Promise> => { + ): TypedPropertyDescriptor<(...args: TArgs) => Promise> { // eslint-disable-next-line typescript/no-non-null-assertion const original: (...args: TArgs) => Promise = descriptor.value!; const wrappedFns: WeakMap Promise> = new WeakMap(); descriptor.value = async function(this: object, ...args: TArgs): Promise { - let wrapped: ((...args: TArgs) => Promise) | undefined = wrappedFns.get(this); - - if (wrapped === undefined) { - const cache: Pick, 'wrapWrite'> = inject(cacheToken); - wrapped = cache.wrapWrite( - original.bind(this), - keyFn as never, - options as never + if (!wrappedFns.has(this)) { + // eslint-disable-next-line typescript/typedef + const cache = inject(cacheToken); + wrappedFns.set( + this, + // eslint-disable-next-line typescript/no-unsafe-argument + cache.wrapWrite(original.bind(this), keyFn, options) ); - wrappedFns.set(this, wrapped); } - - return wrapped(...args); + // eslint-disable-next-line typescript/no-non-null-assertion + return wrappedFns.get(this)!(...args); }; return descriptor; - }) as MethodDecorator; + }; } \ No newline at end of file diff --git a/src/caching/decorators/cache.decorator.ts b/src/caching/decorators/cache.decorator.ts index 25b6626..8e85376 100644 --- a/src/caching/decorators/cache.decorator.ts +++ b/src/caching/decorators/cache.decorator.ts @@ -1,7 +1,7 @@ import { GlobalRegistry } from '../../global/global-registry'; import { Newable } from '../../types/newable.type'; import { MetadataUtilities } from '../../utilities/metadata.utilities'; -import { CacheInterface } from '../cache/cache.interface'; +import { AnyCache } from '../cache/cache.interface'; /** * Marks a class that should be used as a cache. @@ -15,7 +15,6 @@ export function Cache(): ClassDecorator { token: target as unknown as Newable, useClass: target as unknown as Newable }); - // eslint-disable-next-line typescript/no-explicit-any - GlobalRegistry.cacheClasses.push(target as unknown as Newable>); + GlobalRegistry.cacheClasses.push(target as unknown as Newable); }; } \ No newline at end of file diff --git a/src/caching/decorators/cached.decorator.ts b/src/caching/decorators/cached.decorator.ts index 2ecefda..e3662a5 100644 --- a/src/caching/decorators/cached.decorator.ts +++ b/src/caching/decorators/cached.decorator.ts @@ -2,6 +2,17 @@ import { inject } from '../../di/inject.function'; import { DiToken } from '../../di/models/di-token.model'; import { CacheKeyProvider, CacheWrapOptions } from '../cache/cache-options.model'; import { CacheInterface } from '../cache/cache.interface'; +import { MultiTierCache, MultiWrapOptions } from '../cache/multi-tier.cache'; + +// eslint-disable-next-line jsdoc/require-jsdoc +type ExtractWrapOptions + // eslint-disable-next-line typescript/no-explicit-any + = C extends MultiTierCache + ? MultiWrapOptions + // eslint-disable-next-line typescript/no-explicit-any + : C extends Pick, 'wrap'> + ? CacheWrapOptions + : never; // eslint-disable-next-line jsdoc/require-returns /** @@ -10,10 +21,11 @@ import { CacheInterface } from '../cache/cache.interface'; * @param keyFn - How to resolve the key under which results are cached. * @param options - Additional options like ttl or tags. */ -export function Cached( - cacheToken: DiToken, 'wrap'>>, +// eslint-disable-next-line jsdoc/require-jsdoc, typescript/no-explicit-any +export function Cached any }, K, V, TArgs extends unknown[]>( + cacheToken: DiToken, keyFn: CacheKeyProvider, - options?: CacheWrapOptions + options?: ExtractWrapOptions ) { return ( target: object, @@ -21,16 +33,18 @@ export function Cached Promise> ): TypedPropertyDescriptor<(...args: TArgs) => Promise> => { // eslint-disable-next-line typescript/no-non-null-assertion - const original: (...args: TArgs) => Promise = descriptor.value!; + const original: (...args: TArgs) => V | Promise = descriptor.value!; const wrappedFns: WeakMap Promise> = new WeakMap(); descriptor.value = async function(this: object, ...args: TArgs): Promise { if (!wrappedFns.has(this)) { - const cache: Pick, 'wrap'> = inject(cacheToken); + // eslint-disable-next-line typescript/typedef + const cache = inject(cacheToken); + // eslint-disable-next-line typescript/no-unsafe-argument wrappedFns.set(this, cache.wrap(original.bind(this), keyFn, options)); } // eslint-disable-next-line typescript/no-non-null-assertion - return wrappedFns.get(this)!(...args); + return await wrappedFns.get(this)!(...args); }; return descriptor; diff --git a/src/caching/decorators/decorator-types.ts b/src/caching/decorators/decorator-types.ts new file mode 100644 index 0000000..037ad1c --- /dev/null +++ b/src/caching/decorators/decorator-types.ts @@ -0,0 +1,20 @@ +import { CacheInterface } from '../cache/cache.interface'; +import { MultiTierCache } from '../cache/multi-tier.cache'; + +/** + * Extracts the key type `K` from a cache-like type. + */ +export type ExtractCacheKey + // eslint-disable-next-line typescript/no-explicit-any + = C extends MultiTierCache + ? K + // eslint-disable-next-line typescript/no-explicit-any + : C extends CacheInterface + ? K + : never; + +/** Extract the WriteResultAvailable flag from a cache-like type. */ +// eslint-disable-next-line jsdoc/require-jsdoc +export type ExtractCacheWriteResultAvailable = C extends { _writeResultAvailable: infer WR } + ? (WR extends boolean ? WR : never) + : never; \ No newline at end of file diff --git a/src/caching/store/in-memory.cache-store.ts b/src/caching/store/in-memory.cache-store.ts index c097e36..20a3b13 100644 --- a/src/caching/store/in-memory.cache-store.ts +++ b/src/caching/store/in-memory.cache-store.ts @@ -1,35 +1,37 @@ import { CacheStoreConfig, CacheStoreInterface } from './cache-store.interface'; import { CachedValue } from './cached-value.model'; import { Bytes } from '../../utilities/bytes'; +import { DoublyLinkedList, LinkedListNode } from '../../utilities/doubly-linked-list'; import { JsonUtilities } from '../../utilities/json.utilities'; import { NumberUtilities } from '../../utilities/number.utilities'; /** * Internal linked list node. */ -type INode = { - // eslint-disable-next-line jsdoc/require-jsdoc - key: K, - // eslint-disable-next-line jsdoc/require-jsdoc +interface CacheEntry { + /** The node inside the order list (holds the key). */ + orderNode: LinkedListNode, + /** The actual cached value. */ value: CachedValue, - // eslint-disable-next-line jsdoc/require-jsdoc - prev: INode | undefined, - // eslint-disable-next-line jsdoc/require-jsdoc - next: INode | undefined, - // eslint-disable-next-line jsdoc/require-jsdoc - frequency: number, - // eslint-disable-next-line jsdoc/require-jsdoc - byteSize: number -}; + /** Estimated size in bytes (for byte‑based eviction). */ + byteSize: number, + /** Frequency counter (used only by LFU). */ + frequency: number +} /** - * A simple in memory cache store. - * Uses a map internally. + * A simple in‑memory cache store. + * + * Supports LRU, MRU, FIFO and LFU eviction when maxEntries or maxBytes + * are exceeded. The implementation uses a generic {@link DoublyLinkedList} + * to keep O(1) access, removal and reordering. */ export class InMemoryCacheStore implements CacheStoreInterface { - private readonly map: Map> = new Map(); - private head: INode | undefined; // oldest (for LRU/FIFO) - private tail: INode | undefined; // newest (for MRU) + private readonly entries: Map> = new Map(); + + /** Order list – used by LRU, MRU, FIFO. For LFU it’s still maintained but not used for eviction. */ + private readonly orderList: DoublyLinkedList = new DoublyLinkedList(); + private currentBytes: number = 0; // LFU helpers @@ -50,20 +52,20 @@ export class InMemoryCacheStore implements CacheStoreInterface { // eslint-disable-next-line jsdoc/require-jsdoc get(key: K): CachedValue | undefined { - const node: INode | undefined = this.map.get(key); - if (!node) { + const entry: CacheEntry | undefined = this.entries.get(key); + if (!entry) { return undefined; } - this.recordAccess(node); - return node.value; + this.recordAccess(entry); + return entry.value; } // eslint-disable-next-line jsdoc/require-jsdoc set(key: K, value: CachedValue): void { - const existing: INode | undefined = this.map.get(key); + const existing: CacheEntry | undefined = this.entries.get(key); + if (existing) { - // Update existing entry this.currentBytes = NumberUtilities.subtract(this.currentBytes, existing.byteSize).toNumber(); existing.value = value; existing.byteSize = this.estimateBytes(key, value); @@ -74,112 +76,104 @@ export class InMemoryCacheStore implements CacheStoreInterface { const byteSize: number = this.estimateBytes(key, value); - // Evict until both constraints are satisfied + // Evict until there is enough space while ( this.size() >= this.config.maxEntries || NumberUtilities.add(this.currentBytes, byteSize).comparedTo(this.config.maxBytes) === 1 ) { - if (this.map.size <= 0) { + if (this.entries.size <= 0) { // a single entry exceeds the cache, skip caching for it return; } this.evictOne(); } - const node: INode = { - key, + const orderNode: LinkedListNode = this.orderList.addLast(key); + + const entry: CacheEntry = { + orderNode, value, - prev: undefined, - next: undefined, - frequency: 1, - byteSize + byteSize, + frequency: 1 }; - this.map.set(key, node); - this.addToTail(node); + this.entries.set(key, entry); this.currentBytes = NumberUtilities.add(this.currentBytes, byteSize).toNumber(); // LFU initialization if (this.config.removeOnOverflow === 'leastFrequentlyUsed') { - this.increaseFrequency(node); // will set minFrequency if needed + this.increaseFrequency(entry); } } // eslint-disable-next-line jsdoc/require-jsdoc delete(key: K): void { - const node: INode | undefined = this.map.get(key); - if (!node) { + const entry: CacheEntry | undefined = this.entries.get(key); + if (!entry) { return; } - this.removeNode(node); - this.map.delete(key); + // Remove from order list + this.orderList.remove(entry.orderNode); + + // Subtract bytes + this.currentBytes = NumberUtilities.subtract(this.currentBytes, entry.byteSize).toNumber(); + + // LFU cleanup + if (this.config.removeOnOverflow === 'leastFrequentlyUsed') { + this.decreaseFrequency(entry); + } + + this.entries.delete(key); } // eslint-disable-next-line jsdoc/require-jsdoc has(key: K): boolean { - return this.map.has(key); + return this.entries.has(key); } // eslint-disable-next-line jsdoc/require-jsdoc clear(): void { - this.map.clear(); + this.entries.clear(); + this.orderList.clear(); this.frequencyMap.clear(); - this.head = undefined; - this.tail = undefined; this.currentBytes = 0; this.minFrequency = 0; } // eslint-disable-next-line jsdoc/require-jsdoc size(): number { - return this.map.size; + return this.entries.size; } // eslint-disable-next-line jsdoc/require-jsdoc invalidateTags(tags: string[]): void { - for (const [key, node] of this.map.entries()) { - if (node.value.tags.some(t => tags.includes(t))) { + // Safe iteration – we delete while iterating + for (const [key, entry] of this.entries.entries()) { + if (entry.value.tags.some(t => tags.includes(t))) { this.delete(key); } } } - private estimateBytes(key: K, value: CachedValue): number { - try { - return Buffer.byteLength(JsonUtilities.stringify({ key, value }), 'utf8'); - } - catch { - // non-serializable values — fall back to a conservative fixed estimate - return 1024; - } - } - private evictOne(): void { switch (this.config.removeOnOverflow) { - case 'leastRecentlyUsed': { - if (this.head) { - this.delete(this.head.key); + case 'leastRecentlyUsed': + case 'firstInFirstOut': { + // Both evict the head of the order list + if (this.orderList.head) { + this.delete(this.orderList.head.value); } break; } case 'mostRecentlyUsed': { - if (this.tail) { - this.delete(this.tail.key); - } - break; - } - // eslint-disable-next-line sonar/no-duplicated-branches - case 'firstInFirstOut': { - // Same as LRU but we never call recordAccess on get() → head remains the oldest insertion. - // Because we still call recordAccess on set(), we must disable moving: - // we handle FIFO specially in recordAccess. - if (this.head) { - this.delete(this.head.key); + // Evict the tail (most recently used) + if (this.orderList.tail) { + this.delete(this.orderList.tail.value); } break; } case 'leastFrequentlyUsed': { - // Remove any key from the set of minimum frequency + // Remove any key from the current minimum‑frequency bucket const minSet: Set | undefined = this.frequencyMap.get(this.minFrequency); if (minSet && minSet.size > 0) { // eslint-disable-next-line typescript/no-non-null-assertion @@ -187,57 +181,20 @@ export class InMemoryCacheStore implements CacheStoreInterface { this.delete(keyToEvict); return; } - if (this.head) { - this.delete(this.head.key); + if (this.orderList.head) { + this.delete(this.orderList.head.value); } break; } } } - private addToTail(node: INode): void { - if (!this.tail) { - this.head = node; - this.tail = node; - node.prev = undefined; - node.next = undefined; - return; - } - - node.prev = this.tail; - node.next = undefined; - this.tail.next = node; - this.tail = node; - } - - private removeNode(node: INode): void { - this.currentBytes = NumberUtilities.subtract(this.currentBytes, node.byteSize).toNumber(); - if (node.prev) { - node.prev.next = node.next; - } - else { - this.head = node.next; - } - if (node.next) { - node.next.prev = node.prev; - } - else { - this.tail = node.prev; - } - - // LFU cleanup - if (this.config.removeOnOverflow === 'leastFrequentlyUsed') { - this.decreaseFreq(node); - } - } - - private recordAccess(node: INode): void { + private recordAccess(entry: CacheEntry): void { switch (this.config.removeOnOverflow) { case 'leastRecentlyUsed': case 'mostRecentlyUsed': { // Move to tail (mark as most recent) - this.removeNodeFromList(node); - this.addToTail(node); + this.orderList.moveToTail(entry.orderNode); break; } case 'firstInFirstOut': { @@ -246,21 +203,21 @@ export class InMemoryCacheStore implements CacheStoreInterface { } case 'leastFrequentlyUsed': { // Increase frequency - this.increaseFrequency(node); + this.increaseFrequency(entry); break; } } } - private increaseFrequency(node: INode): void { - const oldFrequency: number = node.frequency; + private increaseFrequency(entry: CacheEntry): void { + const oldFrequency: number = entry.frequency; const newFrequency: number = oldFrequency + 1; - node.frequency = newFrequency; + entry.frequency = newFrequency; // remove from old frequency set const oldSet: Set | undefined = this.frequencyMap.get(oldFrequency); if (oldSet) { - oldSet.delete(node.key); + oldSet.delete(entry.orderNode.value); if (oldSet.size === 0) { this.frequencyMap.delete(oldFrequency); if (oldFrequency === this.minFrequency) { @@ -278,54 +235,38 @@ export class InMemoryCacheStore implements CacheStoreInterface { this.frequencyMap.set(newFrequency, new Set()); } // eslint-disable-next-line typescript/no-non-null-assertion - this.frequencyMap.get(newFrequency)!.add(node.key); + this.frequencyMap.get(newFrequency)!.add(entry.orderNode.value); if (newFrequency < this.minFrequency || this.minFrequency === 0) { this.minFrequency = newFrequency; } } - private decreaseFreq(node: INode): void { - // Called when a node is removed completely (delete, evict). - // We don't decrease frequency; we just clean up the frequency set. - const frequency: number = node.frequency; + private decreaseFrequency(entry: CacheEntry): void { + // Called when a node is removed – just clean up the frequency sets. + const frequency: number = entry.frequency; const set: Set | undefined = this.frequencyMap.get(frequency); - if (!set) { return; } - set.delete(node.key); - - if (set.size !== 0) { - return; - } - - this.frequencyMap.delete(frequency); - - if (frequency !== this.minFrequency) { - return; - } - - this.minFrequency = Math.min(...this.frequencyMap.keys()); - - if (!Number.isFinite(this.minFrequency)) { - this.minFrequency = 0; + set.delete(entry.orderNode.value); + if (set.size === 0) { + this.frequencyMap.delete(frequency); + if (frequency === this.minFrequency) { + this.minFrequency = Math.min(...this.frequencyMap.keys()); + if (!Number.isFinite(this.minFrequency)) { + this.minFrequency = 0; + } + } } } - // Small helper to remove a node from the list without deleting metadata - private removeNodeFromList(node: INode): void { - if (node.prev) { - node.prev.next = node.next; - } - else { - this.head = node.next; - } - if (node.next) { - node.next.prev = node.prev; + private estimateBytes(key: K, value: CachedValue): number { + try { + return Buffer.byteLength(JsonUtilities.stringify({ key, value }), 'utf8'); } - else { - this.tail = node.prev; + catch { + return 1024; } } } \ No newline at end of file diff --git a/src/context/request/http-request.context.test.ts b/src/context/request/http-request.context.test.ts index 1ba4816..57aed9b 100644 --- a/src/context/request/http-request.context.test.ts +++ b/src/context/request/http-request.context.test.ts @@ -68,7 +68,7 @@ describe('request context integration', () => { controllers: [ContextTestController] }); baseUrl = await server.start(); - }, 10000); + }, 15000); afterAll(async () => { await server.shutdown(); From 9299b8a7de607e4f24b63b139162fbaf170ca2ae Mon Sep 17 00:00:00 2001 From: Tim Fabian Date: Thu, 7 May 2026 01:23:24 +0200 Subject: [PATCH 6/6] disabled linting --- src/caching/cache/multi-tier.cache.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/caching/cache/multi-tier.cache.ts b/src/caching/cache/multi-tier.cache.ts index 19bc44f..8efe345 100644 --- a/src/caching/cache/multi-tier.cache.ts +++ b/src/caching/cache/multi-tier.cache.ts @@ -194,6 +194,7 @@ export abstract class MultiTierCache< : await (keyFn as CacheKeyProvider)(...args); const perTier: Partial, CacheWrapOptions>> | undefined = options?.perCache; + // eslint-disable-next-line sonar/cognitive-complexity await Promise.all(this.tiers.map(async (tier) => { const tierOpt: CacheWrapWriteOptionsWithResult | CacheWrapWriteOptionsArgsOnly